mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-16 18:03:56 +00:00
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:
parent
32f3c6c3bc
commit
f45a467484
139 changed files with 9768 additions and 7204 deletions
|
|
@ -2,4 +2,4 @@ import { db } from "rivetkit/db/drizzle";
|
|||
import * as schema from "./schema.js";
|
||||
import migrations from "./migrations.js";
|
||||
|
||||
export const authUserDb = db({ schema, migrations });
|
||||
export const auditLogDb = db({ schema, migrations });
|
||||
|
|
@ -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",
|
||||
});
|
||||
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE `events` ADD COLUMN `repo_id` text;
|
||||
|
|
@ -1,48 +1,31 @@
|
|||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "6ffd6acb-e737-46ee-a8fe-fcfddcdd6ea9",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"id": "a1b2c3d4-0001-4000-8000-000000000001",
|
||||
"prevId": "e592c829-141f-4740-88b7-09cf957a4405",
|
||||
"tables": {
|
||||
"repo_meta": {
|
||||
"name": "repo_meta",
|
||||
"events": {
|
||||
"name": "events",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
"autoincrement": true
|
||||
},
|
||||
"remote_url": {
|
||||
"name": "remote_url",
|
||||
"repo_id": {
|
||||
"name": "repo_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_index": {
|
||||
"name": "task_index",
|
||||
"columns": {
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"branch_name": {
|
||||
|
|
@ -52,15 +35,22 @@
|
|||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"kind": {
|
||||
"name": "kind",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"payload_json": {
|
||||
"name": "payload_json",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
|
|
@ -8,6 +8,13 @@
|
|||
"when": 1773376223815,
|
||||
"tag": "0000_fluffy_kid_colt",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1773376223816,
|
||||
"tag": "0001_add_repo_id",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -10,6 +10,12 @@ const journal = {
|
|||
tag: "0000_fluffy_kid_colt",
|
||||
breakpoints: true,
|
||||
},
|
||||
{
|
||||
idx: 1,
|
||||
when: 1773376223816,
|
||||
tag: "0001_add_repo_id",
|
||||
breakpoints: true,
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
|
||||
|
|
@ -24,6 +30,8 @@ export default {
|
|||
\`payload_json\` text NOT NULL,
|
||||
\`created_at\` integer NOT NULL
|
||||
);
|
||||
`,
|
||||
m0001: `ALTER TABLE \`events\` ADD COLUMN \`repo_id\` text;
|
||||
`,
|
||||
} as const,
|
||||
};
|
||||
|
|
@ -2,10 +2,11 @@ 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 history event kind definitions in application code.
|
||||
// Structured by the audit-log event kind definitions in application code.
|
||||
payloadJson: text("payload_json").notNull(),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
});
|
||||
98
foundry/packages/backend/src/actors/audit-log/index.ts
Normal file
98
foundry/packages/backend/src/actors/audit-log/index.ts
Normal 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,
|
||||
}));
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
|
||||
|
||||
export const authUsers = sqliteTable("user", {
|
||||
id: text("id").notNull().primaryKey(),
|
||||
name: text("name").notNull(),
|
||||
email: text("email").notNull(),
|
||||
emailVerified: integer("email_verified").notNull(),
|
||||
image: text("image"),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
export const authSessions = sqliteTable(
|
||||
"session",
|
||||
{
|
||||
id: text("id").notNull().primaryKey(),
|
||||
token: text("token").notNull(),
|
||||
userId: text("user_id").notNull(),
|
||||
expiresAt: integer("expires_at").notNull(),
|
||||
ipAddress: text("ip_address"),
|
||||
userAgent: text("user_agent"),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
tokenIdx: uniqueIndex("session_token_idx").on(table.token),
|
||||
}),
|
||||
);
|
||||
|
||||
export const authAccounts = sqliteTable(
|
||||
"account",
|
||||
{
|
||||
id: text("id").notNull().primaryKey(),
|
||||
accountId: text("account_id").notNull(),
|
||||
providerId: text("provider_id").notNull(),
|
||||
userId: text("user_id").notNull(),
|
||||
accessToken: text("access_token"),
|
||||
refreshToken: text("refresh_token"),
|
||||
idToken: text("id_token"),
|
||||
accessTokenExpiresAt: integer("access_token_expires_at"),
|
||||
refreshTokenExpiresAt: integer("refresh_token_expires_at"),
|
||||
scope: text("scope"),
|
||||
password: text("password"),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
providerAccountIdx: uniqueIndex("account_provider_account_idx").on(table.providerId, table.accountId),
|
||||
}),
|
||||
);
|
||||
|
||||
export const userProfiles = sqliteTable("user_profiles", {
|
||||
userId: text("user_id").notNull().primaryKey(),
|
||||
githubAccountId: text("github_account_id"),
|
||||
githubLogin: text("github_login"),
|
||||
roleLabel: text("role_label").notNull(),
|
||||
eligibleOrganizationIdsJson: text("eligible_organization_ids_json").notNull(),
|
||||
starterRepoStatus: text("starter_repo_status").notNull(),
|
||||
starterRepoStarredAt: integer("starter_repo_starred_at"),
|
||||
starterRepoSkippedAt: integer("starter_repo_skipped_at"),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
export const sessionState = sqliteTable("session_state", {
|
||||
sessionId: text("session_id").notNull().primaryKey(),
|
||||
activeOrganizationId: text("active_organization_id"),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
|
@ -1,353 +0,0 @@
|
|||
import { and, asc, count as sqlCount, desc, eq, gt, gte, inArray, isNotNull, isNull, like, lt, lte, ne, notInArray, or } from "drizzle-orm";
|
||||
import { actor } from "rivetkit";
|
||||
import { authUserDb } from "./db/db.js";
|
||||
import { authAccounts, authSessions, authUsers, sessionState, userProfiles } from "./db/schema.js";
|
||||
|
||||
const tables = {
|
||||
user: authUsers,
|
||||
session: authSessions,
|
||||
account: authAccounts,
|
||||
userProfiles,
|
||||
sessionState,
|
||||
} as const;
|
||||
|
||||
function tableFor(model: string) {
|
||||
const table = tables[model as keyof typeof tables];
|
||||
if (!table) {
|
||||
throw new Error(`Unsupported auth user model: ${model}`);
|
||||
}
|
||||
return table as any;
|
||||
}
|
||||
|
||||
function columnFor(table: any, field: string) {
|
||||
const column = table[field];
|
||||
if (!column) {
|
||||
throw new Error(`Unsupported auth user field: ${field}`);
|
||||
}
|
||||
return column;
|
||||
}
|
||||
|
||||
function normalizeValue(value: unknown): unknown {
|
||||
if (value instanceof Date) {
|
||||
return value.getTime();
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entry) => normalizeValue(entry));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function clauseToExpr(table: any, clause: any) {
|
||||
const column = columnFor(table, clause.field);
|
||||
const value = normalizeValue(clause.value);
|
||||
|
||||
switch (clause.operator) {
|
||||
case "ne":
|
||||
return value === null ? isNotNull(column) : ne(column, value as any);
|
||||
case "lt":
|
||||
return lt(column, value as any);
|
||||
case "lte":
|
||||
return lte(column, value as any);
|
||||
case "gt":
|
||||
return gt(column, value as any);
|
||||
case "gte":
|
||||
return gte(column, value as any);
|
||||
case "in":
|
||||
return inArray(column, Array.isArray(value) ? (value as any[]) : [value as any]);
|
||||
case "not_in":
|
||||
return notInArray(column, Array.isArray(value) ? (value as any[]) : [value as any]);
|
||||
case "contains":
|
||||
return like(column, `%${String(value ?? "")}%`);
|
||||
case "starts_with":
|
||||
return like(column, `${String(value ?? "")}%`);
|
||||
case "ends_with":
|
||||
return like(column, `%${String(value ?? "")}`);
|
||||
case "eq":
|
||||
default:
|
||||
return value === null ? isNull(column) : eq(column, value as any);
|
||||
}
|
||||
}
|
||||
|
||||
function buildWhere(table: any, where: any[] | undefined) {
|
||||
if (!where || where.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let expr = clauseToExpr(table, where[0]);
|
||||
for (const clause of where.slice(1)) {
|
||||
const next = clauseToExpr(table, clause);
|
||||
expr = clause.connector === "OR" ? or(expr, next) : and(expr, next);
|
||||
}
|
||||
return expr;
|
||||
}
|
||||
|
||||
function applyJoinToRow(c: any, model: string, row: any, join: any) {
|
||||
if (!row || !join) {
|
||||
return row;
|
||||
}
|
||||
|
||||
if (model === "session" && join.user) {
|
||||
return c.db
|
||||
.select()
|
||||
.from(authUsers)
|
||||
.where(eq(authUsers.id, row.userId))
|
||||
.get()
|
||||
.then((user: any) => ({ ...row, user: user ?? null }));
|
||||
}
|
||||
|
||||
if (model === "account" && join.user) {
|
||||
return c.db
|
||||
.select()
|
||||
.from(authUsers)
|
||||
.where(eq(authUsers.id, row.userId))
|
||||
.get()
|
||||
.then((user: any) => ({ ...row, user: user ?? null }));
|
||||
}
|
||||
|
||||
if (model === "user" && join.account) {
|
||||
return c.db
|
||||
.select()
|
||||
.from(authAccounts)
|
||||
.where(eq(authAccounts.userId, row.id))
|
||||
.all()
|
||||
.then((accounts: any[]) => ({ ...row, account: accounts }));
|
||||
}
|
||||
|
||||
return Promise.resolve(row);
|
||||
}
|
||||
|
||||
async function applyJoinToRows(c: any, model: string, rows: any[], join: any) {
|
||||
if (!join || rows.length === 0) {
|
||||
return rows;
|
||||
}
|
||||
|
||||
if (model === "session" && join.user) {
|
||||
const userIds = [...new Set(rows.map((row) => row.userId).filter(Boolean))];
|
||||
const users = userIds.length > 0 ? await c.db.select().from(authUsers).where(inArray(authUsers.id, userIds)).all() : [];
|
||||
const userMap = new Map(users.map((user: any) => [user.id, user]));
|
||||
return rows.map((row) => ({ ...row, user: userMap.get(row.userId) ?? null }));
|
||||
}
|
||||
|
||||
if (model === "account" && join.user) {
|
||||
const userIds = [...new Set(rows.map((row) => row.userId).filter(Boolean))];
|
||||
const users = userIds.length > 0 ? await c.db.select().from(authUsers).where(inArray(authUsers.id, userIds)).all() : [];
|
||||
const userMap = new Map(users.map((user: any) => [user.id, user]));
|
||||
return rows.map((row) => ({ ...row, user: userMap.get(row.userId) ?? null }));
|
||||
}
|
||||
|
||||
if (model === "user" && join.account) {
|
||||
const userIds = rows.map((row) => row.id);
|
||||
const accounts = userIds.length > 0 ? await c.db.select().from(authAccounts).where(inArray(authAccounts.userId, userIds)).all() : [];
|
||||
const accountsByUserId = new Map<string, any[]>();
|
||||
for (const account of accounts) {
|
||||
const entries = accountsByUserId.get(account.userId) ?? [];
|
||||
entries.push(account);
|
||||
accountsByUserId.set(account.userId, entries);
|
||||
}
|
||||
return rows.map((row) => ({ ...row, account: accountsByUserId.get(row.id) ?? [] }));
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
export const authUser = actor({
|
||||
db: authUserDb,
|
||||
options: {
|
||||
name: "Auth User",
|
||||
icon: "shield",
|
||||
actionTimeout: 60_000,
|
||||
},
|
||||
createState: (_c, input: { userId: string }) => ({
|
||||
userId: input.userId,
|
||||
}),
|
||||
actions: {
|
||||
async createAuthRecord(c, input: { model: string; data: Record<string, unknown> }) {
|
||||
const table = tableFor(input.model);
|
||||
await c.db
|
||||
.insert(table)
|
||||
.values(input.data as any)
|
||||
.run();
|
||||
return await c.db
|
||||
.select()
|
||||
.from(table)
|
||||
.where(eq(columnFor(table, "id"), input.data.id as any))
|
||||
.get();
|
||||
},
|
||||
|
||||
async findOneAuthRecord(c, input: { model: string; where: any[]; join?: any }) {
|
||||
const table = tableFor(input.model);
|
||||
const predicate = buildWhere(table, input.where);
|
||||
const row = predicate ? await c.db.select().from(table).where(predicate).get() : await c.db.select().from(table).get();
|
||||
return await applyJoinToRow(c, input.model, row ?? null, input.join);
|
||||
},
|
||||
|
||||
async findManyAuthRecords(c, input: { model: string; where?: any[]; limit?: number; offset?: number; sortBy?: any; join?: any }) {
|
||||
const table = tableFor(input.model);
|
||||
const predicate = buildWhere(table, input.where);
|
||||
let query: any = c.db.select().from(table);
|
||||
if (predicate) {
|
||||
query = query.where(predicate);
|
||||
}
|
||||
if (input.sortBy?.field) {
|
||||
const column = columnFor(table, input.sortBy.field);
|
||||
query = query.orderBy(input.sortBy.direction === "asc" ? asc(column) : desc(column));
|
||||
}
|
||||
if (typeof input.limit === "number") {
|
||||
query = query.limit(input.limit);
|
||||
}
|
||||
if (typeof input.offset === "number") {
|
||||
query = query.offset(input.offset);
|
||||
}
|
||||
const rows = await query.all();
|
||||
return await applyJoinToRows(c, input.model, rows, input.join);
|
||||
},
|
||||
|
||||
async updateAuthRecord(c, input: { model: string; where: any[]; update: Record<string, unknown> }) {
|
||||
const table = tableFor(input.model);
|
||||
const predicate = buildWhere(table, input.where);
|
||||
if (!predicate) {
|
||||
throw new Error("updateAuthRecord requires a where clause");
|
||||
}
|
||||
await c.db
|
||||
.update(table)
|
||||
.set(input.update as any)
|
||||
.where(predicate)
|
||||
.run();
|
||||
return await c.db.select().from(table).where(predicate).get();
|
||||
},
|
||||
|
||||
async updateManyAuthRecords(c, input: { model: string; where: any[]; update: Record<string, unknown> }) {
|
||||
const table = tableFor(input.model);
|
||||
const predicate = buildWhere(table, input.where);
|
||||
if (!predicate) {
|
||||
throw new Error("updateManyAuthRecords requires a where clause");
|
||||
}
|
||||
await c.db
|
||||
.update(table)
|
||||
.set(input.update as any)
|
||||
.where(predicate)
|
||||
.run();
|
||||
const row = await c.db.select({ value: sqlCount() }).from(table).where(predicate).get();
|
||||
return row?.value ?? 0;
|
||||
},
|
||||
|
||||
async deleteAuthRecord(c, input: { model: string; where: any[] }) {
|
||||
const table = tableFor(input.model);
|
||||
const predicate = buildWhere(table, input.where);
|
||||
if (!predicate) {
|
||||
throw new Error("deleteAuthRecord requires a where clause");
|
||||
}
|
||||
await c.db.delete(table).where(predicate).run();
|
||||
},
|
||||
|
||||
async deleteManyAuthRecords(c, input: { model: string; where: any[] }) {
|
||||
const table = tableFor(input.model);
|
||||
const predicate = buildWhere(table, input.where);
|
||||
if (!predicate) {
|
||||
throw new Error("deleteManyAuthRecords requires a where clause");
|
||||
}
|
||||
const rows = await c.db.select().from(table).where(predicate).all();
|
||||
await c.db.delete(table).where(predicate).run();
|
||||
return rows.length;
|
||||
},
|
||||
|
||||
async countAuthRecords(c, input: { model: string; where?: any[] }) {
|
||||
const table = tableFor(input.model);
|
||||
const predicate = buildWhere(table, input.where);
|
||||
const row = predicate
|
||||
? await c.db.select({ value: sqlCount() }).from(table).where(predicate).get()
|
||||
: await c.db.select({ value: sqlCount() }).from(table).get();
|
||||
return row?.value ?? 0;
|
||||
},
|
||||
|
||||
async getAppAuthState(c, input: { sessionId: string }) {
|
||||
const session = await c.db.select().from(authSessions).where(eq(authSessions.id, input.sessionId)).get();
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
const [user, profile, currentSessionState, accounts] = await Promise.all([
|
||||
c.db.select().from(authUsers).where(eq(authUsers.id, session.userId)).get(),
|
||||
c.db.select().from(userProfiles).where(eq(userProfiles.userId, session.userId)).get(),
|
||||
c.db.select().from(sessionState).where(eq(sessionState.sessionId, input.sessionId)).get(),
|
||||
c.db.select().from(authAccounts).where(eq(authAccounts.userId, session.userId)).all(),
|
||||
]);
|
||||
return {
|
||||
session,
|
||||
user,
|
||||
profile: profile ?? null,
|
||||
sessionState: currentSessionState ?? null,
|
||||
accounts,
|
||||
};
|
||||
},
|
||||
|
||||
async upsertUserProfile(
|
||||
c,
|
||||
input: {
|
||||
userId: string;
|
||||
patch: {
|
||||
githubAccountId?: string | null;
|
||||
githubLogin?: string | null;
|
||||
roleLabel?: string;
|
||||
eligibleOrganizationIdsJson?: string;
|
||||
starterRepoStatus?: string;
|
||||
starterRepoStarredAt?: number | null;
|
||||
starterRepoSkippedAt?: number | null;
|
||||
};
|
||||
},
|
||||
) {
|
||||
const now = Date.now();
|
||||
await c.db
|
||||
.insert(userProfiles)
|
||||
.values({
|
||||
userId: input.userId,
|
||||
githubAccountId: input.patch.githubAccountId ?? null,
|
||||
githubLogin: input.patch.githubLogin ?? null,
|
||||
roleLabel: input.patch.roleLabel ?? "GitHub user",
|
||||
eligibleOrganizationIdsJson: input.patch.eligibleOrganizationIdsJson ?? "[]",
|
||||
starterRepoStatus: input.patch.starterRepoStatus ?? "pending",
|
||||
starterRepoStarredAt: input.patch.starterRepoStarredAt ?? null,
|
||||
starterRepoSkippedAt: input.patch.starterRepoSkippedAt ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: userProfiles.userId,
|
||||
set: {
|
||||
...(input.patch.githubAccountId !== undefined ? { githubAccountId: input.patch.githubAccountId } : {}),
|
||||
...(input.patch.githubLogin !== undefined ? { githubLogin: input.patch.githubLogin } : {}),
|
||||
...(input.patch.roleLabel !== undefined ? { roleLabel: input.patch.roleLabel } : {}),
|
||||
...(input.patch.eligibleOrganizationIdsJson !== undefined ? { eligibleOrganizationIdsJson: input.patch.eligibleOrganizationIdsJson } : {}),
|
||||
...(input.patch.starterRepoStatus !== undefined ? { starterRepoStatus: input.patch.starterRepoStatus } : {}),
|
||||
...(input.patch.starterRepoStarredAt !== undefined ? { starterRepoStarredAt: input.patch.starterRepoStarredAt } : {}),
|
||||
...(input.patch.starterRepoSkippedAt !== undefined ? { starterRepoSkippedAt: input.patch.starterRepoSkippedAt } : {}),
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
|
||||
return await c.db.select().from(userProfiles).where(eq(userProfiles.userId, input.userId)).get();
|
||||
},
|
||||
|
||||
async upsertSessionState(c, input: { sessionId: string; activeOrganizationId: string | null }) {
|
||||
const now = Date.now();
|
||||
await c.db
|
||||
.insert(sessionState)
|
||||
.values({
|
||||
sessionId: input.sessionId,
|
||||
activeOrganizationId: input.activeOrganizationId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: sessionState.sessionId,
|
||||
set: {
|
||||
activeOrganizationId: input.activeOrganizationId,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
|
||||
return await c.db.select().from(sessionState).where(eq(sessionState.sessionId, input.sessionId)).get();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
import type { TaskStatus, SandboxProviderId } from "@sandbox-agent/foundry-shared";
|
||||
|
||||
export interface TaskCreatedEvent {
|
||||
organizationId: string;
|
||||
repoId: string;
|
||||
taskId: string;
|
||||
sandboxProviderId: SandboxProviderId;
|
||||
branchName: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface TaskStatusEvent {
|
||||
organizationId: string;
|
||||
repoId: string;
|
||||
taskId: string;
|
||||
status: TaskStatus;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface RepositorySnapshotEvent {
|
||||
organizationId: string;
|
||||
repoId: string;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface AgentStartedEvent {
|
||||
organizationId: string;
|
||||
repoId: string;
|
||||
taskId: string;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export interface AgentIdleEvent {
|
||||
organizationId: string;
|
||||
repoId: string;
|
||||
taskId: string;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export interface AgentErrorEvent {
|
||||
organizationId: string;
|
||||
repoId: string;
|
||||
taskId: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface PrCreatedEvent {
|
||||
organizationId: string;
|
||||
repoId: string;
|
||||
taskId: string;
|
||||
prNumber: number;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface PrClosedEvent {
|
||||
organizationId: string;
|
||||
repoId: string;
|
||||
taskId: string;
|
||||
prNumber: number;
|
||||
merged: boolean;
|
||||
}
|
||||
|
||||
export interface PrReviewEvent {
|
||||
organizationId: string;
|
||||
repoId: string;
|
||||
taskId: string;
|
||||
prNumber: number;
|
||||
reviewer: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface CiStatusChangedEvent {
|
||||
organizationId: string;
|
||||
repoId: string;
|
||||
taskId: string;
|
||||
prNumber: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export type TaskStepName = "auto_commit" | "push" | "pr_submit";
|
||||
export type TaskStepStatus = "started" | "completed" | "skipped" | "failed";
|
||||
|
||||
export interface TaskStepEvent {
|
||||
organizationId: string;
|
||||
repoId: string;
|
||||
taskId: string;
|
||||
step: TaskStepName;
|
||||
status: TaskStepStatus;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface BranchSwitchedEvent {
|
||||
organizationId: string;
|
||||
repoId: string;
|
||||
taskId: string;
|
||||
branchName: string;
|
||||
}
|
||||
|
||||
export interface SessionAttachedEvent {
|
||||
organizationId: string;
|
||||
repoId: string;
|
||||
taskId: string;
|
||||
sessionId: string;
|
||||
}
|
||||
|
|
@ -18,6 +18,12 @@ const journal = {
|
|||
tag: "0002_github_branches",
|
||||
breakpoints: true,
|
||||
},
|
||||
{
|
||||
idx: 3,
|
||||
when: 1773907200000,
|
||||
tag: "0003_sync_progress",
|
||||
breakpoints: true,
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
|
||||
|
|
@ -32,7 +38,8 @@ export default {
|
|||
\`installation_id\` integer,
|
||||
\`last_sync_label\` text NOT NULL,
|
||||
\`last_sync_at\` integer,
|
||||
\`updated_at\` integer NOT NULL
|
||||
\`updated_at\` integer NOT NULL,
|
||||
CONSTRAINT \`github_meta_singleton_id_check\` CHECK(\`id\` = 1)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE \`github_repositories\` (
|
||||
|
|
@ -78,6 +85,22 @@ CREATE TABLE \`github_pull_requests\` (
|
|||
\`commit_sha\` text NOT NULL,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
`,
|
||||
m0003: `ALTER TABLE \`github_meta\` ADD \`sync_generation\` integer NOT NULL DEFAULT 0;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE \`github_meta\` ADD \`sync_phase\` text;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE \`github_meta\` ADD \`processed_repository_count\` integer NOT NULL DEFAULT 0;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE \`github_meta\` ADD \`total_repository_count\` integer NOT NULL DEFAULT 0;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE \`github_repositories\` ADD \`sync_generation\` integer NOT NULL DEFAULT 0;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE \`github_members\` ADD \`sync_generation\` integer NOT NULL DEFAULT 0;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE \`github_pull_requests\` ADD \`sync_generation\` integer NOT NULL DEFAULT 0;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE \`github_branches\` ADD \`sync_generation\` integer NOT NULL DEFAULT 0;
|
||||
`,
|
||||
} as const,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,15 +1,24 @@
|
|||
import { integer, sqliteTable, text } from "rivetkit/db/drizzle";
|
||||
import { check, integer, sqliteTable, text } from "rivetkit/db/drizzle";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
export const githubMeta = sqliteTable("github_meta", {
|
||||
id: integer("id").primaryKey(),
|
||||
connectedAccount: text("connected_account").notNull(),
|
||||
installationStatus: text("installation_status").notNull(),
|
||||
syncStatus: text("sync_status").notNull(),
|
||||
installationId: integer("installation_id"),
|
||||
lastSyncLabel: text("last_sync_label").notNull(),
|
||||
lastSyncAt: integer("last_sync_at"),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
export const githubMeta = sqliteTable(
|
||||
"github_meta",
|
||||
{
|
||||
id: integer("id").primaryKey(),
|
||||
connectedAccount: text("connected_account").notNull(),
|
||||
installationStatus: text("installation_status").notNull(),
|
||||
syncStatus: text("sync_status").notNull(),
|
||||
installationId: integer("installation_id"),
|
||||
lastSyncLabel: text("last_sync_label").notNull(),
|
||||
lastSyncAt: integer("last_sync_at"),
|
||||
syncGeneration: integer("sync_generation").notNull(),
|
||||
syncPhase: text("sync_phase"),
|
||||
processedRepositoryCount: integer("processed_repository_count").notNull(),
|
||||
totalRepositoryCount: integer("total_repository_count").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
},
|
||||
(table) => [check("github_meta_singleton_id_check", sql`${table.id} = 1`)],
|
||||
);
|
||||
|
||||
export const githubRepositories = sqliteTable("github_repositories", {
|
||||
repoId: text("repo_id").notNull().primaryKey(),
|
||||
|
|
@ -17,6 +26,7 @@ export const githubRepositories = sqliteTable("github_repositories", {
|
|||
cloneUrl: text("clone_url").notNull(),
|
||||
private: integer("private").notNull(),
|
||||
defaultBranch: text("default_branch").notNull(),
|
||||
syncGeneration: integer("sync_generation").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
|
|
@ -25,6 +35,7 @@ export const githubBranches = sqliteTable("github_branches", {
|
|||
repoId: text("repo_id").notNull(),
|
||||
branchName: text("branch_name").notNull(),
|
||||
commitSha: text("commit_sha").notNull(),
|
||||
syncGeneration: integer("sync_generation").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
|
|
@ -35,6 +46,7 @@ export const githubMembers = sqliteTable("github_members", {
|
|||
email: text("email"),
|
||||
role: text("role"),
|
||||
state: text("state").notNull(),
|
||||
syncGeneration: integer("sync_generation").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
|
|
@ -51,5 +63,6 @@ export const githubPullRequests = sqliteTable("github_pull_requests", {
|
|||
baseRefName: text("base_ref_name").notNull(),
|
||||
authorLogin: text("author_login"),
|
||||
isDraft: integer("is_draft").notNull(),
|
||||
syncGeneration: integer("sync_generation").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
81
foundry/packages/backend/src/actors/github-data/workflow.ts
Normal file
81
foundry/packages/backend/src/actors/github-data/workflow.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
// @ts-nocheck
|
||||
import { logActorWarning, resolveErrorMessage } from "../logging.js";
|
||||
|
||||
// Dynamic imports to break circular dependency: index.ts imports workflow.ts,
|
||||
// and workflow.ts needs functions from index.ts.
|
||||
async function getIndexModule() {
|
||||
return await import("./index.js");
|
||||
}
|
||||
|
||||
export const GITHUB_DATA_QUEUE_NAMES = [
|
||||
"githubData.command.syncRepos",
|
||||
"githubData.command.reloadRepository",
|
||||
"githubData.command.clearState",
|
||||
"githubData.command.handlePullRequestWebhook",
|
||||
] as const;
|
||||
|
||||
export type GithubDataQueueName = (typeof GITHUB_DATA_QUEUE_NAMES)[number];
|
||||
|
||||
export function githubDataWorkflowQueueName(name: GithubDataQueueName): GithubDataQueueName {
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Plain run handler (no workflow engine). Drains the queue using `c.queue.iter()`
|
||||
* with completable messages. This avoids the RivetKit bug where actors created
|
||||
* from another actor's workflow context never start their `run: workflow(...)`.
|
||||
*/
|
||||
export async function runGithubDataCommandLoop(c: any): Promise<void> {
|
||||
for await (const msg of c.queue.iter({ names: [...GITHUB_DATA_QUEUE_NAMES], completable: true })) {
|
||||
try {
|
||||
if (msg.name === "githubData.command.syncRepos") {
|
||||
try {
|
||||
const { runFullSync } = await getIndexModule();
|
||||
await runFullSync(c, msg.body);
|
||||
await msg.complete({ ok: true });
|
||||
} catch (error) {
|
||||
const { fullSyncError } = await getIndexModule();
|
||||
try {
|
||||
await fullSyncError(c, error);
|
||||
} catch {
|
||||
/* best effort */
|
||||
}
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
await msg.complete({ error: message }).catch(() => {});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (msg.name === "githubData.command.reloadRepository") {
|
||||
const { reloadRepositoryMutation } = await getIndexModule();
|
||||
const result = await reloadRepositoryMutation(c, msg.body);
|
||||
await msg.complete(result);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (msg.name === "githubData.command.clearState") {
|
||||
const { clearStateMutation } = await getIndexModule();
|
||||
await clearStateMutation(c, msg.body);
|
||||
await msg.complete({ ok: true });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (msg.name === "githubData.command.handlePullRequestWebhook") {
|
||||
const { handlePullRequestWebhookMutation } = await getIndexModule();
|
||||
await handlePullRequestWebhookMutation(c, msg.body);
|
||||
await msg.complete({ ok: true });
|
||||
continue;
|
||||
}
|
||||
|
||||
logActorWarning("githubData", "unknown queue message", { queueName: msg.name });
|
||||
await msg.complete({ error: `Unknown command: ${msg.name}` });
|
||||
} catch (error) {
|
||||
const message = resolveErrorMessage(error);
|
||||
logActorWarning("githubData", "github-data command failed", {
|
||||
queueName: msg.name,
|
||||
error: message,
|
||||
});
|
||||
await msg.complete({ error: message }).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { authUserKey, githubDataKey, historyKey, organizationKey, repositoryKey, taskKey, taskSandboxKey } from "./keys.js";
|
||||
import { auditLogKey, githubDataKey, organizationKey, taskKey, taskSandboxKey, userKey } from "./keys.js";
|
||||
|
||||
export function actorClient(c: any) {
|
||||
return c.client();
|
||||
|
|
@ -10,28 +10,14 @@ export async function getOrCreateOrganization(c: any, organizationId: string) {
|
|||
});
|
||||
}
|
||||
|
||||
export async function getOrCreateAuthUser(c: any, userId: string) {
|
||||
return await actorClient(c).authUser.getOrCreate(authUserKey(userId), {
|
||||
export async function getOrCreateUser(c: any, userId: string) {
|
||||
return await actorClient(c).user.getOrCreate(userKey(userId), {
|
||||
createWithInput: { userId },
|
||||
});
|
||||
}
|
||||
|
||||
export function getAuthUser(c: any, userId: string) {
|
||||
return actorClient(c).authUser.get(authUserKey(userId));
|
||||
}
|
||||
|
||||
export async function getOrCreateRepository(c: any, organizationId: string, repoId: string, remoteUrl: string) {
|
||||
return await actorClient(c).repository.getOrCreate(repositoryKey(organizationId, repoId), {
|
||||
createWithInput: {
|
||||
organizationId,
|
||||
repoId,
|
||||
remoteUrl,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function getRepository(c: any, organizationId: string, repoId: string) {
|
||||
return actorClient(c).repository.get(repositoryKey(organizationId, repoId));
|
||||
export function getUser(c: any, userId: string) {
|
||||
return actorClient(c).user.get(userKey(userId));
|
||||
}
|
||||
|
||||
export function getTask(c: any, organizationId: string, repoId: string, taskId: string) {
|
||||
|
|
@ -44,11 +30,10 @@ export async function getOrCreateTask(c: any, organizationId: string, repoId: st
|
|||
});
|
||||
}
|
||||
|
||||
export async function getOrCreateHistory(c: any, organizationId: string, repoId: string) {
|
||||
return await actorClient(c).history.getOrCreate(historyKey(organizationId, repoId), {
|
||||
export async function getOrCreateAuditLog(c: any, organizationId: string) {
|
||||
return await actorClient(c).auditLog.getOrCreate(auditLogKey(organizationId), {
|
||||
createWithInput: {
|
||||
organizationId,
|
||||
repoId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -75,8 +60,8 @@ export async function getOrCreateTaskSandbox(c: any, organizationId: string, san
|
|||
});
|
||||
}
|
||||
|
||||
export function selfHistory(c: any) {
|
||||
return actorClient(c).history.getForId(c.actorId);
|
||||
export function selfAuditLog(c: any) {
|
||||
return actorClient(c).auditLog.getForId(c.actorId);
|
||||
}
|
||||
|
||||
export function selfTask(c: any) {
|
||||
|
|
@ -87,12 +72,8 @@ export function selfOrganization(c: any) {
|
|||
return actorClient(c).organization.getForId(c.actorId);
|
||||
}
|
||||
|
||||
export function selfRepository(c: any) {
|
||||
return actorClient(c).repository.getForId(c.actorId);
|
||||
}
|
||||
|
||||
export function selfAuthUser(c: any) {
|
||||
return actorClient(c).authUser.getForId(c.actorId);
|
||||
export function selfUser(c: any) {
|
||||
return actorClient(c).user.getForId(c.actorId);
|
||||
}
|
||||
|
||||
export function selfGithubData(c: any) {
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
import { defineConfig } from "rivetkit/db/drizzle";
|
||||
|
||||
export default defineConfig({
|
||||
out: "./src/actors/history/db/drizzle",
|
||||
schema: "./src/actors/history/db/schema.ts",
|
||||
});
|
||||
|
|
@ -1,115 +0,0 @@
|
|||
// @ts-nocheck
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import { actor, queue } from "rivetkit";
|
||||
import { Loop, workflow } from "rivetkit/workflow";
|
||||
import type { HistoryEvent } from "@sandbox-agent/foundry-shared";
|
||||
import { selfHistory } from "../handles.js";
|
||||
import { historyDb } from "./db/db.js";
|
||||
import { events } from "./db/schema.js";
|
||||
|
||||
export interface HistoryInput {
|
||||
organizationId: string;
|
||||
repoId: string;
|
||||
}
|
||||
|
||||
export interface AppendHistoryCommand {
|
||||
kind: string;
|
||||
taskId?: string;
|
||||
branchName?: string;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ListHistoryParams {
|
||||
branch?: string;
|
||||
taskId?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
const HISTORY_QUEUE_NAMES = ["history.command.append"] as const;
|
||||
|
||||
async function appendHistoryRow(loopCtx: any, body: AppendHistoryCommand): Promise<void> {
|
||||
const now = Date.now();
|
||||
await loopCtx.db
|
||||
.insert(events)
|
||||
.values({
|
||||
taskId: body.taskId ?? null,
|
||||
branchName: body.branchName ?? null,
|
||||
kind: body.kind,
|
||||
payloadJson: JSON.stringify(body.payload),
|
||||
createdAt: now,
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
async function runHistoryWorkflow(ctx: any): Promise<void> {
|
||||
await ctx.loop("history-command-loop", async (loopCtx: any) => {
|
||||
const msg = await loopCtx.queue.next("next-history-command", {
|
||||
names: [...HISTORY_QUEUE_NAMES],
|
||||
completable: true,
|
||||
});
|
||||
if (!msg) {
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
|
||||
if (msg.name === "history.command.append") {
|
||||
await loopCtx.step("append-history-row", async () => appendHistoryRow(loopCtx, msg.body as AppendHistoryCommand));
|
||||
await msg.complete({ ok: true });
|
||||
}
|
||||
|
||||
return Loop.continue(undefined);
|
||||
});
|
||||
}
|
||||
|
||||
export const history = actor({
|
||||
db: historyDb,
|
||||
queues: {
|
||||
"history.command.append": queue(),
|
||||
},
|
||||
options: {
|
||||
name: "History",
|
||||
icon: "database",
|
||||
},
|
||||
createState: (_c, input: HistoryInput) => ({
|
||||
organizationId: input.organizationId,
|
||||
repoId: input.repoId,
|
||||
}),
|
||||
actions: {
|
||||
async append(c, command: AppendHistoryCommand): Promise<void> {
|
||||
const self = selfHistory(c);
|
||||
await self.send("history.command.append", command, { wait: true, timeout: 15_000 });
|
||||
},
|
||||
|
||||
async list(c, params?: ListHistoryParams): Promise<HistoryEvent[]> {
|
||||
const whereParts = [];
|
||||
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,
|
||||
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: c.state.repoId,
|
||||
}));
|
||||
},
|
||||
},
|
||||
run: workflow(runHistoryWorkflow),
|
||||
});
|
||||
|
|
@ -1,9 +1,8 @@
|
|||
import { authUser } from "./auth-user/index.js";
|
||||
import { user } from "./user/index.js";
|
||||
import { setup } from "rivetkit";
|
||||
import { githubData } from "./github-data/index.js";
|
||||
import { task } from "./task/index.js";
|
||||
import { history } from "./history/index.js";
|
||||
import { repository } from "./repository/index.js";
|
||||
import { auditLog } from "./audit-log/index.js";
|
||||
import { taskSandbox } from "./sandbox/index.js";
|
||||
import { organization } from "./organization/index.js";
|
||||
import { logger } from "../logging.js";
|
||||
|
|
@ -21,23 +20,20 @@ export const registry = setup({
|
|||
baseLogger: logger,
|
||||
},
|
||||
use: {
|
||||
authUser,
|
||||
user,
|
||||
organization,
|
||||
repository,
|
||||
task,
|
||||
taskSandbox,
|
||||
history,
|
||||
auditLog,
|
||||
githubData,
|
||||
},
|
||||
});
|
||||
|
||||
export * from "./context.js";
|
||||
export * from "./events.js";
|
||||
export * from "./auth-user/index.js";
|
||||
export * from "./audit-log/index.js";
|
||||
export * from "./user/index.js";
|
||||
export * from "./github-data/index.js";
|
||||
export * from "./task/index.js";
|
||||
export * from "./history/index.js";
|
||||
export * from "./keys.js";
|
||||
export * from "./repository/index.js";
|
||||
export * from "./sandbox/index.js";
|
||||
export * from "./organization/index.js";
|
||||
|
|
|
|||
|
|
@ -4,24 +4,21 @@ export function organizationKey(organizationId: string): ActorKey {
|
|||
return ["org", organizationId];
|
||||
}
|
||||
|
||||
export function authUserKey(userId: string): ActorKey {
|
||||
export function userKey(userId: string): ActorKey {
|
||||
return ["org", "app", "user", userId];
|
||||
}
|
||||
|
||||
export function repositoryKey(organizationId: string, repoId: string): ActorKey {
|
||||
return ["org", organizationId, "repository", repoId];
|
||||
}
|
||||
|
||||
export function taskKey(organizationId: string, repoId: string, taskId: string): ActorKey {
|
||||
return ["org", organizationId, "repository", repoId, "task", taskId];
|
||||
return ["org", organizationId, "task", repoId, taskId];
|
||||
}
|
||||
|
||||
export function taskSandboxKey(organizationId: string, sandboxId: string): ActorKey {
|
||||
return ["org", organizationId, "sandbox", sandboxId];
|
||||
}
|
||||
|
||||
export function historyKey(organizationId: string, repoId: string): ActorKey {
|
||||
return ["org", organizationId, "repository", repoId, "history"];
|
||||
/** One audit log per org (not per repo) — see audit-log/index.ts for rationale. */
|
||||
export function auditLogKey(organizationId: string): ActorKey {
|
||||
return ["org", organizationId, "audit-log"];
|
||||
}
|
||||
|
||||
export function githubDataKey(organizationId: string): ActorKey {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1 @@
|
|||
export { organizationAppActions } from "../app-shell.js";
|
||||
|
|
@ -0,0 +1,323 @@
|
|||
import {
|
||||
and,
|
||||
asc,
|
||||
count as sqlCount,
|
||||
desc,
|
||||
eq,
|
||||
gt,
|
||||
gte,
|
||||
inArray,
|
||||
isNotNull,
|
||||
isNull,
|
||||
like,
|
||||
lt,
|
||||
lte,
|
||||
ne,
|
||||
notInArray,
|
||||
or,
|
||||
} from "drizzle-orm";
|
||||
import { authAccountIndex, authEmailIndex, authSessionIndex, authVerification } from "../db/schema.js";
|
||||
import { APP_SHELL_ORGANIZATION_ID } from "../constants.js";
|
||||
|
||||
function assertAppOrganization(c: any): void {
|
||||
if (c.state.organizationId !== APP_SHELL_ORGANIZATION_ID) {
|
||||
throw new Error(`App shell action requires organization ${APP_SHELL_ORGANIZATION_ID}, got ${c.state.organizationId}`);
|
||||
}
|
||||
}
|
||||
|
||||
function organizationAuthColumn(table: any, field: string): any {
|
||||
const column = table[field];
|
||||
if (!column) {
|
||||
throw new Error(`Unknown auth table field: ${field}`);
|
||||
}
|
||||
return column;
|
||||
}
|
||||
|
||||
function normalizeAuthValue(value: unknown): unknown {
|
||||
if (value instanceof Date) {
|
||||
return value.getTime();
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entry) => normalizeAuthValue(entry));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function organizationAuthClause(table: any, clause: { field: string; value: unknown; operator?: string }): any {
|
||||
const column = organizationAuthColumn(table, clause.field);
|
||||
const value = normalizeAuthValue(clause.value);
|
||||
switch (clause.operator) {
|
||||
case "ne":
|
||||
return value === null ? isNotNull(column) : ne(column, value as any);
|
||||
case "lt":
|
||||
return lt(column, value as any);
|
||||
case "lte":
|
||||
return lte(column, value as any);
|
||||
case "gt":
|
||||
return gt(column, value as any);
|
||||
case "gte":
|
||||
return gte(column, value as any);
|
||||
case "in":
|
||||
return inArray(column, Array.isArray(value) ? (value as any[]) : [value as any]);
|
||||
case "not_in":
|
||||
return notInArray(column, Array.isArray(value) ? (value as any[]) : [value as any]);
|
||||
case "contains":
|
||||
return like(column, `%${String(value ?? "")}%`);
|
||||
case "starts_with":
|
||||
return like(column, `${String(value ?? "")}%`);
|
||||
case "ends_with":
|
||||
return like(column, `%${String(value ?? "")}`);
|
||||
case "eq":
|
||||
default:
|
||||
return value === null ? isNull(column) : eq(column, value as any);
|
||||
}
|
||||
}
|
||||
|
||||
function organizationBetterAuthWhere(table: any, clauses: any[] | undefined): any {
|
||||
if (!clauses || clauses.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
let expr = organizationAuthClause(table, clauses[0]);
|
||||
for (const clause of clauses.slice(1)) {
|
||||
const next = organizationAuthClause(table, clause);
|
||||
expr = clause.connector === "OR" ? or(expr, next) : and(expr, next);
|
||||
}
|
||||
return expr;
|
||||
}
|
||||
|
||||
export async function betterAuthUpsertSessionIndexMutation(c: any, input: { sessionId: string; sessionToken: string; userId: string }) {
|
||||
assertAppOrganization(c);
|
||||
|
||||
const now = Date.now();
|
||||
await c.db
|
||||
.insert(authSessionIndex)
|
||||
.values({
|
||||
sessionId: input.sessionId,
|
||||
sessionToken: input.sessionToken,
|
||||
userId: input.userId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: authSessionIndex.sessionId,
|
||||
set: {
|
||||
sessionToken: input.sessionToken,
|
||||
userId: input.userId,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
return await c.db.select().from(authSessionIndex).where(eq(authSessionIndex.sessionId, input.sessionId)).get();
|
||||
}
|
||||
|
||||
export async function betterAuthDeleteSessionIndexMutation(c: any, input: { sessionId?: string; sessionToken?: string }) {
|
||||
assertAppOrganization(c);
|
||||
|
||||
const clauses = [
|
||||
...(input.sessionId ? [{ field: "sessionId", value: input.sessionId }] : []),
|
||||
...(input.sessionToken ? [{ field: "sessionToken", value: input.sessionToken }] : []),
|
||||
];
|
||||
if (clauses.length === 0) {
|
||||
return;
|
||||
}
|
||||
const predicate = organizationBetterAuthWhere(authSessionIndex, clauses);
|
||||
await c.db.delete(authSessionIndex).where(predicate!).run();
|
||||
}
|
||||
|
||||
export async function betterAuthUpsertEmailIndexMutation(c: any, input: { email: string; userId: string }) {
|
||||
assertAppOrganization(c);
|
||||
|
||||
const now = Date.now();
|
||||
await c.db
|
||||
.insert(authEmailIndex)
|
||||
.values({
|
||||
email: input.email,
|
||||
userId: input.userId,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: authEmailIndex.email,
|
||||
set: {
|
||||
userId: input.userId,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
return await c.db.select().from(authEmailIndex).where(eq(authEmailIndex.email, input.email)).get();
|
||||
}
|
||||
|
||||
export async function betterAuthDeleteEmailIndexMutation(c: any, input: { email: string }) {
|
||||
assertAppOrganization(c);
|
||||
await c.db.delete(authEmailIndex).where(eq(authEmailIndex.email, input.email)).run();
|
||||
}
|
||||
|
||||
export async function betterAuthUpsertAccountIndexMutation(
|
||||
c: any,
|
||||
input: { id: string; providerId: string; accountId: string; userId: string },
|
||||
) {
|
||||
assertAppOrganization(c);
|
||||
|
||||
const now = Date.now();
|
||||
await c.db
|
||||
.insert(authAccountIndex)
|
||||
.values({
|
||||
id: input.id,
|
||||
providerId: input.providerId,
|
||||
accountId: input.accountId,
|
||||
userId: input.userId,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: authAccountIndex.id,
|
||||
set: {
|
||||
providerId: input.providerId,
|
||||
accountId: input.accountId,
|
||||
userId: input.userId,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
return await c.db.select().from(authAccountIndex).where(eq(authAccountIndex.id, input.id)).get();
|
||||
}
|
||||
|
||||
export async function betterAuthDeleteAccountIndexMutation(c: any, input: { id?: string; providerId?: string; accountId?: string }) {
|
||||
assertAppOrganization(c);
|
||||
|
||||
if (input.id) {
|
||||
await c.db.delete(authAccountIndex).where(eq(authAccountIndex.id, input.id)).run();
|
||||
return;
|
||||
}
|
||||
if (input.providerId && input.accountId) {
|
||||
await c.db
|
||||
.delete(authAccountIndex)
|
||||
.where(and(eq(authAccountIndex.providerId, input.providerId), eq(authAccountIndex.accountId, input.accountId)))
|
||||
.run();
|
||||
}
|
||||
}
|
||||
|
||||
export async function betterAuthCreateVerificationMutation(c: any, input: { data: Record<string, unknown> }) {
|
||||
assertAppOrganization(c);
|
||||
|
||||
await c.db.insert(authVerification).values(input.data as any).run();
|
||||
return await c.db.select().from(authVerification).where(eq(authVerification.id, input.data.id as string)).get();
|
||||
}
|
||||
|
||||
export async function betterAuthUpdateVerificationMutation(c: any, input: { where: any[]; update: Record<string, unknown> }) {
|
||||
assertAppOrganization(c);
|
||||
|
||||
const predicate = organizationBetterAuthWhere(authVerification, input.where);
|
||||
if (!predicate) {
|
||||
return null;
|
||||
}
|
||||
await c.db.update(authVerification).set(input.update as any).where(predicate).run();
|
||||
return await c.db.select().from(authVerification).where(predicate).get();
|
||||
}
|
||||
|
||||
export async function betterAuthUpdateManyVerificationMutation(c: any, input: { where: any[]; update: Record<string, unknown> }) {
|
||||
assertAppOrganization(c);
|
||||
|
||||
const predicate = organizationBetterAuthWhere(authVerification, input.where);
|
||||
if (!predicate) {
|
||||
return 0;
|
||||
}
|
||||
await c.db.update(authVerification).set(input.update as any).where(predicate).run();
|
||||
const row = await c.db.select({ value: sqlCount() }).from(authVerification).where(predicate).get();
|
||||
return row?.value ?? 0;
|
||||
}
|
||||
|
||||
export async function betterAuthDeleteVerificationMutation(c: any, input: { where: any[] }) {
|
||||
assertAppOrganization(c);
|
||||
|
||||
const predicate = organizationBetterAuthWhere(authVerification, input.where);
|
||||
if (!predicate) {
|
||||
return;
|
||||
}
|
||||
await c.db.delete(authVerification).where(predicate).run();
|
||||
}
|
||||
|
||||
export async function betterAuthDeleteManyVerificationMutation(c: any, input: { where: any[] }) {
|
||||
assertAppOrganization(c);
|
||||
|
||||
const predicate = organizationBetterAuthWhere(authVerification, input.where);
|
||||
if (!predicate) {
|
||||
return 0;
|
||||
}
|
||||
const rows = await c.db.select().from(authVerification).where(predicate).all();
|
||||
await c.db.delete(authVerification).where(predicate).run();
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
export const organizationBetterAuthActions = {
|
||||
async betterAuthFindSessionIndex(c: any, input: { sessionId?: string; sessionToken?: string }) {
|
||||
assertAppOrganization(c);
|
||||
|
||||
const clauses = [
|
||||
...(input.sessionId ? [{ field: "sessionId", value: input.sessionId }] : []),
|
||||
...(input.sessionToken ? [{ field: "sessionToken", value: input.sessionToken }] : []),
|
||||
];
|
||||
if (clauses.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const predicate = organizationBetterAuthWhere(authSessionIndex, clauses);
|
||||
return await c.db.select().from(authSessionIndex).where(predicate!).get();
|
||||
},
|
||||
|
||||
async betterAuthFindEmailIndex(c: any, input: { email: string }) {
|
||||
assertAppOrganization(c);
|
||||
return await c.db.select().from(authEmailIndex).where(eq(authEmailIndex.email, input.email)).get();
|
||||
},
|
||||
|
||||
async betterAuthFindAccountIndex(c: any, input: { id?: string; providerId?: string; accountId?: string }) {
|
||||
assertAppOrganization(c);
|
||||
|
||||
if (input.id) {
|
||||
return await c.db.select().from(authAccountIndex).where(eq(authAccountIndex.id, input.id)).get();
|
||||
}
|
||||
if (!input.providerId || !input.accountId) {
|
||||
return null;
|
||||
}
|
||||
return await c.db
|
||||
.select()
|
||||
.from(authAccountIndex)
|
||||
.where(and(eq(authAccountIndex.providerId, input.providerId), eq(authAccountIndex.accountId, input.accountId)))
|
||||
.get();
|
||||
},
|
||||
|
||||
async betterAuthFindOneVerification(c: any, input: { where: any[] }) {
|
||||
assertAppOrganization(c);
|
||||
|
||||
const predicate = organizationBetterAuthWhere(authVerification, input.where);
|
||||
return predicate ? await c.db.select().from(authVerification).where(predicate).get() : null;
|
||||
},
|
||||
|
||||
async betterAuthFindManyVerification(c: any, input: { where?: any[]; limit?: number; sortBy?: any; offset?: number }) {
|
||||
assertAppOrganization(c);
|
||||
|
||||
const predicate = organizationBetterAuthWhere(authVerification, input.where);
|
||||
let query = c.db.select().from(authVerification);
|
||||
if (predicate) {
|
||||
query = query.where(predicate);
|
||||
}
|
||||
if (input.sortBy?.field) {
|
||||
const column = organizationAuthColumn(authVerification, input.sortBy.field);
|
||||
query = query.orderBy(input.sortBy.direction === "asc" ? asc(column) : desc(column));
|
||||
}
|
||||
if (typeof input.limit === "number") {
|
||||
query = query.limit(input.limit);
|
||||
}
|
||||
if (typeof input.offset === "number") {
|
||||
query = query.offset(input.offset);
|
||||
}
|
||||
return await query.all();
|
||||
},
|
||||
|
||||
async betterAuthCountVerification(c: any, input: { where?: any[] }) {
|
||||
assertAppOrganization(c);
|
||||
|
||||
const predicate = organizationBetterAuthWhere(authVerification, input.where);
|
||||
const row = predicate
|
||||
? await c.db.select({ value: sqlCount() }).from(authVerification).where(predicate).get()
|
||||
: await c.db.select({ value: sqlCount() }).from(authVerification).get();
|
||||
return row?.value ?? 0;
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
import { desc } from "drizzle-orm";
|
||||
import type { FoundryAppSnapshot } from "@sandbox-agent/foundry-shared";
|
||||
import { getOrCreateGithubData, getOrCreateOrganization } from "../../handles.js";
|
||||
import { authSessionIndex } from "../db/schema.js";
|
||||
import {
|
||||
assertAppOrganization,
|
||||
buildAppSnapshot,
|
||||
requireEligibleOrganization,
|
||||
requireSignedInSession,
|
||||
markOrganizationSyncStartedMutation,
|
||||
} from "../app-shell.js";
|
||||
import { getBetterAuthService } from "../../../services/better-auth.js";
|
||||
import { refreshOrganizationSnapshotMutation } from "../actions.js";
|
||||
|
||||
export const organizationGithubActions = {
|
||||
async resolveAppGithubToken(
|
||||
c: any,
|
||||
input: { organizationId: string; requireRepoScope?: boolean },
|
||||
): Promise<{ accessToken: string; scopes: string[] } | null> {
|
||||
assertAppOrganization(c);
|
||||
const auth = getBetterAuthService();
|
||||
const rows = await c.db.select().from(authSessionIndex).orderBy(desc(authSessionIndex.updatedAt)).all();
|
||||
|
||||
for (const row of rows) {
|
||||
const authState = await auth.getAuthState(row.sessionId);
|
||||
if (authState?.sessionState?.activeOrganizationId !== input.organizationId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const token = await auth.getAccessTokenForSession(row.sessionId);
|
||||
if (!token?.accessToken) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const scopes = token.scopes;
|
||||
if (input.requireRepoScope !== false && scopes.length > 0 && !scopes.some((scope) => scope === "repo" || scope.startsWith("repo:"))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken: token.accessToken,
|
||||
scopes,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
async triggerAppRepoImport(c: any, input: { sessionId: string; organizationId: string }): Promise<FoundryAppSnapshot> {
|
||||
assertAppOrganization(c);
|
||||
const session = await requireSignedInSession(c, input.sessionId);
|
||||
requireEligibleOrganization(session, input.organizationId);
|
||||
|
||||
const githubData = await getOrCreateGithubData(c, input.organizationId);
|
||||
const summary = await githubData.getSummary({});
|
||||
if (summary.syncStatus === "syncing") {
|
||||
return await buildAppSnapshot(c, input.sessionId);
|
||||
}
|
||||
|
||||
const organizationHandle = await getOrCreateOrganization(c, input.organizationId);
|
||||
await organizationHandle.commandMarkSyncStarted({ label: "Importing repository catalog..." });
|
||||
await organizationHandle.commandBroadcastSnapshot({});
|
||||
|
||||
void githubData.syncRepos({ label: "Importing repository catalog..." }).catch(() => {});
|
||||
|
||||
return await buildAppSnapshot(c, input.sessionId);
|
||||
},
|
||||
|
||||
async adminReloadGithubOrganization(c: any): Promise<void> {
|
||||
const githubData = await getOrCreateGithubData(c, c.state.organizationId);
|
||||
await githubData.syncRepos({ label: "Reloading GitHub organization..." });
|
||||
},
|
||||
|
||||
async adminReloadGithubRepository(c: any, input: { repoId: string }): Promise<void> {
|
||||
const githubData = await getOrCreateGithubData(c, c.state.organizationId);
|
||||
await githubData.reloadRepository(input);
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import type { FoundryAppSnapshot, StarSandboxAgentRepoInput, StarSandboxAgentRepoResult } from "@sandbox-agent/foundry-shared";
|
||||
import { getOrCreateGithubData, getOrCreateOrganization } from "../../handles.js";
|
||||
import {
|
||||
assertAppOrganization,
|
||||
buildAppSnapshot,
|
||||
getOrganizationState,
|
||||
requireEligibleOrganization,
|
||||
requireSignedInSession,
|
||||
} from "../app-shell.js";
|
||||
import { getBetterAuthService } from "../../../services/better-auth.js";
|
||||
import { getActorRuntimeContext } from "../../context.js";
|
||||
import { resolveOrganizationGithubAuth } from "../../../services/github-auth.js";
|
||||
|
||||
const SANDBOX_AGENT_REPO = "rivet-dev/sandbox-agent";
|
||||
|
||||
export const organizationOnboardingActions = {
|
||||
async skipAppStarterRepo(c: any, input: { sessionId: string }): Promise<FoundryAppSnapshot> {
|
||||
assertAppOrganization(c);
|
||||
const session = await requireSignedInSession(c, input.sessionId);
|
||||
await getBetterAuthService().upsertUserProfile(session.authUserId, {
|
||||
starterRepoStatus: "skipped",
|
||||
starterRepoSkippedAt: Date.now(),
|
||||
starterRepoStarredAt: null,
|
||||
});
|
||||
return await buildAppSnapshot(c, input.sessionId);
|
||||
},
|
||||
|
||||
async starAppStarterRepo(c: any, input: { sessionId: string; organizationId: string }): Promise<FoundryAppSnapshot> {
|
||||
assertAppOrganization(c);
|
||||
const session = await requireSignedInSession(c, input.sessionId);
|
||||
requireEligibleOrganization(session, input.organizationId);
|
||||
const organization = await getOrCreateOrganization(c, input.organizationId);
|
||||
await organization.starSandboxAgentRepo({
|
||||
organizationId: input.organizationId,
|
||||
});
|
||||
await getBetterAuthService().upsertUserProfile(session.authUserId, {
|
||||
starterRepoStatus: "starred",
|
||||
starterRepoStarredAt: Date.now(),
|
||||
starterRepoSkippedAt: null,
|
||||
});
|
||||
return await buildAppSnapshot(c, input.sessionId);
|
||||
},
|
||||
|
||||
async selectAppOrganization(c: any, input: { sessionId: string; organizationId: string }): Promise<FoundryAppSnapshot> {
|
||||
assertAppOrganization(c);
|
||||
const session = await requireSignedInSession(c, input.sessionId);
|
||||
requireEligibleOrganization(session, input.organizationId);
|
||||
await getBetterAuthService().setActiveOrganization(input.sessionId, input.organizationId);
|
||||
await getOrCreateGithubData(c, input.organizationId);
|
||||
return await buildAppSnapshot(c, input.sessionId);
|
||||
},
|
||||
|
||||
async beginAppGithubInstall(c: any, input: { sessionId: string; organizationId: string }): Promise<{ url: string }> {
|
||||
assertAppOrganization(c);
|
||||
const session = await requireSignedInSession(c, input.sessionId);
|
||||
requireEligibleOrganization(session, input.organizationId);
|
||||
const { appShell } = getActorRuntimeContext();
|
||||
const organizationHandle = await getOrCreateOrganization(c, input.organizationId);
|
||||
const organizationState = await getOrganizationState(organizationHandle);
|
||||
if (organizationState.snapshot.kind !== "organization") {
|
||||
return {
|
||||
url: `${appShell.appUrl}/organizations/${input.organizationId}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
url: await appShell.github.buildInstallationUrl(organizationState.githubLogin, randomUUID()),
|
||||
};
|
||||
},
|
||||
|
||||
async starSandboxAgentRepo(c: any, input: StarSandboxAgentRepoInput): Promise<StarSandboxAgentRepoResult> {
|
||||
const { driver } = getActorRuntimeContext();
|
||||
const auth = await resolveOrganizationGithubAuth(c, c.state.organizationId);
|
||||
await driver.github.starRepository(SANDBOX_AGENT_REPO, {
|
||||
githubToken: auth?.githubToken ?? null,
|
||||
});
|
||||
return {
|
||||
repo: SANDBOX_AGENT_REPO,
|
||||
starredAt: Date.now(),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import type { FoundryAppSnapshot, UpdateFoundryOrganizationProfileInput, WorkspaceModelId } from "@sandbox-agent/foundry-shared";
|
||||
import { getBetterAuthService } from "../../../services/better-auth.js";
|
||||
import { getOrCreateOrganization } from "../../handles.js";
|
||||
// actions called directly (no queue)
|
||||
import {
|
||||
assertAppOrganization,
|
||||
assertOrganizationShell,
|
||||
buildAppSnapshot,
|
||||
buildOrganizationState,
|
||||
buildOrganizationStateIfInitialized,
|
||||
requireEligibleOrganization,
|
||||
requireSignedInSession,
|
||||
} from "../app-shell.js";
|
||||
// org queue names removed — using direct actions
|
||||
|
||||
export const organizationShellActions = {
|
||||
async getAppSnapshot(c: any, input: { sessionId: string }): Promise<FoundryAppSnapshot> {
|
||||
return await buildAppSnapshot(c, input.sessionId);
|
||||
},
|
||||
|
||||
async setAppDefaultModel(c: any, input: { sessionId: string; defaultModel: WorkspaceModelId }): Promise<FoundryAppSnapshot> {
|
||||
assertAppOrganization(c);
|
||||
const session = await requireSignedInSession(c, input.sessionId);
|
||||
await getBetterAuthService().upsertUserProfile(session.authUserId, {
|
||||
defaultModel: input.defaultModel,
|
||||
});
|
||||
return await buildAppSnapshot(c, input.sessionId);
|
||||
},
|
||||
|
||||
async updateAppOrganizationProfile(
|
||||
c: any,
|
||||
input: { sessionId: string; organizationId: string } & UpdateFoundryOrganizationProfileInput,
|
||||
): Promise<FoundryAppSnapshot> {
|
||||
assertAppOrganization(c);
|
||||
const session = await requireSignedInSession(c, input.sessionId);
|
||||
requireEligibleOrganization(session, input.organizationId);
|
||||
const organization = await getOrCreateOrganization(c, input.organizationId);
|
||||
await organization.commandUpdateShellProfile({
|
||||
displayName: input.displayName,
|
||||
slug: input.slug,
|
||||
primaryDomain: input.primaryDomain,
|
||||
});
|
||||
return await buildAppSnapshot(c, input.sessionId);
|
||||
},
|
||||
|
||||
async getOrganizationShellState(c: any): Promise<any> {
|
||||
assertOrganizationShell(c);
|
||||
return await buildOrganizationState(c);
|
||||
},
|
||||
|
||||
async getOrganizationShellStateIfInitialized(c: any): Promise<any | null> {
|
||||
assertOrganizationShell(c);
|
||||
return await buildOrganizationStateIfInitialized(c);
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,543 @@
|
|||
// @ts-nocheck
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, desc, eq, isNotNull, ne } from "drizzle-orm";
|
||||
import type {
|
||||
RepoOverview,
|
||||
SandboxProviderId,
|
||||
TaskRecord,
|
||||
TaskSummary,
|
||||
WorkspacePullRequestSummary,
|
||||
WorkspaceSessionSummary,
|
||||
WorkspaceTaskSummary,
|
||||
} from "@sandbox-agent/foundry-shared";
|
||||
import { getActorRuntimeContext } from "../../context.js";
|
||||
import { getGithubData, getOrCreateAuditLog, getOrCreateTask, getTask } from "../../handles.js";
|
||||
// task actions called directly (no queue)
|
||||
import { deriveFallbackTitle, resolveCreateFlowDecision } from "../../../services/create-flow.js";
|
||||
// actions return directly (no queue response unwrapping)
|
||||
import { isActorNotFoundError, logActorWarning, resolveErrorMessage } from "../../logging.js";
|
||||
import { defaultSandboxProviderId } from "../../../sandbox-config.js";
|
||||
import { taskIndex, taskSummaries } from "../db/schema.js";
|
||||
import { refreshOrganizationSnapshotMutation } from "../actions.js";
|
||||
|
||||
interface CreateTaskCommand {
|
||||
repoId: string;
|
||||
task: string;
|
||||
sandboxProviderId: SandboxProviderId;
|
||||
explicitTitle: string | null;
|
||||
explicitBranchName: string | null;
|
||||
onBranch: string | null;
|
||||
}
|
||||
|
||||
interface RegisterTaskBranchCommand {
|
||||
repoId: string;
|
||||
taskId: string;
|
||||
branchName: string;
|
||||
requireExistingRemote?: boolean;
|
||||
}
|
||||
|
||||
function isStaleTaskReferenceError(error: unknown): boolean {
|
||||
const message = resolveErrorMessage(error);
|
||||
return isActorNotFoundError(error) || message.startsWith("Task not found:");
|
||||
}
|
||||
|
||||
function parseJsonValue<T>(value: string | null | undefined, fallback: T): T {
|
||||
if (!value) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function taskSummaryRowFromSummary(taskSummary: WorkspaceTaskSummary) {
|
||||
return {
|
||||
taskId: taskSummary.id,
|
||||
repoId: taskSummary.repoId,
|
||||
title: taskSummary.title,
|
||||
status: taskSummary.status,
|
||||
repoName: taskSummary.repoName,
|
||||
updatedAtMs: taskSummary.updatedAtMs,
|
||||
branch: taskSummary.branch,
|
||||
pullRequestJson: JSON.stringify(taskSummary.pullRequest),
|
||||
sessionsSummaryJson: JSON.stringify(taskSummary.sessionsSummary),
|
||||
};
|
||||
}
|
||||
|
||||
export function taskSummaryFromRow(repoId: string, row: any): WorkspaceTaskSummary {
|
||||
return {
|
||||
id: row.taskId,
|
||||
repoId,
|
||||
title: row.title,
|
||||
status: row.status,
|
||||
repoName: row.repoName,
|
||||
updatedAtMs: row.updatedAtMs,
|
||||
branch: row.branch ?? null,
|
||||
pullRequest: parseJsonValue<WorkspacePullRequestSummary | null>(row.pullRequestJson, null),
|
||||
sessionsSummary: parseJsonValue<WorkspaceSessionSummary[]>(row.sessionsSummaryJson, []),
|
||||
};
|
||||
}
|
||||
|
||||
export async function upsertTaskSummary(c: any, taskSummary: WorkspaceTaskSummary): Promise<void> {
|
||||
await c.db
|
||||
.insert(taskSummaries)
|
||||
.values(taskSummaryRowFromSummary(taskSummary))
|
||||
.onConflictDoUpdate({
|
||||
target: taskSummaries.taskId,
|
||||
set: taskSummaryRowFromSummary(taskSummary),
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
async function deleteStaleTaskIndexRow(c: any, taskId: string): Promise<void> {
|
||||
try {
|
||||
await c.db.delete(taskIndex).where(eq(taskIndex.taskId, taskId)).run();
|
||||
} catch {
|
||||
// Best effort cleanup only.
|
||||
}
|
||||
}
|
||||
|
||||
async function listKnownTaskBranches(c: any, repoId: string): Promise<string[]> {
|
||||
const rows = await c.db
|
||||
.select({ branchName: taskIndex.branchName })
|
||||
.from(taskIndex)
|
||||
.where(and(eq(taskIndex.repoId, repoId), isNotNull(taskIndex.branchName)))
|
||||
.all();
|
||||
return rows.map((row) => row.branchName).filter((value): value is string => typeof value === "string" && value.trim().length > 0);
|
||||
}
|
||||
|
||||
async function resolveGitHubRepository(c: any, repoId: string) {
|
||||
const githubData = getGithubData(c, c.state.organizationId);
|
||||
return await githubData.getRepository({ repoId }).catch(() => null);
|
||||
}
|
||||
|
||||
async function listGitHubBranches(c: any, repoId: string): Promise<Array<{ branchName: string; commitSha: string }>> {
|
||||
const githubData = getGithubData(c, c.state.organizationId);
|
||||
return await githubData.listBranchesForRepository({ repoId }).catch(() => []);
|
||||
}
|
||||
|
||||
async function resolveRepositoryRemoteUrl(c: any, repoId: string): Promise<string> {
|
||||
const repository = await resolveGitHubRepository(c, repoId);
|
||||
const remoteUrl = repository?.cloneUrl?.trim();
|
||||
if (!remoteUrl) {
|
||||
throw new Error(`Missing remote URL for repo ${repoId}`);
|
||||
}
|
||||
return remoteUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* The ONLY backend code path that creates a task actor via getOrCreateTask.
|
||||
* Called when a user explicitly creates a new task (not during sync/webhooks).
|
||||
*
|
||||
* All other code must use getTask (handles.ts) which calls .get() and will
|
||||
* error if the actor doesn't exist. Virtual tasks created during PR sync
|
||||
* are materialized lazily by the client's getOrCreate in backend-client.ts.
|
||||
*
|
||||
* NEVER call this from a sync loop or webhook handler.
|
||||
*/
|
||||
export async function createTaskMutation(c: any, cmd: CreateTaskCommand): Promise<TaskRecord> {
|
||||
const organizationId = c.state.organizationId;
|
||||
const repoId = cmd.repoId;
|
||||
await resolveRepositoryRemoteUrl(c, repoId);
|
||||
const onBranch = cmd.onBranch?.trim() || null;
|
||||
const taskId = randomUUID();
|
||||
let initialBranchName: string | null = null;
|
||||
let initialTitle: string | null = null;
|
||||
|
||||
if (onBranch) {
|
||||
initialBranchName = onBranch;
|
||||
initialTitle = deriveFallbackTitle(cmd.task, cmd.explicitTitle ?? undefined);
|
||||
|
||||
await registerTaskBranchMutation(c, {
|
||||
repoId,
|
||||
taskId,
|
||||
branchName: onBranch,
|
||||
requireExistingRemote: true,
|
||||
});
|
||||
} else {
|
||||
const reservedBranches = await listKnownTaskBranches(c, repoId);
|
||||
const resolved = resolveCreateFlowDecision({
|
||||
task: cmd.task,
|
||||
explicitTitle: cmd.explicitTitle ?? undefined,
|
||||
explicitBranchName: cmd.explicitBranchName ?? undefined,
|
||||
localBranches: [],
|
||||
taskBranches: reservedBranches,
|
||||
});
|
||||
|
||||
initialBranchName = resolved.branchName;
|
||||
initialTitle = resolved.title;
|
||||
|
||||
const now = Date.now();
|
||||
await c.db
|
||||
.insert(taskIndex)
|
||||
.values({
|
||||
taskId,
|
||||
repoId,
|
||||
branchName: resolved.branchName,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.run();
|
||||
}
|
||||
|
||||
let taskHandle: Awaited<ReturnType<typeof getOrCreateTask>>;
|
||||
try {
|
||||
taskHandle = await getOrCreateTask(c, organizationId, repoId, taskId, {
|
||||
organizationId,
|
||||
repoId,
|
||||
taskId,
|
||||
});
|
||||
} catch (error) {
|
||||
if (initialBranchName) {
|
||||
await deleteStaleTaskIndexRow(c, taskId);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const created = await taskHandle.initialize({
|
||||
sandboxProviderId: cmd.sandboxProviderId,
|
||||
branchName: initialBranchName,
|
||||
title: initialTitle,
|
||||
task: cmd.task,
|
||||
});
|
||||
|
||||
try {
|
||||
await upsertTaskSummary(c, await taskHandle.getTaskSummary({}));
|
||||
await refreshOrganizationSnapshotMutation(c);
|
||||
} catch (error) {
|
||||
logActorWarning("organization", "failed seeding task summary after task creation", {
|
||||
organizationId,
|
||||
repoId,
|
||||
taskId,
|
||||
error: resolveErrorMessage(error),
|
||||
});
|
||||
}
|
||||
|
||||
const auditLog = await getOrCreateAuditLog(c, organizationId);
|
||||
void auditLog.append({
|
||||
kind: "task.created",
|
||||
repoId,
|
||||
taskId,
|
||||
payload: {
|
||||
repoId,
|
||||
sandboxProviderId: cmd.sandboxProviderId,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const taskSummary = await taskHandle.getTaskSummary({});
|
||||
await upsertTaskSummary(c, taskSummary);
|
||||
} catch (error) {
|
||||
logActorWarning("organization", "failed seeding organization task projection", {
|
||||
organizationId,
|
||||
repoId,
|
||||
taskId,
|
||||
error: resolveErrorMessage(error),
|
||||
});
|
||||
}
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
export async function registerTaskBranchMutation(c: any, cmd: RegisterTaskBranchCommand): Promise<{ branchName: string; headSha: string }> {
|
||||
const branchName = cmd.branchName.trim();
|
||||
if (!branchName) {
|
||||
throw new Error("branchName is required");
|
||||
}
|
||||
|
||||
const existingOwner = await c.db
|
||||
.select({ taskId: taskIndex.taskId })
|
||||
.from(taskIndex)
|
||||
.where(and(eq(taskIndex.branchName, branchName), eq(taskIndex.repoId, cmd.repoId), ne(taskIndex.taskId, cmd.taskId)))
|
||||
.get();
|
||||
|
||||
if (existingOwner) {
|
||||
let ownerMissing = false;
|
||||
try {
|
||||
await getTask(c, c.state.organizationId, cmd.repoId, existingOwner.taskId).get();
|
||||
} catch (error) {
|
||||
if (isStaleTaskReferenceError(error)) {
|
||||
ownerMissing = true;
|
||||
await deleteStaleTaskIndexRow(c, existingOwner.taskId);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (!ownerMissing) {
|
||||
throw new Error(`branch is already assigned to a different task: ${branchName}`);
|
||||
}
|
||||
}
|
||||
|
||||
const branches = await listGitHubBranches(c, cmd.repoId);
|
||||
const branchMatch = branches.find((branch) => branch.branchName === branchName) ?? null;
|
||||
if (cmd.requireExistingRemote && !branchMatch) {
|
||||
throw new Error(`Remote branch not found: ${branchName}`);
|
||||
}
|
||||
|
||||
const repository = await resolveGitHubRepository(c, cmd.repoId);
|
||||
const defaultBranch = repository?.defaultBranch ?? "main";
|
||||
const headSha = branchMatch?.commitSha ?? branches.find((branch) => branch.branchName === defaultBranch)?.commitSha ?? "";
|
||||
|
||||
const now = Date.now();
|
||||
await c.db
|
||||
.insert(taskIndex)
|
||||
.values({
|
||||
taskId: cmd.taskId,
|
||||
repoId: cmd.repoId,
|
||||
branchName,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: taskIndex.taskId,
|
||||
set: {
|
||||
branchName,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
|
||||
return { branchName, headSha };
|
||||
}
|
||||
|
||||
export async function applyTaskSummaryUpdateMutation(c: any, input: { taskSummary: WorkspaceTaskSummary }): Promise<void> {
|
||||
await upsertTaskSummary(c, input.taskSummary);
|
||||
await refreshOrganizationSnapshotMutation(c);
|
||||
}
|
||||
|
||||
export async function removeTaskSummaryMutation(c: any, input: { taskId: string }): Promise<void> {
|
||||
await c.db.delete(taskSummaries).where(eq(taskSummaries.taskId, input.taskId)).run();
|
||||
await refreshOrganizationSnapshotMutation(c);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called for every changed PR during sync and on webhook PR events.
|
||||
* Runs in a bulk loop — MUST NOT create task actors or make cross-actor calls
|
||||
* to task actors. Only writes to the org's local taskIndex/taskSummaries tables.
|
||||
* Task actors are created lazily when the user views the task.
|
||||
*/
|
||||
export async function refreshTaskSummaryForBranchMutation(
|
||||
c: any,
|
||||
input: { repoId: string; branchName: string; pullRequest?: WorkspacePullRequestSummary | null; repoName?: string },
|
||||
): Promise<void> {
|
||||
const pullRequest = input.pullRequest ?? null;
|
||||
let rows = await c.db
|
||||
.select({ taskId: taskSummaries.taskId })
|
||||
.from(taskSummaries)
|
||||
.where(and(eq(taskSummaries.branch, input.branchName), eq(taskSummaries.repoId, input.repoId)))
|
||||
.all();
|
||||
|
||||
if (rows.length === 0 && pullRequest) {
|
||||
// Create a virtual task entry in the org's local tables only.
|
||||
// No task actor is spawned — it will be created lazily when the user
|
||||
// clicks on the task in the sidebar (the "materialize" path).
|
||||
const taskId = randomUUID();
|
||||
const now = Date.now();
|
||||
const title = pullRequest.title?.trim() || input.branchName;
|
||||
const repoName = input.repoName ?? `${c.state.organizationId}/${input.repoId}`;
|
||||
|
||||
await c.db
|
||||
.insert(taskIndex)
|
||||
.values({ taskId, repoId: input.repoId, branchName: input.branchName, createdAt: now, updatedAt: now })
|
||||
.onConflictDoNothing()
|
||||
.run();
|
||||
|
||||
await c.db
|
||||
.insert(taskSummaries)
|
||||
.values({
|
||||
taskId,
|
||||
repoId: input.repoId,
|
||||
title,
|
||||
status: "init_complete",
|
||||
repoName,
|
||||
updatedAtMs: pullRequest.updatedAtMs ?? now,
|
||||
branch: input.branchName,
|
||||
pullRequestJson: JSON.stringify(pullRequest),
|
||||
sessionsSummaryJson: "[]",
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.run();
|
||||
|
||||
rows = [{ taskId }];
|
||||
} else {
|
||||
// Update PR data on existing task summaries locally.
|
||||
// If a real task actor exists, also notify it.
|
||||
for (const row of rows) {
|
||||
// Update the local summary with the new PR data
|
||||
await c.db
|
||||
.update(taskSummaries)
|
||||
.set({
|
||||
pullRequestJson: pullRequest ? JSON.stringify(pullRequest) : null,
|
||||
updatedAtMs: pullRequest?.updatedAtMs ?? Date.now(),
|
||||
})
|
||||
.where(eq(taskSummaries.taskId, row.taskId))
|
||||
.run();
|
||||
|
||||
// Best-effort notify the task actor if it exists (fire-and-forget)
|
||||
try {
|
||||
const task = getTask(c, c.state.organizationId, input.repoId, row.taskId);
|
||||
void task.pullRequestSync({ pullRequest }).catch(() => {});
|
||||
} catch {
|
||||
// Task actor doesn't exist yet — that's fine, it's virtual
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await refreshOrganizationSnapshotMutation(c);
|
||||
}
|
||||
|
||||
export function sortOverviewBranches(
|
||||
branches: Array<{
|
||||
branchName: string;
|
||||
commitSha: string;
|
||||
taskId: string | null;
|
||||
taskTitle: string | null;
|
||||
taskStatus: TaskRecord["status"] | null;
|
||||
pullRequest: WorkspacePullRequestSummary | null;
|
||||
ciStatus: string | null;
|
||||
updatedAt: number;
|
||||
}>,
|
||||
defaultBranch: string | null,
|
||||
) {
|
||||
return [...branches].sort((left, right) => {
|
||||
if (defaultBranch) {
|
||||
if (left.branchName === defaultBranch && right.branchName !== defaultBranch) return -1;
|
||||
if (right.branchName === defaultBranch && left.branchName !== defaultBranch) return 1;
|
||||
}
|
||||
if (Boolean(left.taskId) !== Boolean(right.taskId)) {
|
||||
return left.taskId ? -1 : 1;
|
||||
}
|
||||
if (left.updatedAt !== right.updatedAt) {
|
||||
return right.updatedAt - left.updatedAt;
|
||||
}
|
||||
return left.branchName.localeCompare(right.branchName);
|
||||
});
|
||||
}
|
||||
|
||||
export async function listTaskSummariesForRepo(c: any, repoId: string, includeArchived = false): Promise<TaskSummary[]> {
|
||||
const rows = await c.db.select().from(taskSummaries).where(eq(taskSummaries.repoId, repoId)).orderBy(desc(taskSummaries.updatedAtMs)).all();
|
||||
return rows
|
||||
.map((row) => ({
|
||||
organizationId: c.state.organizationId,
|
||||
repoId,
|
||||
taskId: row.taskId,
|
||||
branchName: row.branch ?? null,
|
||||
title: row.title,
|
||||
status: row.status,
|
||||
updatedAt: row.updatedAtMs,
|
||||
pullRequest: parseJsonValue<WorkspacePullRequestSummary | null>(row.pullRequestJson, null),
|
||||
}))
|
||||
.filter((row) => includeArchived || row.status !== "archived");
|
||||
}
|
||||
|
||||
export async function listAllTaskSummaries(c: any, includeArchived = false): Promise<TaskSummary[]> {
|
||||
const rows = await c.db.select().from(taskSummaries).orderBy(desc(taskSummaries.updatedAtMs)).all();
|
||||
return rows
|
||||
.map((row) => ({
|
||||
organizationId: c.state.organizationId,
|
||||
repoId: row.repoId,
|
||||
taskId: row.taskId,
|
||||
branchName: row.branch ?? null,
|
||||
title: row.title,
|
||||
status: row.status,
|
||||
updatedAt: row.updatedAtMs,
|
||||
pullRequest: parseJsonValue<WorkspacePullRequestSummary | null>(row.pullRequestJson, null),
|
||||
}))
|
||||
.filter((row) => includeArchived || row.status !== "archived");
|
||||
}
|
||||
|
||||
export async function listWorkspaceTaskSummaries(c: any): Promise<WorkspaceTaskSummary[]> {
|
||||
const rows = await c.db.select().from(taskSummaries).orderBy(desc(taskSummaries.updatedAtMs)).all();
|
||||
return rows.map((row) => taskSummaryFromRow(row.repoId, row));
|
||||
}
|
||||
|
||||
export async function getRepoOverviewFromOrg(c: any, repoId: string): Promise<RepoOverview> {
|
||||
const now = Date.now();
|
||||
const repository = await resolveGitHubRepository(c, repoId);
|
||||
const remoteUrl = await resolveRepositoryRemoteUrl(c, repoId);
|
||||
const githubBranches = await listGitHubBranches(c, repoId).catch(() => []);
|
||||
const taskRows = await c.db.select().from(taskSummaries).where(eq(taskSummaries.repoId, repoId)).all();
|
||||
|
||||
const taskMetaByBranch = new Map<
|
||||
string,
|
||||
{ taskId: string; title: string | null; status: TaskRecord["status"] | null; updatedAt: number; pullRequest: WorkspacePullRequestSummary | null }
|
||||
>();
|
||||
for (const row of taskRows) {
|
||||
if (!row.branch) {
|
||||
continue;
|
||||
}
|
||||
taskMetaByBranch.set(row.branch, {
|
||||
taskId: row.taskId,
|
||||
title: row.title ?? null,
|
||||
status: row.status,
|
||||
updatedAt: row.updatedAtMs,
|
||||
pullRequest: parseJsonValue<WorkspacePullRequestSummary | null>(row.pullRequestJson, null),
|
||||
});
|
||||
}
|
||||
|
||||
const branchMap = new Map<string, { branchName: string; commitSha: string }>();
|
||||
for (const branch of githubBranches) {
|
||||
branchMap.set(branch.branchName, branch);
|
||||
}
|
||||
for (const branchName of taskMetaByBranch.keys()) {
|
||||
if (!branchMap.has(branchName)) {
|
||||
branchMap.set(branchName, { branchName, commitSha: "" });
|
||||
}
|
||||
}
|
||||
if (repository?.defaultBranch && !branchMap.has(repository.defaultBranch)) {
|
||||
branchMap.set(repository.defaultBranch, { branchName: repository.defaultBranch, commitSha: "" });
|
||||
}
|
||||
|
||||
const branches = sortOverviewBranches(
|
||||
[...branchMap.values()].map((branch) => {
|
||||
const taskMeta = taskMetaByBranch.get(branch.branchName);
|
||||
const pr = taskMeta?.pullRequest ?? null;
|
||||
return {
|
||||
branchName: branch.branchName,
|
||||
commitSha: branch.commitSha,
|
||||
taskId: taskMeta?.taskId ?? null,
|
||||
taskTitle: taskMeta?.title ?? null,
|
||||
taskStatus: taskMeta?.status ?? null,
|
||||
pullRequest: pr,
|
||||
ciStatus: null,
|
||||
updatedAt: Math.max(taskMeta?.updatedAt ?? 0, pr?.updatedAtMs ?? 0, now),
|
||||
};
|
||||
}),
|
||||
repository?.defaultBranch ?? null,
|
||||
);
|
||||
|
||||
return {
|
||||
organizationId: c.state.organizationId,
|
||||
repoId,
|
||||
remoteUrl,
|
||||
baseRef: repository?.defaultBranch ?? null,
|
||||
fetchedAt: now,
|
||||
branches,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getRepositoryMetadataFromOrg(
|
||||
c: any,
|
||||
repoId: string,
|
||||
): Promise<{ defaultBranch: string | null; fullName: string | null; remoteUrl: string }> {
|
||||
const repository = await resolveGitHubRepository(c, repoId);
|
||||
const remoteUrl = await resolveRepositoryRemoteUrl(c, repoId);
|
||||
return {
|
||||
defaultBranch: repository?.defaultBranch ?? null,
|
||||
fullName: repository?.fullName ?? null,
|
||||
remoteUrl,
|
||||
};
|
||||
}
|
||||
|
||||
export async function findTaskForBranch(c: any, repoId: string, branchName: string): Promise<{ taskId: string | null }> {
|
||||
const row = await c.db
|
||||
.select({ taskId: taskSummaries.taskId })
|
||||
.from(taskSummaries)
|
||||
.where(and(eq(taskSummaries.branch, branchName), eq(taskSummaries.repoId, repoId)))
|
||||
.get();
|
||||
return { taskId: row?.taskId ?? null };
|
||||
}
|
||||
|
|
@ -0,0 +1,340 @@
|
|||
// @ts-nocheck
|
||||
import { desc, eq } from "drizzle-orm";
|
||||
import type {
|
||||
AuditLogEvent,
|
||||
CreateTaskInput,
|
||||
HistoryQueryInput,
|
||||
ListTasksInput,
|
||||
RepoOverview,
|
||||
SwitchResult,
|
||||
TaskRecord,
|
||||
TaskSummary,
|
||||
TaskWorkspaceChangeModelInput,
|
||||
TaskWorkspaceCreateTaskInput,
|
||||
TaskWorkspaceDiffInput,
|
||||
TaskWorkspaceRenameInput,
|
||||
TaskWorkspaceRenameSessionInput,
|
||||
TaskWorkspaceSelectInput,
|
||||
TaskWorkspaceSetSessionUnreadInput,
|
||||
TaskWorkspaceSendMessageInput,
|
||||
TaskWorkspaceSessionInput,
|
||||
TaskWorkspaceUpdateDraftInput,
|
||||
} from "@sandbox-agent/foundry-shared";
|
||||
import { getActorRuntimeContext } from "../../context.js";
|
||||
import { getOrCreateAuditLog, getOrCreateTask, getTask as getTaskHandle } from "../../handles.js";
|
||||
import { defaultSandboxProviderId } from "../../../sandbox-config.js";
|
||||
import { logActorWarning, resolveErrorMessage } from "../../logging.js";
|
||||
import { taskIndex, taskSummaries } from "../db/schema.js";
|
||||
import {
|
||||
createTaskMutation,
|
||||
getRepoOverviewFromOrg,
|
||||
getRepositoryMetadataFromOrg,
|
||||
findTaskForBranch,
|
||||
listTaskSummariesForRepo,
|
||||
listAllTaskSummaries,
|
||||
} from "./task-mutations.js";
|
||||
|
||||
function assertOrganization(c: { state: { organizationId: string } }, organizationId: string): void {
|
||||
if (organizationId !== c.state.organizationId) {
|
||||
throw new Error(`Organization actor mismatch: actor=${c.state.organizationId} command=${organizationId}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up the repoId for a task from the local task index.
|
||||
* Used when callers (e.g. sandbox actor) only have taskId but need repoId
|
||||
* to construct the task actor key.
|
||||
*/
|
||||
async function resolveTaskRepoId(c: any, taskId: string): Promise<string> {
|
||||
const row = await c.db.select({ repoId: taskIndex.repoId }).from(taskIndex).where(eq(taskIndex.taskId, taskId)).get();
|
||||
if (!row) {
|
||||
throw new Error(`Task ${taskId} not found in task index`);
|
||||
}
|
||||
return row.repoId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or lazily create a task actor for a user-initiated action.
|
||||
* Uses getOrCreate because the user may be interacting with a virtual task
|
||||
* (PR-driven) that has no actor yet. The task actor self-initializes in
|
||||
* getCurrentRecord() from the org's getTaskIndexEntry data.
|
||||
*
|
||||
* This is safe because requireWorkspaceTask is only called from user-initiated
|
||||
* actions (createSession, sendMessage, etc.), never from sync loops.
|
||||
* See CLAUDE.md "Lazy Task Actor Creation".
|
||||
*/
|
||||
async function requireWorkspaceTask(c: any, repoId: string, taskId: string) {
|
||||
return getOrCreateTask(c, c.state.organizationId, repoId, taskId, {
|
||||
organizationId: c.state.organizationId,
|
||||
repoId,
|
||||
taskId,
|
||||
});
|
||||
}
|
||||
|
||||
interface GetTaskInput {
|
||||
organizationId: string;
|
||||
repoId: string;
|
||||
taskId: string;
|
||||
}
|
||||
|
||||
interface TaskProxyActionInput extends GetTaskInput {
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
interface RepoOverviewInput {
|
||||
organizationId: string;
|
||||
repoId: string;
|
||||
}
|
||||
|
||||
export { createTaskMutation };
|
||||
|
||||
export const organizationTaskActions = {
|
||||
async createTask(c: any, input: CreateTaskInput): Promise<TaskRecord> {
|
||||
assertOrganization(c, input.organizationId);
|
||||
const { config } = getActorRuntimeContext();
|
||||
const sandboxProviderId = input.sandboxProviderId ?? defaultSandboxProviderId(config);
|
||||
|
||||
// Self-call: call the mutation directly since we're inside the org actor
|
||||
return await createTaskMutation(c, {
|
||||
repoId: input.repoId,
|
||||
task: input.task,
|
||||
sandboxProviderId,
|
||||
explicitTitle: input.explicitTitle ?? null,
|
||||
explicitBranchName: input.explicitBranchName ?? null,
|
||||
onBranch: input.onBranch ?? null,
|
||||
});
|
||||
},
|
||||
|
||||
async materializeTask(c: any, input: { organizationId: string; repoId: string; virtualTaskId: string }): Promise<TaskRecord> {
|
||||
assertOrganization(c, input.organizationId);
|
||||
const { config } = getActorRuntimeContext();
|
||||
// Self-call: call the mutation directly
|
||||
return await createTaskMutation(c, {
|
||||
repoId: input.repoId,
|
||||
task: input.virtualTaskId,
|
||||
sandboxProviderId: defaultSandboxProviderId(config),
|
||||
explicitTitle: null,
|
||||
explicitBranchName: null,
|
||||
onBranch: null,
|
||||
});
|
||||
},
|
||||
|
||||
async createWorkspaceTask(c: any, input: TaskWorkspaceCreateTaskInput): Promise<{ taskId: string; sessionId?: string }> {
|
||||
const created = await organizationTaskActions.createTask(c, {
|
||||
organizationId: c.state.organizationId,
|
||||
repoId: input.repoId,
|
||||
task: input.task,
|
||||
...(input.title ? { explicitTitle: input.title } : {}),
|
||||
...(input.onBranch ? { onBranch: input.onBranch } : input.branch ? { explicitBranchName: input.branch } : {}),
|
||||
});
|
||||
|
||||
const task = await requireWorkspaceTask(c, input.repoId, created.taskId);
|
||||
void task
|
||||
.createSessionAndSend({
|
||||
model: input.model,
|
||||
text: input.task,
|
||||
authSessionId: input.authSessionId,
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
return { taskId: created.taskId };
|
||||
},
|
||||
|
||||
async markWorkspaceUnread(c: any, input: TaskWorkspaceSelectInput): Promise<void> {
|
||||
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
|
||||
await task.markUnread({ authSessionId: input.authSessionId });
|
||||
},
|
||||
|
||||
async renameWorkspaceTask(c: any, input: TaskWorkspaceRenameInput): Promise<void> {
|
||||
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
|
||||
await task.renameTask({ value: input.value });
|
||||
},
|
||||
|
||||
async createWorkspaceSession(c: any, input: TaskWorkspaceSelectInput & { model?: string }): Promise<{ sessionId: string }> {
|
||||
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
|
||||
return await task.createSession({
|
||||
...(input.model ? { model: input.model } : {}),
|
||||
...(input.authSessionId ? { authSessionId: input.authSessionId } : {}),
|
||||
});
|
||||
},
|
||||
|
||||
async renameWorkspaceSession(c: any, input: TaskWorkspaceRenameSessionInput): Promise<void> {
|
||||
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
|
||||
await task.renameSession({ sessionId: input.sessionId, title: input.title, authSessionId: input.authSessionId });
|
||||
},
|
||||
|
||||
async selectWorkspaceSession(c: any, input: TaskWorkspaceSessionInput): Promise<void> {
|
||||
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
|
||||
await task.selectSession({ sessionId: input.sessionId, authSessionId: input.authSessionId });
|
||||
},
|
||||
|
||||
async setWorkspaceSessionUnread(c: any, input: TaskWorkspaceSetSessionUnreadInput): Promise<void> {
|
||||
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
|
||||
await task.setSessionUnread({ sessionId: input.sessionId, unread: input.unread, authSessionId: input.authSessionId });
|
||||
},
|
||||
|
||||
async updateWorkspaceDraft(c: any, input: TaskWorkspaceUpdateDraftInput): Promise<void> {
|
||||
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
|
||||
void task
|
||||
.updateDraft({
|
||||
sessionId: input.sessionId,
|
||||
text: input.text,
|
||||
attachments: input.attachments,
|
||||
authSessionId: input.authSessionId,
|
||||
})
|
||||
.catch(() => {});
|
||||
},
|
||||
|
||||
async changeWorkspaceModel(c: any, input: TaskWorkspaceChangeModelInput): Promise<void> {
|
||||
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
|
||||
await task.changeModel({ sessionId: input.sessionId, model: input.model, authSessionId: input.authSessionId });
|
||||
},
|
||||
|
||||
async sendWorkspaceMessage(c: any, input: TaskWorkspaceSendMessageInput): Promise<void> {
|
||||
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
|
||||
void task
|
||||
.sendMessage({
|
||||
sessionId: input.sessionId,
|
||||
text: input.text,
|
||||
attachments: input.attachments,
|
||||
authSessionId: input.authSessionId,
|
||||
})
|
||||
.catch(() => {});
|
||||
},
|
||||
|
||||
async stopWorkspaceSession(c: any, input: TaskWorkspaceSessionInput): Promise<void> {
|
||||
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
|
||||
void task.stopSession({ sessionId: input.sessionId, authSessionId: input.authSessionId }).catch(() => {});
|
||||
},
|
||||
|
||||
async closeWorkspaceSession(c: any, input: TaskWorkspaceSessionInput): Promise<void> {
|
||||
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
|
||||
void task.closeSession({ sessionId: input.sessionId, authSessionId: input.authSessionId }).catch(() => {});
|
||||
},
|
||||
|
||||
async publishWorkspacePr(c: any, input: TaskWorkspaceSelectInput): Promise<void> {
|
||||
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
|
||||
void task.publishPr({}).catch(() => {});
|
||||
},
|
||||
|
||||
async revertWorkspaceFile(c: any, input: TaskWorkspaceDiffInput): Promise<void> {
|
||||
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
|
||||
void task.revertFile(input).catch(() => {});
|
||||
},
|
||||
|
||||
async getRepoOverview(c: any, input: RepoOverviewInput): Promise<RepoOverview> {
|
||||
assertOrganization(c, input.organizationId);
|
||||
|
||||
return await getRepoOverviewFromOrg(c, input.repoId);
|
||||
},
|
||||
|
||||
async listTasks(c: any, input: ListTasksInput): Promise<TaskSummary[]> {
|
||||
assertOrganization(c, input.organizationId);
|
||||
if (input.repoId) {
|
||||
return await listTaskSummariesForRepo(c, input.repoId, true);
|
||||
}
|
||||
return await listAllTaskSummaries(c, true);
|
||||
},
|
||||
|
||||
async switchTask(c: any, input: { repoId: string; taskId: string }): Promise<SwitchResult> {
|
||||
const h = getTaskHandle(c, c.state.organizationId, input.repoId, input.taskId);
|
||||
const record = await h.get();
|
||||
const switched = await h.switchTask({});
|
||||
return {
|
||||
organizationId: c.state.organizationId,
|
||||
taskId: input.taskId,
|
||||
sandboxProviderId: record.sandboxProviderId,
|
||||
switchTarget: switched.switchTarget,
|
||||
};
|
||||
},
|
||||
|
||||
async auditLog(c: any, input: HistoryQueryInput): Promise<AuditLogEvent[]> {
|
||||
assertOrganization(c, input.organizationId);
|
||||
const auditLog = await getOrCreateAuditLog(c, c.state.organizationId);
|
||||
return await auditLog.list({
|
||||
repoId: input.repoId,
|
||||
branch: input.branch,
|
||||
taskId: input.taskId,
|
||||
limit: input.limit ?? 20,
|
||||
});
|
||||
},
|
||||
|
||||
async getTask(c: any, input: GetTaskInput): Promise<TaskRecord> {
|
||||
assertOrganization(c, input.organizationId);
|
||||
// Resolve repoId from local task index if not provided (e.g. sandbox actor only has taskId)
|
||||
const repoId = input.repoId || (await resolveTaskRepoId(c, input.taskId));
|
||||
// Use getOrCreate — the task may be virtual (PR-driven, no actor yet).
|
||||
// The task actor self-initializes in getCurrentRecord().
|
||||
const handle = await getOrCreateTask(c, c.state.organizationId, repoId, input.taskId, {
|
||||
organizationId: c.state.organizationId,
|
||||
repoId,
|
||||
taskId: input.taskId,
|
||||
});
|
||||
return await handle.get();
|
||||
},
|
||||
|
||||
async attachTask(c: any, input: TaskProxyActionInput): Promise<{ target: string; sessionId: string | null }> {
|
||||
assertOrganization(c, input.organizationId);
|
||||
|
||||
const h = getTaskHandle(c, c.state.organizationId, input.repoId, input.taskId);
|
||||
return await h.attach({ reason: input.reason });
|
||||
},
|
||||
|
||||
async pushTask(c: any, input: TaskProxyActionInput): Promise<void> {
|
||||
assertOrganization(c, input.organizationId);
|
||||
|
||||
const h = getTaskHandle(c, c.state.organizationId, input.repoId, input.taskId);
|
||||
void h.push({ reason: input.reason }).catch(() => {});
|
||||
},
|
||||
|
||||
async syncTask(c: any, input: TaskProxyActionInput): Promise<void> {
|
||||
assertOrganization(c, input.organizationId);
|
||||
|
||||
const h = getTaskHandle(c, c.state.organizationId, input.repoId, input.taskId);
|
||||
void h.sync({ reason: input.reason }).catch(() => {});
|
||||
},
|
||||
|
||||
async mergeTask(c: any, input: TaskProxyActionInput): Promise<void> {
|
||||
assertOrganization(c, input.organizationId);
|
||||
|
||||
const h = getTaskHandle(c, c.state.organizationId, input.repoId, input.taskId);
|
||||
void h.merge({ reason: input.reason }).catch(() => {});
|
||||
},
|
||||
|
||||
async archiveTask(c: any, input: TaskProxyActionInput): Promise<void> {
|
||||
assertOrganization(c, input.organizationId);
|
||||
|
||||
const h = getTaskHandle(c, c.state.organizationId, input.repoId, input.taskId);
|
||||
void h.archive({ reason: input.reason }).catch(() => {});
|
||||
},
|
||||
|
||||
async killTask(c: any, input: TaskProxyActionInput): Promise<void> {
|
||||
assertOrganization(c, input.organizationId);
|
||||
|
||||
const h = getTaskHandle(c, c.state.organizationId, input.repoId, input.taskId);
|
||||
void h.kill({ reason: input.reason }).catch(() => {});
|
||||
},
|
||||
|
||||
async getRepositoryMetadata(c: any, input: { repoId: string }): Promise<{ defaultBranch: string | null; fullName: string | null; remoteUrl: string }> {
|
||||
return await getRepositoryMetadataFromOrg(c, input.repoId);
|
||||
},
|
||||
|
||||
async findTaskForBranch(c: any, input: { repoId: string; branchName: string }): Promise<{ taskId: string | null }> {
|
||||
return await findTaskForBranch(c, input.repoId, input.branchName);
|
||||
},
|
||||
|
||||
/**
|
||||
* Lightweight read of task index + summary data. Used by the task actor
|
||||
* to self-initialize when lazily materialized from a virtual task.
|
||||
* Does NOT trigger materialization — no circular dependency.
|
||||
*/
|
||||
async getTaskIndexEntry(c: any, input: { taskId: string }): Promise<{ branchName: string | null; title: string | null } | null> {
|
||||
const idx = await c.db.select({ branchName: taskIndex.branchName }).from(taskIndex).where(eq(taskIndex.taskId, input.taskId)).get();
|
||||
const summary = await c.db.select({ title: taskSummaries.title }).from(taskSummaries).where(eq(taskSummaries.taskId, input.taskId)).get();
|
||||
if (!idx && !summary) return null;
|
||||
return {
|
||||
branchName: idx?.branchName ?? null,
|
||||
title: summary?.title ?? null,
|
||||
};
|
||||
},
|
||||
};
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1 @@
|
|||
export const APP_SHELL_ORGANIZATION_ID = "app";
|
||||
|
|
@ -56,6 +56,10 @@ CREATE TABLE `organization_profile` (
|
|||
`github_last_sync_at` integer,
|
||||
`github_last_webhook_at` integer,
|
||||
`github_last_webhook_event` text,
|
||||
`github_sync_generation` integer NOT NULL,
|
||||
`github_sync_phase` text,
|
||||
`github_processed_repository_count` integer NOT NULL,
|
||||
`github_total_repository_count` integer NOT NULL,
|
||||
`stripe_customer_id` text,
|
||||
`stripe_subscription_id` text,
|
||||
`stripe_price_id` text,
|
||||
|
|
@ -86,8 +90,3 @@ CREATE TABLE `stripe_lookup` (
|
|||
`organization_id` text NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `task_lookup` (
|
||||
`task_id` text PRIMARY KEY NOT NULL,
|
||||
`repo_id` text NOT NULL
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
CREATE TABLE `auth_session_index` (
|
||||
`session_id` text PRIMARY KEY NOT NULL,
|
||||
`session_token` text NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `auth_email_index` (
|
||||
`email` text PRIMARY KEY NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `auth_account_index` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`provider_id` text NOT NULL,
|
||||
`account_id` text NOT NULL,
|
||||
`user_id` text NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `auth_verification` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`identifier` text NOT NULL,
|
||||
`value` text NOT NULL,
|
||||
`expires_at` integer NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `task_index` (
|
||||
`task_id` text PRIMARY KEY NOT NULL,
|
||||
`repo_id` text NOT NULL,
|
||||
`branch_name` text,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `task_summaries` (
|
||||
`task_id` text PRIMARY KEY NOT NULL,
|
||||
`repo_id` text NOT NULL,
|
||||
`title` text NOT NULL,
|
||||
`status` text NOT NULL,
|
||||
`repo_name` text NOT NULL,
|
||||
`updated_at_ms` integer NOT NULL,
|
||||
`branch` text,
|
||||
`pull_request_json` text,
|
||||
`sessions_summary_json` text DEFAULT '[]' NOT NULL
|
||||
);
|
||||
|
|
@ -373,6 +373,34 @@
|
|||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"github_sync_generation": {
|
||||
"name": "github_sync_generation",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"github_sync_phase": {
|
||||
"name": "github_sync_phase",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"github_processed_repository_count": {
|
||||
"name": "github_processed_repository_count",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"github_total_repository_count": {
|
||||
"name": "github_total_repository_count",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"stripe_customer_id": {
|
||||
"name": "stripe_customer_id",
|
||||
"type": "text",
|
||||
|
|
@ -549,30 +577,6 @@
|
|||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_lookup": {
|
||||
"name": "task_lookup",
|
||||
"columns": {
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"repo_id": {
|
||||
"name": "repo_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
|
|
|
|||
|
|
@ -8,6 +8,13 @@
|
|||
"when": 1773376221152,
|
||||
"tag": "0000_melted_viper",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1773840000000,
|
||||
"tag": "0001_add_auth_and_task_tables",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,20 +12,8 @@ const journal = {
|
|||
},
|
||||
{
|
||||
idx: 1,
|
||||
when: 1773638400000,
|
||||
tag: "0001_auth_index_tables",
|
||||
breakpoints: true,
|
||||
},
|
||||
{
|
||||
idx: 2,
|
||||
when: 1773720000000,
|
||||
tag: "0002_task_summaries",
|
||||
breakpoints: true,
|
||||
},
|
||||
{
|
||||
idx: 3,
|
||||
when: 1773810001000,
|
||||
tag: "0003_drop_provider_profiles",
|
||||
when: 1773840000000,
|
||||
tag: "0001_add_auth_and_task_tables",
|
||||
breakpoints: true,
|
||||
},
|
||||
],
|
||||
|
|
@ -92,6 +80,10 @@ CREATE TABLE \`organization_profile\` (
|
|||
\`github_last_sync_at\` integer,
|
||||
\`github_last_webhook_at\` integer,
|
||||
\`github_last_webhook_event\` text,
|
||||
\`github_sync_generation\` integer NOT NULL,
|
||||
\`github_sync_phase\` text,
|
||||
\`github_processed_repository_count\` integer NOT NULL,
|
||||
\`github_total_repository_count\` integer NOT NULL,
|
||||
\`stripe_customer_id\` text,
|
||||
\`stripe_subscription_id\` text,
|
||||
\`stripe_price_id\` text,
|
||||
|
|
@ -122,13 +114,8 @@ CREATE TABLE \`stripe_lookup\` (
|
|||
\`organization_id\` text NOT NULL,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE \`task_lookup\` (
|
||||
\`task_id\` text PRIMARY KEY NOT NULL,
|
||||
\`repo_id\` text NOT NULL
|
||||
);
|
||||
`,
|
||||
m0001: `CREATE TABLE IF NOT EXISTS \`auth_session_index\` (
|
||||
m0001: `CREATE TABLE \`auth_session_index\` (
|
||||
\`session_id\` text PRIMARY KEY NOT NULL,
|
||||
\`session_token\` text NOT NULL,
|
||||
\`user_id\` text NOT NULL,
|
||||
|
|
@ -136,13 +123,13 @@ CREATE TABLE \`task_lookup\` (
|
|||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS \`auth_email_index\` (
|
||||
CREATE TABLE \`auth_email_index\` (
|
||||
\`email\` text PRIMARY KEY NOT NULL,
|
||||
\`user_id\` text NOT NULL,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS \`auth_account_index\` (
|
||||
CREATE TABLE \`auth_account_index\` (
|
||||
\`id\` text PRIMARY KEY NOT NULL,
|
||||
\`provider_id\` text NOT NULL,
|
||||
\`account_id\` text NOT NULL,
|
||||
|
|
@ -150,7 +137,7 @@ CREATE TABLE IF NOT EXISTS \`auth_account_index\` (
|
|||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS \`auth_verification\` (
|
||||
CREATE TABLE \`auth_verification\` (
|
||||
\`id\` text PRIMARY KEY NOT NULL,
|
||||
\`identifier\` text NOT NULL,
|
||||
\`value\` text NOT NULL,
|
||||
|
|
@ -158,8 +145,16 @@ CREATE TABLE IF NOT EXISTS \`auth_verification\` (
|
|||
\`created_at\` integer NOT NULL,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
`,
|
||||
m0002: `CREATE TABLE IF NOT EXISTS \`task_summaries\` (
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE \`task_index\` (
|
||||
\`task_id\` text PRIMARY KEY NOT NULL,
|
||||
\`repo_id\` text NOT NULL,
|
||||
\`branch_name\` text,
|
||||
\`created_at\` integer NOT NULL,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE \`task_summaries\` (
|
||||
\`task_id\` text PRIMARY KEY NOT NULL,
|
||||
\`repo_id\` text NOT NULL,
|
||||
\`title\` text NOT NULL,
|
||||
|
|
@ -170,8 +165,6 @@ CREATE TABLE IF NOT EXISTS \`auth_verification\` (
|
|||
\`pull_request_json\` text,
|
||||
\`sessions_summary_json\` text DEFAULT '[]' NOT NULL
|
||||
);
|
||||
`,
|
||||
m0003: `DROP TABLE IF EXISTS \`provider_profiles\`;
|
||||
`,
|
||||
} as const,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,34 +1,34 @@
|
|||
import { integer, sqliteTable, text } from "rivetkit/db/drizzle";
|
||||
import { check, integer, sqliteTable, text } from "rivetkit/db/drizzle";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { DEFAULT_WORKSPACE_MODEL_ID } from "@sandbox-agent/foundry-shared";
|
||||
|
||||
// SQLite is per organization actor instance, so no organizationId column needed.
|
||||
|
||||
/**
|
||||
* Coordinator index of RepositoryActor instances.
|
||||
* The organization actor is the coordinator for repositories.
|
||||
* Rows are created/removed when repos are added/removed from the organization.
|
||||
* Coordinator index of TaskActor instances.
|
||||
* The organization actor is the direct coordinator for tasks (not a per-repo
|
||||
* actor) because the sidebar needs to query all tasks across all repos on
|
||||
* every snapshot. With many repos, fanning out to N repo actors on the hot
|
||||
* read path is too expensive — owning the index here keeps that a single
|
||||
* local table scan. Each row maps a taskId to its repo and immutable branch
|
||||
* name. Used for branch conflict checking (scoped by repoId) and
|
||||
* task-by-branch lookups.
|
||||
*/
|
||||
export const repos = sqliteTable("repos", {
|
||||
repoId: text("repo_id").notNull().primaryKey(),
|
||||
remoteUrl: text("remote_url").notNull(),
|
||||
export const taskIndex = sqliteTable("task_index", {
|
||||
taskId: text("task_id").notNull().primaryKey(),
|
||||
repoId: text("repo_id").notNull(),
|
||||
branchName: text("branch_name"),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Coordinator index of TaskActor instances.
|
||||
* Fast taskId → repoId lookup so the organization can route requests
|
||||
* to the correct RepositoryActor without scanning all repos.
|
||||
*/
|
||||
export const taskLookup = sqliteTable("task_lookup", {
|
||||
taskId: text("task_id").notNull().primaryKey(),
|
||||
repoId: text("repo_id").notNull(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Coordinator index of TaskActor instances — materialized sidebar projection.
|
||||
* Task actors push summary updates to the organization actor via
|
||||
* applyTaskSummaryUpdate(). Source of truth lives on each TaskActor;
|
||||
* this table exists so organization reads stay local without fan-out.
|
||||
* Organization-owned materialized task summary projection.
|
||||
* Task actors push summary updates directly to the organization coordinator,
|
||||
* which keeps this table local for fast list/lookups without fan-out.
|
||||
* Same rationale as taskIndex: the sidebar repeatedly reads all tasks across
|
||||
* all repos, so the org must own the materialized view to avoid O(repos)
|
||||
* actor fan-out on the hot read path.
|
||||
*/
|
||||
export const taskSummaries = sqliteTable("task_summaries", {
|
||||
taskId: text("task_id").notNull().primaryKey(),
|
||||
|
|
@ -42,38 +42,46 @@ export const taskSummaries = sqliteTable("task_summaries", {
|
|||
sessionsSummaryJson: text("sessions_summary_json").notNull().default("[]"),
|
||||
});
|
||||
|
||||
export const organizationProfile = sqliteTable("organization_profile", {
|
||||
id: text("id").notNull().primaryKey(),
|
||||
kind: text("kind").notNull(),
|
||||
githubAccountId: text("github_account_id").notNull(),
|
||||
githubLogin: text("github_login").notNull(),
|
||||
githubAccountType: text("github_account_type").notNull(),
|
||||
displayName: text("display_name").notNull(),
|
||||
slug: text("slug").notNull(),
|
||||
primaryDomain: text("primary_domain").notNull(),
|
||||
defaultModel: text("default_model").notNull(),
|
||||
autoImportRepos: integer("auto_import_repos").notNull(),
|
||||
repoImportStatus: text("repo_import_status").notNull(),
|
||||
githubConnectedAccount: text("github_connected_account").notNull(),
|
||||
githubInstallationStatus: text("github_installation_status").notNull(),
|
||||
githubSyncStatus: text("github_sync_status").notNull(),
|
||||
githubInstallationId: integer("github_installation_id"),
|
||||
githubLastSyncLabel: text("github_last_sync_label").notNull(),
|
||||
githubLastSyncAt: integer("github_last_sync_at"),
|
||||
githubLastWebhookAt: integer("github_last_webhook_at"),
|
||||
githubLastWebhookEvent: text("github_last_webhook_event"),
|
||||
stripeCustomerId: text("stripe_customer_id"),
|
||||
stripeSubscriptionId: text("stripe_subscription_id"),
|
||||
stripePriceId: text("stripe_price_id"),
|
||||
billingPlanId: text("billing_plan_id").notNull(),
|
||||
billingStatus: text("billing_status").notNull(),
|
||||
billingSeatsIncluded: integer("billing_seats_included").notNull(),
|
||||
billingTrialEndsAt: text("billing_trial_ends_at"),
|
||||
billingRenewalAt: text("billing_renewal_at"),
|
||||
billingPaymentMethodLabel: text("billing_payment_method_label").notNull(),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
export const organizationProfile = sqliteTable(
|
||||
"organization_profile",
|
||||
{
|
||||
id: integer("id").primaryKey(),
|
||||
kind: text("kind").notNull(),
|
||||
githubAccountId: text("github_account_id").notNull(),
|
||||
githubLogin: text("github_login").notNull(),
|
||||
githubAccountType: text("github_account_type").notNull(),
|
||||
displayName: text("display_name").notNull(),
|
||||
slug: text("slug").notNull(),
|
||||
defaultModel: text("default_model").notNull().default(DEFAULT_WORKSPACE_MODEL_ID),
|
||||
primaryDomain: text("primary_domain").notNull(),
|
||||
autoImportRepos: integer("auto_import_repos").notNull(),
|
||||
repoImportStatus: text("repo_import_status").notNull(),
|
||||
githubConnectedAccount: text("github_connected_account").notNull(),
|
||||
githubInstallationStatus: text("github_installation_status").notNull(),
|
||||
githubSyncStatus: text("github_sync_status").notNull(),
|
||||
githubInstallationId: integer("github_installation_id"),
|
||||
githubLastSyncLabel: text("github_last_sync_label").notNull(),
|
||||
githubLastSyncAt: integer("github_last_sync_at"),
|
||||
githubLastWebhookAt: integer("github_last_webhook_at"),
|
||||
githubLastWebhookEvent: text("github_last_webhook_event"),
|
||||
githubSyncGeneration: integer("github_sync_generation").notNull(),
|
||||
githubSyncPhase: text("github_sync_phase"),
|
||||
githubProcessedRepositoryCount: integer("github_processed_repository_count").notNull(),
|
||||
githubTotalRepositoryCount: integer("github_total_repository_count").notNull(),
|
||||
stripeCustomerId: text("stripe_customer_id"),
|
||||
stripeSubscriptionId: text("stripe_subscription_id"),
|
||||
stripePriceId: text("stripe_price_id"),
|
||||
billingPlanId: text("billing_plan_id").notNull(),
|
||||
billingStatus: text("billing_status").notNull(),
|
||||
billingSeatsIncluded: integer("billing_seats_included").notNull(),
|
||||
billingTrialEndsAt: text("billing_trial_ends_at"),
|
||||
billingRenewalAt: text("billing_renewal_at"),
|
||||
billingPaymentMethodLabel: text("billing_payment_method_label").notNull(),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
},
|
||||
(table) => [check("organization_profile_singleton_id_check", sql`${table.id} = 1`)],
|
||||
);
|
||||
|
||||
export const organizationMembers = sqliteTable("organization_members", {
|
||||
id: text("id").notNull().primaryKey(),
|
||||
|
|
@ -133,6 +141,7 @@ export const authAccountIndex = sqliteTable("auth_account_index", {
|
|||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
/** Better Auth core model — schema defined at https://better-auth.com/docs/concepts/database */
|
||||
export const authVerification = sqliteTable("auth_verification", {
|
||||
id: text("id").notNull().primaryKey(),
|
||||
identifier: text("identifier").notNull(),
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import { actor, queue } from "rivetkit";
|
||||
import { workflow } from "rivetkit/workflow";
|
||||
import { actor } from "rivetkit";
|
||||
import { organizationDb } from "./db/db.js";
|
||||
import { runOrganizationWorkflow, ORGANIZATION_QUEUE_NAMES, organizationActions } from "./actions.js";
|
||||
import { organizationActions } from "./actions.js";
|
||||
import { organizationCommandActions } from "./workflow.js";
|
||||
|
||||
export const organization = actor({
|
||||
db: organizationDb,
|
||||
queues: Object.fromEntries(ORGANIZATION_QUEUE_NAMES.map((name) => [name, queue()])),
|
||||
options: {
|
||||
name: "Organization",
|
||||
icon: "compass",
|
||||
|
|
@ -14,6 +13,8 @@ export const organization = actor({
|
|||
createState: (_c, organizationId: string) => ({
|
||||
organizationId,
|
||||
}),
|
||||
actions: organizationActions,
|
||||
run: workflow(runOrganizationWorkflow),
|
||||
actions: {
|
||||
...organizationActions,
|
||||
...organizationCommandActions,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
39
foundry/packages/backend/src/actors/organization/queues.ts
Normal file
39
foundry/packages/backend/src/actors/organization/queues.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
export const ORGANIZATION_QUEUE_NAMES = [
|
||||
"organization.command.createTask",
|
||||
"organization.command.materializeTask",
|
||||
"organization.command.registerTaskBranch",
|
||||
"organization.command.applyTaskSummaryUpdate",
|
||||
"organization.command.removeTaskSummary",
|
||||
"organization.command.refreshTaskSummaryForBranch",
|
||||
"organization.command.snapshot.broadcast",
|
||||
"organization.command.syncGithubSession",
|
||||
"organization.command.better_auth.session_index.upsert",
|
||||
"organization.command.better_auth.session_index.delete",
|
||||
"organization.command.better_auth.email_index.upsert",
|
||||
"organization.command.better_auth.email_index.delete",
|
||||
"organization.command.better_auth.account_index.upsert",
|
||||
"organization.command.better_auth.account_index.delete",
|
||||
"organization.command.better_auth.verification.create",
|
||||
"organization.command.better_auth.verification.update",
|
||||
"organization.command.better_auth.verification.update_many",
|
||||
"organization.command.better_auth.verification.delete",
|
||||
"organization.command.better_auth.verification.delete_many",
|
||||
"organization.command.github.sync_progress.apply",
|
||||
"organization.command.github.webhook_receipt.record",
|
||||
"organization.command.github.organization_shell.sync_from_github",
|
||||
"organization.command.shell.profile.update",
|
||||
"organization.command.shell.sync_started.mark",
|
||||
"organization.command.billing.stripe_customer.apply",
|
||||
"organization.command.billing.stripe_subscription.apply",
|
||||
"organization.command.billing.free_plan.apply",
|
||||
"organization.command.billing.payment_method.set",
|
||||
"organization.command.billing.status.set",
|
||||
"organization.command.billing.invoice.upsert",
|
||||
"organization.command.billing.seat_usage.record",
|
||||
] as const;
|
||||
|
||||
export type OrganizationQueueName = (typeof ORGANIZATION_QUEUE_NAMES)[number];
|
||||
|
||||
export function organizationWorkflowQueueName(name: OrganizationQueueName): OrganizationQueueName {
|
||||
return name;
|
||||
}
|
||||
163
foundry/packages/backend/src/actors/organization/workflow.ts
Normal file
163
foundry/packages/backend/src/actors/organization/workflow.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
// @ts-nocheck
|
||||
/**
|
||||
* Organization command actions — converted from queue handlers to direct actions.
|
||||
* Each export becomes an action on the organization actor.
|
||||
*/
|
||||
import { applyGithubSyncProgressMutation, recordGithubWebhookReceiptMutation, refreshOrganizationSnapshotMutation } from "./actions.js";
|
||||
import {
|
||||
applyTaskSummaryUpdateMutation,
|
||||
createTaskMutation,
|
||||
refreshTaskSummaryForBranchMutation,
|
||||
registerTaskBranchMutation,
|
||||
removeTaskSummaryMutation,
|
||||
} from "./actions/task-mutations.js";
|
||||
import {
|
||||
betterAuthCreateVerificationMutation,
|
||||
betterAuthDeleteAccountIndexMutation,
|
||||
betterAuthDeleteEmailIndexMutation,
|
||||
betterAuthDeleteManyVerificationMutation,
|
||||
betterAuthDeleteSessionIndexMutation,
|
||||
betterAuthDeleteVerificationMutation,
|
||||
betterAuthUpdateManyVerificationMutation,
|
||||
betterAuthUpdateVerificationMutation,
|
||||
betterAuthUpsertAccountIndexMutation,
|
||||
betterAuthUpsertEmailIndexMutation,
|
||||
betterAuthUpsertSessionIndexMutation,
|
||||
} from "./actions/better-auth.js";
|
||||
import {
|
||||
applyOrganizationFreePlanMutation,
|
||||
applyOrganizationStripeCustomerMutation,
|
||||
applyOrganizationStripeSubscriptionMutation,
|
||||
markOrganizationSyncStartedMutation,
|
||||
recordOrganizationSeatUsageMutation,
|
||||
setOrganizationBillingPaymentMethodMutation,
|
||||
setOrganizationBillingStatusMutation,
|
||||
syncOrganizationShellFromGithubMutation,
|
||||
updateOrganizationShellProfileMutation,
|
||||
upsertOrganizationInvoiceMutation,
|
||||
} from "./app-shell.js";
|
||||
|
||||
export const organizationCommandActions = {
|
||||
async commandCreateTask(c: any, body: any) {
|
||||
return await createTaskMutation(c, body);
|
||||
},
|
||||
async commandMaterializeTask(c: any, body: any) {
|
||||
return await createTaskMutation(c, body);
|
||||
},
|
||||
async commandRegisterTaskBranch(c: any, body: any) {
|
||||
return await registerTaskBranchMutation(c, body);
|
||||
},
|
||||
async commandApplyTaskSummaryUpdate(c: any, body: any) {
|
||||
await applyTaskSummaryUpdateMutation(c, body);
|
||||
return { ok: true };
|
||||
},
|
||||
async commandRemoveTaskSummary(c: any, body: any) {
|
||||
await removeTaskSummaryMutation(c, body);
|
||||
return { ok: true };
|
||||
},
|
||||
async commandRefreshTaskSummaryForBranch(c: any, body: any) {
|
||||
await refreshTaskSummaryForBranchMutation(c, body);
|
||||
return { ok: true };
|
||||
},
|
||||
async commandBroadcastSnapshot(c: any, _body: any) {
|
||||
await refreshOrganizationSnapshotMutation(c);
|
||||
return { ok: true };
|
||||
},
|
||||
async commandSyncGithubSession(c: any, body: any) {
|
||||
const { syncGithubOrganizations } = await import("./app-shell.js");
|
||||
await syncGithubOrganizations(c, body);
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
// Better Auth index actions
|
||||
async commandBetterAuthSessionIndexUpsert(c: any, body: any) {
|
||||
return await betterAuthUpsertSessionIndexMutation(c, body);
|
||||
},
|
||||
async commandBetterAuthSessionIndexDelete(c: any, body: any) {
|
||||
await betterAuthDeleteSessionIndexMutation(c, body);
|
||||
return { ok: true };
|
||||
},
|
||||
async commandBetterAuthEmailIndexUpsert(c: any, body: any) {
|
||||
return await betterAuthUpsertEmailIndexMutation(c, body);
|
||||
},
|
||||
async commandBetterAuthEmailIndexDelete(c: any, body: any) {
|
||||
await betterAuthDeleteEmailIndexMutation(c, body);
|
||||
return { ok: true };
|
||||
},
|
||||
async commandBetterAuthAccountIndexUpsert(c: any, body: any) {
|
||||
return await betterAuthUpsertAccountIndexMutation(c, body);
|
||||
},
|
||||
async commandBetterAuthAccountIndexDelete(c: any, body: any) {
|
||||
await betterAuthDeleteAccountIndexMutation(c, body);
|
||||
return { ok: true };
|
||||
},
|
||||
async commandBetterAuthVerificationCreate(c: any, body: any) {
|
||||
return await betterAuthCreateVerificationMutation(c, body);
|
||||
},
|
||||
async commandBetterAuthVerificationUpdate(c: any, body: any) {
|
||||
return await betterAuthUpdateVerificationMutation(c, body);
|
||||
},
|
||||
async commandBetterAuthVerificationUpdateMany(c: any, body: any) {
|
||||
return await betterAuthUpdateManyVerificationMutation(c, body);
|
||||
},
|
||||
async commandBetterAuthVerificationDelete(c: any, body: any) {
|
||||
await betterAuthDeleteVerificationMutation(c, body);
|
||||
return { ok: true };
|
||||
},
|
||||
async commandBetterAuthVerificationDeleteMany(c: any, body: any) {
|
||||
return await betterAuthDeleteManyVerificationMutation(c, body);
|
||||
},
|
||||
|
||||
// GitHub sync actions
|
||||
async commandApplyGithubSyncProgress(c: any, body: any) {
|
||||
await applyGithubSyncProgressMutation(c, body);
|
||||
return { ok: true };
|
||||
},
|
||||
async commandRecordGithubWebhookReceipt(c: any, body: any) {
|
||||
await recordGithubWebhookReceiptMutation(c, body);
|
||||
return { ok: true };
|
||||
},
|
||||
async commandSyncOrganizationShellFromGithub(c: any, body: any) {
|
||||
return await syncOrganizationShellFromGithubMutation(c, body);
|
||||
},
|
||||
|
||||
// Shell/profile actions
|
||||
async commandUpdateShellProfile(c: any, body: any) {
|
||||
await updateOrganizationShellProfileMutation(c, body);
|
||||
return { ok: true };
|
||||
},
|
||||
async commandMarkSyncStarted(c: any, body: any) {
|
||||
await markOrganizationSyncStartedMutation(c, body);
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
// Billing actions
|
||||
async commandApplyStripeCustomer(c: any, body: any) {
|
||||
await applyOrganizationStripeCustomerMutation(c, body);
|
||||
return { ok: true };
|
||||
},
|
||||
async commandApplyStripeSubscription(c: any, body: any) {
|
||||
await applyOrganizationStripeSubscriptionMutation(c, body);
|
||||
return { ok: true };
|
||||
},
|
||||
async commandApplyFreePlan(c: any, body: any) {
|
||||
await applyOrganizationFreePlanMutation(c, body);
|
||||
return { ok: true };
|
||||
},
|
||||
async commandSetPaymentMethod(c: any, body: any) {
|
||||
await setOrganizationBillingPaymentMethodMutation(c, body);
|
||||
return { ok: true };
|
||||
},
|
||||
async commandSetBillingStatus(c: any, body: any) {
|
||||
await setOrganizationBillingStatusMutation(c, body);
|
||||
return { ok: true };
|
||||
},
|
||||
async commandUpsertInvoice(c: any, body: any) {
|
||||
await upsertOrganizationInvoiceMutation(c, body);
|
||||
return { ok: true };
|
||||
},
|
||||
async commandRecordSeatUsage(c: any, body: any) {
|
||||
await recordOrganizationSeatUsageMutation(c, body);
|
||||
return { ok: true };
|
||||
},
|
||||
};
|
||||
|
|
@ -1,557 +0,0 @@
|
|||
// @ts-nocheck
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { and, desc, eq, isNotNull, ne } from "drizzle-orm";
|
||||
import { Loop } from "rivetkit/workflow";
|
||||
import type { AgentType, RepoOverview, SandboxProviderId, TaskRecord, TaskSummary } from "@sandbox-agent/foundry-shared";
|
||||
import { getGithubData, getOrCreateHistory, getOrCreateTask, getTask, selfRepository } from "../handles.js";
|
||||
import { deriveFallbackTitle, resolveCreateFlowDecision } from "../../services/create-flow.js";
|
||||
import { expectQueueResponse } from "../../services/queue.js";
|
||||
import { isActorNotFoundError, logActorWarning, resolveErrorMessage } from "../logging.js";
|
||||
import { repoMeta, taskIndex } from "./db/schema.js";
|
||||
|
||||
interface CreateTaskCommand {
|
||||
task: string;
|
||||
sandboxProviderId: SandboxProviderId;
|
||||
agentType: AgentType | null;
|
||||
explicitTitle: string | null;
|
||||
explicitBranchName: string | null;
|
||||
initialPrompt: string | null;
|
||||
onBranch: string | null;
|
||||
}
|
||||
|
||||
interface RegisterTaskBranchCommand {
|
||||
taskId: string;
|
||||
branchName: string;
|
||||
requireExistingRemote?: boolean;
|
||||
}
|
||||
|
||||
interface ListTaskSummariesCommand {
|
||||
includeArchived?: boolean;
|
||||
}
|
||||
|
||||
interface GetTaskEnrichedCommand {
|
||||
taskId: string;
|
||||
}
|
||||
|
||||
interface GetPullRequestForBranchCommand {
|
||||
branchName: string;
|
||||
}
|
||||
|
||||
const REPOSITORY_QUEUE_NAMES = ["repository.command.createTask", "repository.command.registerTaskBranch"] as const;
|
||||
|
||||
type RepositoryQueueName = (typeof REPOSITORY_QUEUE_NAMES)[number];
|
||||
|
||||
export { REPOSITORY_QUEUE_NAMES };
|
||||
|
||||
export function repositoryWorkflowQueueName(name: RepositoryQueueName): RepositoryQueueName {
|
||||
return name;
|
||||
}
|
||||
|
||||
function isStaleTaskReferenceError(error: unknown): boolean {
|
||||
const message = resolveErrorMessage(error);
|
||||
return isActorNotFoundError(error) || message.startsWith("Task not found:");
|
||||
}
|
||||
|
||||
async function persistRemoteUrl(c: any, remoteUrl: string): Promise<void> {
|
||||
c.state.remoteUrl = remoteUrl;
|
||||
await c.db
|
||||
.insert(repoMeta)
|
||||
.values({
|
||||
id: 1,
|
||||
remoteUrl,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: repoMeta.id,
|
||||
set: {
|
||||
remoteUrl,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
async function deleteStaleTaskIndexRow(c: any, taskId: string): Promise<void> {
|
||||
try {
|
||||
await c.db.delete(taskIndex).where(eq(taskIndex.taskId, taskId)).run();
|
||||
} catch {
|
||||
// Best effort cleanup only.
|
||||
}
|
||||
}
|
||||
|
||||
async function reinsertTaskIndexRow(c: any, taskId: string, branchName: string | null, updatedAt: number): Promise<void> {
|
||||
const now = Date.now();
|
||||
await c.db
|
||||
.insert(taskIndex)
|
||||
.values({
|
||||
taskId,
|
||||
branchName,
|
||||
createdAt: updatedAt || now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: taskIndex.taskId,
|
||||
set: {
|
||||
branchName,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
async function listKnownTaskBranches(c: any): Promise<string[]> {
|
||||
const rows = await c.db.select({ branchName: taskIndex.branchName }).from(taskIndex).where(isNotNull(taskIndex.branchName)).all();
|
||||
return rows.map((row) => row.branchName).filter((value): value is string => typeof value === "string" && value.trim().length > 0);
|
||||
}
|
||||
|
||||
async function resolveGitHubRepository(c: any) {
|
||||
const githubData = getGithubData(c, c.state.organizationId);
|
||||
return await githubData.getRepository({ repoId: c.state.repoId }).catch(() => null);
|
||||
}
|
||||
|
||||
async function listGitHubBranches(c: any): Promise<Array<{ branchName: string; commitSha: string }>> {
|
||||
const githubData = getGithubData(c, c.state.organizationId);
|
||||
return await githubData.listBranchesForRepository({ repoId: c.state.repoId }).catch(() => []);
|
||||
}
|
||||
|
||||
async function enrichTaskRecord(c: any, record: TaskRecord): Promise<TaskRecord> {
|
||||
const branchName = record.branchName?.trim() || null;
|
||||
if (!branchName) {
|
||||
return record;
|
||||
}
|
||||
|
||||
const pr =
|
||||
branchName != null
|
||||
? await getGithubData(c, c.state.organizationId)
|
||||
.listPullRequestsForRepository({ repoId: c.state.repoId })
|
||||
.then((rows: any[]) => rows.find((row) => row.headRefName === branchName) ?? null)
|
||||
.catch(() => null)
|
||||
: null;
|
||||
|
||||
return {
|
||||
...record,
|
||||
prUrl: pr?.url ?? null,
|
||||
prAuthor: pr?.authorLogin ?? null,
|
||||
ciStatus: null,
|
||||
reviewStatus: null,
|
||||
reviewer: pr?.authorLogin ?? null,
|
||||
diffStat: record.diffStat ?? null,
|
||||
hasUnpushed: record.hasUnpushed ?? null,
|
||||
conflictsWithMain: record.conflictsWithMain ?? null,
|
||||
parentBranch: record.parentBranch ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
async function createTaskMutation(c: any, cmd: CreateTaskCommand): Promise<TaskRecord> {
|
||||
const organizationId = c.state.organizationId;
|
||||
const repoId = c.state.repoId;
|
||||
const repoRemote = c.state.remoteUrl;
|
||||
const onBranch = cmd.onBranch?.trim() || null;
|
||||
const taskId = randomUUID();
|
||||
let initialBranchName: string | null = null;
|
||||
let initialTitle: string | null = null;
|
||||
|
||||
await persistRemoteUrl(c, repoRemote);
|
||||
|
||||
if (onBranch) {
|
||||
initialBranchName = onBranch;
|
||||
initialTitle = deriveFallbackTitle(cmd.task, cmd.explicitTitle ?? undefined);
|
||||
|
||||
await registerTaskBranchMutation(c, {
|
||||
taskId,
|
||||
branchName: onBranch,
|
||||
requireExistingRemote: true,
|
||||
});
|
||||
} else {
|
||||
const reservedBranches = await listKnownTaskBranches(c);
|
||||
const resolved = resolveCreateFlowDecision({
|
||||
task: cmd.task,
|
||||
explicitTitle: cmd.explicitTitle ?? undefined,
|
||||
explicitBranchName: cmd.explicitBranchName ?? undefined,
|
||||
localBranches: [],
|
||||
taskBranches: reservedBranches,
|
||||
});
|
||||
|
||||
initialBranchName = resolved.branchName;
|
||||
initialTitle = resolved.title;
|
||||
|
||||
const now = Date.now();
|
||||
await c.db
|
||||
.insert(taskIndex)
|
||||
.values({
|
||||
taskId,
|
||||
branchName: resolved.branchName,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.run();
|
||||
}
|
||||
|
||||
let taskHandle: Awaited<ReturnType<typeof getOrCreateTask>>;
|
||||
try {
|
||||
taskHandle = await getOrCreateTask(c, organizationId, repoId, taskId, {
|
||||
organizationId,
|
||||
repoId,
|
||||
taskId,
|
||||
repoRemote,
|
||||
branchName: initialBranchName,
|
||||
title: initialTitle,
|
||||
task: cmd.task,
|
||||
sandboxProviderId: cmd.sandboxProviderId,
|
||||
agentType: cmd.agentType,
|
||||
explicitTitle: null,
|
||||
explicitBranchName: null,
|
||||
initialPrompt: cmd.initialPrompt,
|
||||
});
|
||||
} catch (error) {
|
||||
if (initialBranchName) {
|
||||
await deleteStaleTaskIndexRow(c, taskId);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const created = await taskHandle.initialize({ sandboxProviderId: cmd.sandboxProviderId });
|
||||
|
||||
const history = await getOrCreateHistory(c, organizationId, repoId);
|
||||
await history.append({
|
||||
kind: "task.created",
|
||||
taskId,
|
||||
payload: {
|
||||
repoId,
|
||||
sandboxProviderId: cmd.sandboxProviderId,
|
||||
},
|
||||
});
|
||||
|
||||
return created;
|
||||
}
|
||||
|
||||
async function registerTaskBranchMutation(c: any, cmd: RegisterTaskBranchCommand): Promise<{ branchName: string; headSha: string }> {
|
||||
const branchName = cmd.branchName.trim();
|
||||
if (!branchName) {
|
||||
throw new Error("branchName is required");
|
||||
}
|
||||
|
||||
await persistRemoteUrl(c, c.state.remoteUrl);
|
||||
|
||||
const existingOwner = await c.db
|
||||
.select({ taskId: taskIndex.taskId })
|
||||
.from(taskIndex)
|
||||
.where(and(eq(taskIndex.branchName, branchName), ne(taskIndex.taskId, cmd.taskId)))
|
||||
.get();
|
||||
|
||||
if (existingOwner) {
|
||||
let ownerMissing = false;
|
||||
try {
|
||||
await getTask(c, c.state.organizationId, c.state.repoId, existingOwner.taskId).get();
|
||||
} catch (error) {
|
||||
if (isStaleTaskReferenceError(error)) {
|
||||
ownerMissing = true;
|
||||
await deleteStaleTaskIndexRow(c, existingOwner.taskId);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
if (!ownerMissing) {
|
||||
throw new Error(`branch is already assigned to a different task: ${branchName}`);
|
||||
}
|
||||
}
|
||||
|
||||
const branches = await listGitHubBranches(c);
|
||||
const branchMatch = branches.find((branch) => branch.branchName === branchName) ?? null;
|
||||
if (cmd.requireExistingRemote && !branchMatch) {
|
||||
throw new Error(`Remote branch not found: ${branchName}`);
|
||||
}
|
||||
|
||||
const repository = await resolveGitHubRepository(c);
|
||||
const defaultBranch = repository?.defaultBranch ?? "main";
|
||||
const headSha = branchMatch?.commitSha ?? branches.find((branch) => branch.branchName === defaultBranch)?.commitSha ?? "";
|
||||
|
||||
const now = Date.now();
|
||||
await c.db
|
||||
.insert(taskIndex)
|
||||
.values({
|
||||
taskId: cmd.taskId,
|
||||
branchName,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: taskIndex.taskId,
|
||||
set: {
|
||||
branchName,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
|
||||
return { branchName, headSha };
|
||||
}
|
||||
|
||||
async function listTaskSummaries(c: any, includeArchived = false): Promise<TaskSummary[]> {
|
||||
const taskRows = await c.db.select({ taskId: taskIndex.taskId }).from(taskIndex).orderBy(desc(taskIndex.updatedAt)).all();
|
||||
const records: TaskSummary[] = [];
|
||||
|
||||
for (const row of taskRows) {
|
||||
try {
|
||||
const record = await getTask(c, c.state.organizationId, c.state.repoId, row.taskId).get();
|
||||
if (!includeArchived && record.status === "archived") {
|
||||
continue;
|
||||
}
|
||||
records.push({
|
||||
organizationId: record.organizationId,
|
||||
repoId: record.repoId,
|
||||
taskId: record.taskId,
|
||||
branchName: record.branchName,
|
||||
title: record.title,
|
||||
status: record.status,
|
||||
updatedAt: record.updatedAt,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isStaleTaskReferenceError(error)) {
|
||||
await deleteStaleTaskIndexRow(c, row.taskId);
|
||||
continue;
|
||||
}
|
||||
logActorWarning("repository", "failed loading task summary row", {
|
||||
organizationId: c.state.organizationId,
|
||||
repoId: c.state.repoId,
|
||||
taskId: row.taskId,
|
||||
error: resolveErrorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
records.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
return records;
|
||||
}
|
||||
|
||||
function sortOverviewBranches(
|
||||
branches: Array<{
|
||||
branchName: string;
|
||||
commitSha: string;
|
||||
taskId: string | null;
|
||||
taskTitle: string | null;
|
||||
taskStatus: TaskRecord["status"] | null;
|
||||
prNumber: number | null;
|
||||
prState: string | null;
|
||||
prUrl: string | null;
|
||||
ciStatus: string | null;
|
||||
reviewStatus: string | null;
|
||||
reviewer: string | null;
|
||||
updatedAt: number;
|
||||
}>,
|
||||
defaultBranch: string | null,
|
||||
) {
|
||||
return [...branches].sort((left, right) => {
|
||||
if (defaultBranch) {
|
||||
if (left.branchName === defaultBranch && right.branchName !== defaultBranch) return -1;
|
||||
if (right.branchName === defaultBranch && left.branchName !== defaultBranch) return 1;
|
||||
}
|
||||
if (Boolean(left.taskId) !== Boolean(right.taskId)) {
|
||||
return left.taskId ? -1 : 1;
|
||||
}
|
||||
if (left.updatedAt !== right.updatedAt) {
|
||||
return right.updatedAt - left.updatedAt;
|
||||
}
|
||||
return left.branchName.localeCompare(right.branchName);
|
||||
});
|
||||
}
|
||||
|
||||
export async function runRepositoryWorkflow(ctx: any): Promise<void> {
|
||||
await ctx.loop("repository-command-loop", async (loopCtx: any) => {
|
||||
const msg = await loopCtx.queue.next("next-repository-command", {
|
||||
names: [...REPOSITORY_QUEUE_NAMES],
|
||||
completable: true,
|
||||
});
|
||||
if (!msg) {
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
|
||||
try {
|
||||
if (msg.name === "repository.command.createTask") {
|
||||
const result = await loopCtx.step({
|
||||
name: "repository-create-task",
|
||||
timeout: 5 * 60_000,
|
||||
run: async () => createTaskMutation(loopCtx, msg.body as CreateTaskCommand),
|
||||
});
|
||||
await msg.complete(result);
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
|
||||
if (msg.name === "repository.command.registerTaskBranch") {
|
||||
const result = await loopCtx.step({
|
||||
name: "repository-register-task-branch",
|
||||
timeout: 60_000,
|
||||
run: async () => registerTaskBranchMutation(loopCtx, msg.body as RegisterTaskBranchCommand),
|
||||
});
|
||||
await msg.complete(result);
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = resolveErrorMessage(error);
|
||||
logActorWarning("repository", "repository workflow command failed", {
|
||||
queueName: msg.name,
|
||||
error: message,
|
||||
});
|
||||
await msg.complete({ error: message }).catch(() => {});
|
||||
}
|
||||
|
||||
return Loop.continue(undefined);
|
||||
});
|
||||
}
|
||||
|
||||
export const repositoryActions = {
|
||||
async createTask(c: any, cmd: CreateTaskCommand): Promise<TaskRecord> {
|
||||
const self = selfRepository(c);
|
||||
return expectQueueResponse<TaskRecord>(
|
||||
await self.send(repositoryWorkflowQueueName("repository.command.createTask"), cmd, {
|
||||
wait: true,
|
||||
timeout: 10_000,
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
async listReservedBranches(c: any): Promise<string[]> {
|
||||
return await listKnownTaskBranches(c);
|
||||
},
|
||||
|
||||
async registerTaskBranch(c: any, cmd: RegisterTaskBranchCommand): Promise<{ branchName: string; headSha: string }> {
|
||||
const self = selfRepository(c);
|
||||
return expectQueueResponse<{ branchName: string; headSha: string }>(
|
||||
await self.send(repositoryWorkflowQueueName("repository.command.registerTaskBranch"), cmd, {
|
||||
wait: true,
|
||||
timeout: 10_000,
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
async listTaskSummaries(c: any, cmd?: ListTaskSummariesCommand): Promise<TaskSummary[]> {
|
||||
return await listTaskSummaries(c, cmd?.includeArchived === true);
|
||||
},
|
||||
|
||||
async getTaskEnriched(c: any, cmd: GetTaskEnrichedCommand): Promise<TaskRecord> {
|
||||
const row = await c.db.select({ taskId: taskIndex.taskId }).from(taskIndex).where(eq(taskIndex.taskId, cmd.taskId)).get();
|
||||
if (!row) {
|
||||
const record = await getTask(c, c.state.organizationId, c.state.repoId, cmd.taskId).get();
|
||||
await reinsertTaskIndexRow(c, cmd.taskId, record.branchName ?? null, record.updatedAt ?? Date.now());
|
||||
return await enrichTaskRecord(c, record);
|
||||
}
|
||||
|
||||
try {
|
||||
const record = await getTask(c, c.state.organizationId, c.state.repoId, cmd.taskId).get();
|
||||
return await enrichTaskRecord(c, record);
|
||||
} catch (error) {
|
||||
if (isStaleTaskReferenceError(error)) {
|
||||
await deleteStaleTaskIndexRow(c, cmd.taskId);
|
||||
throw new Error(`Unknown task in repo ${c.state.repoId}: ${cmd.taskId}`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async getRepositoryMetadata(c: any): Promise<{ defaultBranch: string | null; fullName: string | null; remoteUrl: string }> {
|
||||
const repository = await resolveGitHubRepository(c);
|
||||
return {
|
||||
defaultBranch: repository?.defaultBranch ?? null,
|
||||
fullName: repository?.fullName ?? null,
|
||||
remoteUrl: c.state.remoteUrl,
|
||||
};
|
||||
},
|
||||
|
||||
async getRepoOverview(c: any): Promise<RepoOverview> {
|
||||
await persistRemoteUrl(c, c.state.remoteUrl);
|
||||
|
||||
const now = Date.now();
|
||||
const repository = await resolveGitHubRepository(c);
|
||||
const githubBranches = await listGitHubBranches(c).catch(() => []);
|
||||
const githubData = getGithubData(c, c.state.organizationId);
|
||||
const prRows = await githubData.listPullRequestsForRepository({ repoId: c.state.repoId }).catch(() => []);
|
||||
const prByBranch = new Map(prRows.map((row) => [row.headRefName, row]));
|
||||
|
||||
const taskRows = await c.db
|
||||
.select({
|
||||
taskId: taskIndex.taskId,
|
||||
branchName: taskIndex.branchName,
|
||||
updatedAt: taskIndex.updatedAt,
|
||||
})
|
||||
.from(taskIndex)
|
||||
.all();
|
||||
|
||||
const taskMetaByBranch = new Map<string, { taskId: string; title: string | null; status: TaskRecord["status"] | null; updatedAt: number }>();
|
||||
for (const row of taskRows) {
|
||||
if (!row.branchName) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const record = await getTask(c, c.state.organizationId, c.state.repoId, row.taskId).get();
|
||||
taskMetaByBranch.set(row.branchName, {
|
||||
taskId: row.taskId,
|
||||
title: record.title ?? null,
|
||||
status: record.status,
|
||||
updatedAt: record.updatedAt,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isStaleTaskReferenceError(error)) {
|
||||
await deleteStaleTaskIndexRow(c, row.taskId);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const branchMap = new Map<string, { branchName: string; commitSha: string }>();
|
||||
for (const branch of githubBranches) {
|
||||
branchMap.set(branch.branchName, branch);
|
||||
}
|
||||
for (const branchName of taskMetaByBranch.keys()) {
|
||||
if (!branchMap.has(branchName)) {
|
||||
branchMap.set(branchName, { branchName, commitSha: "" });
|
||||
}
|
||||
}
|
||||
if (repository?.defaultBranch && !branchMap.has(repository.defaultBranch)) {
|
||||
branchMap.set(repository.defaultBranch, { branchName: repository.defaultBranch, commitSha: "" });
|
||||
}
|
||||
|
||||
const branches = sortOverviewBranches(
|
||||
[...branchMap.values()].map((branch) => {
|
||||
const taskMeta = taskMetaByBranch.get(branch.branchName);
|
||||
const pr = prByBranch.get(branch.branchName);
|
||||
return {
|
||||
branchName: branch.branchName,
|
||||
commitSha: branch.commitSha,
|
||||
taskId: taskMeta?.taskId ?? null,
|
||||
taskTitle: taskMeta?.title ?? null,
|
||||
taskStatus: taskMeta?.status ?? null,
|
||||
prNumber: pr?.number ?? null,
|
||||
prState: pr?.state ?? null,
|
||||
prUrl: pr?.url ?? null,
|
||||
ciStatus: null,
|
||||
reviewStatus: null,
|
||||
reviewer: pr?.authorLogin ?? null,
|
||||
updatedAt: Math.max(taskMeta?.updatedAt ?? 0, pr?.updatedAtMs ?? 0, now),
|
||||
};
|
||||
}),
|
||||
repository?.defaultBranch ?? null,
|
||||
);
|
||||
|
||||
return {
|
||||
organizationId: c.state.organizationId,
|
||||
repoId: c.state.repoId,
|
||||
remoteUrl: c.state.remoteUrl,
|
||||
baseRef: repository?.defaultBranch ?? null,
|
||||
fetchedAt: now,
|
||||
branches,
|
||||
};
|
||||
},
|
||||
|
||||
async getPullRequestForBranch(c: any, cmd: GetPullRequestForBranchCommand): Promise<{ number: number; status: "draft" | "ready" } | null> {
|
||||
const branchName = cmd.branchName?.trim();
|
||||
if (!branchName) {
|
||||
return null;
|
||||
}
|
||||
const githubData = getGithubData(c, c.state.organizationId);
|
||||
return await githubData.getPullRequestForBranch({
|
||||
repoId: c.state.repoId,
|
||||
branchName,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
import { db } from "rivetkit/db/drizzle";
|
||||
import * as schema from "./schema.js";
|
||||
import migrations from "./migrations.js";
|
||||
|
||||
export const repositoryDb = db({ schema, migrations });
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
import { defineConfig } from "rivetkit/db/drizzle";
|
||||
|
||||
export default defineConfig({
|
||||
out: "./src/actors/repository/db/drizzle",
|
||||
schema: "./src/actors/repository/db/schema.ts",
|
||||
});
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
CREATE TABLE `repo_meta` (
|
||||
`id` integer PRIMARY KEY NOT NULL,
|
||||
`remote_url` text NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `task_index` (
|
||||
`task_id` text PRIMARY KEY NOT NULL,
|
||||
`branch_name` text,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1773376221848,
|
||||
"tag": "0000_useful_la_nuit",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
// 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: 1773376221848,
|
||||
tag: "0000_useful_la_nuit",
|
||||
breakpoints: true,
|
||||
},
|
||||
{
|
||||
idx: 1,
|
||||
when: 1778900000000,
|
||||
tag: "0001_remove_local_git_state",
|
||||
breakpoints: true,
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
|
||||
export default {
|
||||
journal,
|
||||
migrations: {
|
||||
m0000: `CREATE TABLE \`repo_meta\` (
|
||||
\t\`id\` integer PRIMARY KEY NOT NULL,
|
||||
\t\`remote_url\` text NOT NULL,
|
||||
\t\`updated_at\` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE \`task_index\` (
|
||||
\t\`task_id\` text PRIMARY KEY NOT NULL,
|
||||
\t\`branch_name\` text,
|
||||
\t\`created_at\` integer NOT NULL,
|
||||
\t\`updated_at\` integer NOT NULL
|
||||
);
|
||||
`,
|
||||
m0001: `DROP TABLE IF EXISTS \`branches\`;
|
||||
--> statement-breakpoint
|
||||
DROP TABLE IF EXISTS \`repo_action_jobs\`;
|
||||
`,
|
||||
} as const,
|
||||
};
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
import { integer, sqliteTable, text } from "rivetkit/db/drizzle";
|
||||
|
||||
// SQLite is per repository actor instance (organizationId+repoId).
|
||||
|
||||
export const repoMeta = sqliteTable("repo_meta", {
|
||||
id: integer("id").primaryKey(),
|
||||
remoteUrl: text("remote_url").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Coordinator index of TaskActor instances.
|
||||
* The repository actor is the coordinator for tasks. Each row maps a
|
||||
* taskId to its branch name. Used for branch conflict checking and
|
||||
* task-by-branch lookups. Rows are inserted at task creation and
|
||||
* updated on branch rename.
|
||||
*/
|
||||
export const taskIndex = sqliteTable("task_index", {
|
||||
taskId: text("task_id").notNull().primaryKey(),
|
||||
branchName: text("branch_name"),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
import { actor, queue } from "rivetkit";
|
||||
import { workflow } from "rivetkit/workflow";
|
||||
import { repositoryDb } from "./db/db.js";
|
||||
import { REPOSITORY_QUEUE_NAMES, repositoryActions, runRepositoryWorkflow } from "./actions.js";
|
||||
|
||||
export interface RepositoryInput {
|
||||
organizationId: string;
|
||||
repoId: string;
|
||||
remoteUrl: string;
|
||||
}
|
||||
|
||||
export const repository = actor({
|
||||
db: repositoryDb,
|
||||
queues: Object.fromEntries(REPOSITORY_QUEUE_NAMES.map((name) => [name, queue()])),
|
||||
options: {
|
||||
name: "Repository",
|
||||
icon: "folder",
|
||||
actionTimeout: 5 * 60_000,
|
||||
},
|
||||
createState: (_c, input: RepositoryInput) => ({
|
||||
organizationId: input.organizationId,
|
||||
repoId: input.repoId,
|
||||
remoteUrl: input.remoteUrl,
|
||||
}),
|
||||
actions: repositoryActions,
|
||||
run: workflow(runRepositoryWorkflow),
|
||||
});
|
||||
|
|
@ -2,12 +2,14 @@ import { actor } from "rivetkit";
|
|||
import { e2b, sandboxActor } from "rivetkit/sandbox";
|
||||
import { existsSync } from "node:fs";
|
||||
import Dockerode from "dockerode";
|
||||
import { DEFAULT_WORKSPACE_MODEL_GROUPS, workspaceModelGroupsFromSandboxAgents, type WorkspaceModelGroup } from "@sandbox-agent/foundry-shared";
|
||||
import { SandboxAgent } from "sandbox-agent";
|
||||
import { getActorRuntimeContext } from "../context.js";
|
||||
import { organizationKey } from "../keys.js";
|
||||
import { logActorWarning, resolveErrorMessage } from "../logging.js";
|
||||
import { resolveSandboxProviderId } from "../../sandbox-config.js";
|
||||
|
||||
const SANDBOX_REPO_CWD = "/home/sandbox/organization/repo";
|
||||
const SANDBOX_REPO_CWD = "/home/user/repo";
|
||||
const DEFAULT_LOCAL_SANDBOX_IMAGE = "rivetdev/sandbox-agent:full";
|
||||
const DEFAULT_LOCAL_SANDBOX_PORT = 2468;
|
||||
const dockerClient = new Dockerode({ socketPath: "/var/run/docker.sock" });
|
||||
|
|
@ -203,6 +205,13 @@ const baseTaskSandbox = sandboxActor({
|
|||
create: () => ({
|
||||
template: config.sandboxProviders.e2b.template ?? "sandbox-agent-full-0.3.x",
|
||||
envs: sandboxEnvObject(),
|
||||
// TEMPORARY: Default E2B timeout is 5 minutes which is too short.
|
||||
// Set to 1 hour as a stopgap. Remove this once the E2B provider in
|
||||
// sandbox-agent uses betaCreate + autoPause (see
|
||||
// .context/proposal-rivetkit-sandbox-resilience.md). At that point
|
||||
// the provider handles timeout/pause lifecycle and this override is
|
||||
// unnecessary.
|
||||
timeoutMs: 60 * 60 * 1000,
|
||||
}),
|
||||
installAgents: ["claude", "codex"],
|
||||
});
|
||||
|
|
@ -219,8 +228,12 @@ async function broadcastProcesses(c: any, actions: Record<string, (...args: any[
|
|||
type: "processesUpdated",
|
||||
processes: listed.processes ?? [],
|
||||
});
|
||||
} catch {
|
||||
} catch (error) {
|
||||
// Process broadcasts are best-effort. Callers still receive the primary action result.
|
||||
logActorWarning("taskSandbox", "broadcastProcesses failed", {
|
||||
sandboxId: c.state?.sandboxId,
|
||||
error: resolveErrorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -258,6 +271,26 @@ async function providerForConnection(c: any): Promise<any | null> {
|
|||
return provider;
|
||||
}
|
||||
|
||||
async function listWorkspaceModelGroupsForSandbox(c: any): Promise<WorkspaceModelGroup[]> {
|
||||
const provider = await providerForConnection(c);
|
||||
if (!provider || !c.state.sandboxId || typeof provider.connectAgent !== "function") {
|
||||
return DEFAULT_WORKSPACE_MODEL_GROUPS;
|
||||
}
|
||||
|
||||
try {
|
||||
const client = await provider.connectAgent(c.state.sandboxId, {
|
||||
waitForHealth: {
|
||||
timeoutMs: 15_000,
|
||||
},
|
||||
});
|
||||
const listed = await client.listAgents({ config: true });
|
||||
const groups = workspaceModelGroupsFromSandboxAgents(Array.isArray(listed?.agents) ? listed.agents : []);
|
||||
return groups.length > 0 ? groups : DEFAULT_WORKSPACE_MODEL_GROUPS;
|
||||
} catch {
|
||||
return DEFAULT_WORKSPACE_MODEL_GROUPS;
|
||||
}
|
||||
}
|
||||
|
||||
const baseActions = baseTaskSandbox.config.actions as Record<string, (c: any, ...args: any[]) => Promise<any>>;
|
||||
|
||||
export const taskSandbox = actor({
|
||||
|
|
@ -316,6 +349,19 @@ export const taskSandbox = actor({
|
|||
return sanitizeActorResult(await session.prompt([{ type: "text", text }]));
|
||||
},
|
||||
|
||||
async listProcesses(c: any): Promise<any> {
|
||||
try {
|
||||
return await baseActions.listProcesses(c);
|
||||
} catch (error) {
|
||||
// Sandbox may be gone (E2B timeout, destroyed, etc.) — degrade to empty
|
||||
logActorWarning("taskSandbox", "listProcesses failed, sandbox may be expired", {
|
||||
sandboxId: c.state.sandboxId,
|
||||
error: resolveErrorMessage(error),
|
||||
});
|
||||
return { processes: [] };
|
||||
}
|
||||
},
|
||||
|
||||
async createProcess(c: any, request: any): Promise<any> {
|
||||
const created = await baseActions.createProcess(c, request);
|
||||
await broadcastProcesses(c, baseActions);
|
||||
|
|
@ -360,6 +406,10 @@ export const taskSandbox = actor({
|
|||
}
|
||||
},
|
||||
|
||||
async listWorkspaceModelGroups(c: any): Promise<WorkspaceModelGroup[]> {
|
||||
return await listWorkspaceModelGroupsForSandbox(c);
|
||||
},
|
||||
|
||||
async providerState(c: any): Promise<{ sandboxProviderId: "e2b" | "local"; sandboxId: string; state: string; at: number }> {
|
||||
const { config } = getActorRuntimeContext();
|
||||
const { taskId } = parseTaskSandboxKey(c.key);
|
||||
|
|
|
|||
|
|
@ -3,10 +3,9 @@ CREATE TABLE `task` (
|
|||
`branch_name` text,
|
||||
`title` text,
|
||||
`task` text NOT NULL,
|
||||
`provider_id` text NOT NULL,
|
||||
`sandbox_provider_id` text NOT NULL,
|
||||
`status` text NOT NULL,
|
||||
`agent_type` text DEFAULT 'claude',
|
||||
`pr_submitted` integer DEFAULT 0,
|
||||
`pull_request_json` text,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL,
|
||||
CONSTRAINT "task_singleton_id_check" CHECK("task"."id" = 1)
|
||||
|
|
@ -15,33 +14,33 @@ CREATE TABLE `task` (
|
|||
CREATE TABLE `task_runtime` (
|
||||
`id` integer PRIMARY KEY NOT NULL,
|
||||
`active_sandbox_id` text,
|
||||
`active_session_id` text,
|
||||
`active_switch_target` text,
|
||||
`active_cwd` text,
|
||||
`status_message` text,
|
||||
`git_state_json` text,
|
||||
`git_state_updated_at` integer,
|
||||
`updated_at` integer NOT NULL,
|
||||
CONSTRAINT "task_runtime_singleton_id_check" CHECK("task_runtime"."id" = 1)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `task_sandboxes` (
|
||||
`sandbox_id` text PRIMARY KEY NOT NULL,
|
||||
`provider_id` text NOT NULL,
|
||||
`sandbox_provider_id` text NOT NULL,
|
||||
`sandbox_actor_id` text,
|
||||
`switch_target` text NOT NULL,
|
||||
`cwd` text,
|
||||
`status_message` text,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `task_workbench_sessions` (
|
||||
CREATE TABLE `task_workspace_sessions` (
|
||||
`session_id` text PRIMARY KEY NOT NULL,
|
||||
`sandbox_session_id` text,
|
||||
`session_name` text NOT NULL,
|
||||
`model` text NOT NULL,
|
||||
`unread` integer DEFAULT 0 NOT NULL,
|
||||
`draft_text` text DEFAULT '' NOT NULL,
|
||||
`draft_attachments_json` text DEFAULT '[]' NOT NULL,
|
||||
`draft_updated_at` integer,
|
||||
`status` text DEFAULT 'ready' NOT NULL,
|
||||
`error_message` text,
|
||||
`transcript_json` text DEFAULT '[]' NOT NULL,
|
||||
`transcript_updated_at` integer,
|
||||
`created` integer DEFAULT 1 NOT NULL,
|
||||
`closed` integer DEFAULT 0 NOT NULL,
|
||||
`thinking_since_ms` integer,
|
||||
|
|
|
|||
|
|
@ -35,8 +35,8 @@
|
|||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"sandbox_provider_id": {
|
||||
"name": "sandbox_provider_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
|
|
@ -49,21 +49,12 @@
|
|||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent_type": {
|
||||
"name": "agent_type",
|
||||
"pull_request_json": {
|
||||
"name": "pull_request_json",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'claude'"
|
||||
},
|
||||
"pr_submitted": {
|
||||
"name": "pr_submitted",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
|
|
@ -108,13 +99,6 @@
|
|||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"active_session_id": {
|
||||
"name": "active_session_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"active_switch_target": {
|
||||
"name": "active_switch_target",
|
||||
"type": "text",
|
||||
|
|
@ -129,13 +113,20 @@
|
|||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status_message": {
|
||||
"name": "status_message",
|
||||
"git_state_json": {
|
||||
"name": "git_state_json",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"git_state_updated_at": {
|
||||
"name": "git_state_updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
|
|
@ -165,8 +156,8 @@
|
|||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"sandbox_provider_id": {
|
||||
"name": "sandbox_provider_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
|
|
@ -193,13 +184,6 @@
|
|||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status_message": {
|
||||
"name": "status_message",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
|
|
@ -221,8 +205,8 @@
|
|||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_workbench_sessions": {
|
||||
"name": "task_workbench_sessions",
|
||||
"task_workspace_sessions": {
|
||||
"name": "task_workspace_sessions",
|
||||
"columns": {
|
||||
"session_id": {
|
||||
"name": "session_id",
|
||||
|
|
@ -231,6 +215,13 @@
|
|||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sandbox_session_id": {
|
||||
"name": "sandbox_session_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"session_name": {
|
||||
"name": "session_name",
|
||||
"type": "text",
|
||||
|
|
@ -245,32 +236,31 @@
|
|||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"unread": {
|
||||
"name": "unread",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"draft_text": {
|
||||
"name": "draft_text",
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
"default": "'ready'"
|
||||
},
|
||||
"draft_attachments_json": {
|
||||
"name": "draft_attachments_json",
|
||||
"error_message": {
|
||||
"name": "error_message",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"transcript_json": {
|
||||
"name": "transcript_json",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'[]'"
|
||||
},
|
||||
"draft_updated_at": {
|
||||
"name": "draft_updated_at",
|
||||
"transcript_updated_at": {
|
||||
"name": "transcript_updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
|
|
|
|||
|
|
@ -10,12 +10,6 @@ const journal = {
|
|||
tag: "0000_charming_maestro",
|
||||
breakpoints: true,
|
||||
},
|
||||
{
|
||||
idx: 1,
|
||||
when: 1773810000000,
|
||||
tag: "0001_sandbox_provider_columns",
|
||||
breakpoints: true,
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
|
||||
|
|
@ -27,10 +21,9 @@ export default {
|
|||
\`branch_name\` text,
|
||||
\`title\` text,
|
||||
\`task\` text NOT NULL,
|
||||
\`provider_id\` text NOT NULL,
|
||||
\`sandbox_provider_id\` text NOT NULL,
|
||||
\`status\` text NOT NULL,
|
||||
\`agent_type\` text DEFAULT 'claude',
|
||||
\`pr_submitted\` integer DEFAULT 0,
|
||||
\`pull_request_json\` text,
|
||||
\`created_at\` integer NOT NULL,
|
||||
\`updated_at\` integer NOT NULL,
|
||||
CONSTRAINT "task_singleton_id_check" CHECK("task"."id" = 1)
|
||||
|
|
@ -39,43 +32,39 @@ export default {
|
|||
CREATE TABLE \`task_runtime\` (
|
||||
\`id\` integer PRIMARY KEY NOT NULL,
|
||||
\`active_sandbox_id\` text,
|
||||
\`active_session_id\` text,
|
||||
\`active_switch_target\` text,
|
||||
\`active_cwd\` text,
|
||||
\`status_message\` text,
|
||||
\`git_state_json\` text,
|
||||
\`git_state_updated_at\` integer,
|
||||
\`updated_at\` integer NOT NULL,
|
||||
CONSTRAINT "task_runtime_singleton_id_check" CHECK("task_runtime"."id" = 1)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE \`task_sandboxes\` (
|
||||
\`sandbox_id\` text PRIMARY KEY NOT NULL,
|
||||
\`provider_id\` text NOT NULL,
|
||||
\`sandbox_provider_id\` text NOT NULL,
|
||||
\`sandbox_actor_id\` text,
|
||||
\`switch_target\` text NOT NULL,
|
||||
\`cwd\` text,
|
||||
\`status_message\` text,
|
||||
\`created_at\` integer NOT NULL,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE \`task_workbench_sessions\` (
|
||||
CREATE TABLE \`task_workspace_sessions\` (
|
||||
\`session_id\` text PRIMARY KEY NOT NULL,
|
||||
\`sandbox_session_id\` text,
|
||||
\`session_name\` text NOT NULL,
|
||||
\`model\` text NOT NULL,
|
||||
\`unread\` integer DEFAULT 0 NOT NULL,
|
||||
\`draft_text\` text DEFAULT '' NOT NULL,
|
||||
\`draft_attachments_json\` text DEFAULT '[]' NOT NULL,
|
||||
\`draft_updated_at\` integer,
|
||||
\`status\` text DEFAULT 'ready' NOT NULL,
|
||||
\`error_message\` text,
|
||||
\`transcript_json\` text DEFAULT '[]' NOT NULL,
|
||||
\`transcript_updated_at\` integer,
|
||||
\`created\` integer DEFAULT 1 NOT NULL,
|
||||
\`closed\` integer DEFAULT 0 NOT NULL,
|
||||
\`thinking_since_ms\` integer,
|
||||
\`created_at\` integer NOT NULL,
|
||||
\`created_at\` integer NOT NULL,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
`,
|
||||
m0001: `ALTER TABLE \`task\` RENAME COLUMN \`provider_id\` TO \`sandbox_provider_id\`;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE \`task_sandboxes\` RENAME COLUMN \`provider_id\` TO \`sandbox_provider_id\`;
|
||||
`,
|
||||
} as const,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -11,8 +11,7 @@ export const task = sqliteTable(
|
|||
task: text("task").notNull(),
|
||||
sandboxProviderId: text("sandbox_provider_id").notNull(),
|
||||
status: text("status").notNull(),
|
||||
agentType: text("agent_type").default("claude"),
|
||||
prSubmitted: integer("pr_submitted").default(0),
|
||||
pullRequestJson: text("pull_request_json"),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
},
|
||||
|
|
@ -24,14 +23,10 @@ export const taskRuntime = sqliteTable(
|
|||
{
|
||||
id: integer("id").primaryKey(),
|
||||
activeSandboxId: text("active_sandbox_id"),
|
||||
activeSessionId: text("active_session_id"),
|
||||
activeSwitchTarget: text("active_switch_target"),
|
||||
activeCwd: text("active_cwd"),
|
||||
statusMessage: text("status_message"),
|
||||
gitStateJson: text("git_state_json"),
|
||||
gitStateUpdatedAt: integer("git_state_updated_at"),
|
||||
provisionStage: text("provision_stage"),
|
||||
provisionStageUpdatedAt: integer("provision_stage_updated_at"),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
},
|
||||
(table) => [check("task_runtime_singleton_id_check", sql`${table.id} = 1`)],
|
||||
|
|
@ -48,18 +43,17 @@ export const taskSandboxes = sqliteTable("task_sandboxes", {
|
|||
sandboxActorId: text("sandbox_actor_id"),
|
||||
switchTarget: text("switch_target").notNull(),
|
||||
cwd: text("cwd"),
|
||||
statusMessage: text("status_message"),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Coordinator index of workbench sessions within this task.
|
||||
* Coordinator index of workspace sessions within this task.
|
||||
* The task actor is the coordinator for sessions. Each row holds session
|
||||
* metadata, model, status, transcript, and draft state. Sessions are
|
||||
* sub-entities of the task — no separate session actor in the DB.
|
||||
*/
|
||||
export const taskWorkbenchSessions = sqliteTable("task_workbench_sessions", {
|
||||
export const taskWorkspaceSessions = sqliteTable("task_workspace_sessions", {
|
||||
sessionId: text("session_id").notNull().primaryKey(),
|
||||
sandboxSessionId: text("sandbox_session_id"),
|
||||
sessionName: text("session_name").notNull(),
|
||||
|
|
@ -68,11 +62,6 @@ export const taskWorkbenchSessions = sqliteTable("task_workbench_sessions", {
|
|||
errorMessage: text("error_message"),
|
||||
transcriptJson: text("transcript_json").notNull().default("[]"),
|
||||
transcriptUpdatedAt: integer("transcript_updated_at"),
|
||||
unread: integer("unread").notNull().default(0),
|
||||
draftText: text("draft_text").notNull().default(""),
|
||||
// Structured by the workbench composer attachment payload format.
|
||||
draftAttachmentsJson: text("draft_attachments_json").notNull().default("[]"),
|
||||
draftUpdatedAt: integer("draft_updated_at"),
|
||||
created: integer("created").notNull().default(1),
|
||||
closed: integer("closed").notNull().default(0),
|
||||
thinkingSinceMs: integer("thinking_since_ms"),
|
||||
|
|
|
|||
|
|
@ -1,393 +1,47 @@
|
|||
import { actor, queue } from "rivetkit";
|
||||
import { workflow } from "rivetkit/workflow";
|
||||
import type {
|
||||
AgentType,
|
||||
TaskRecord,
|
||||
TaskWorkbenchChangeModelInput,
|
||||
TaskWorkbenchRenameInput,
|
||||
TaskWorkbenchRenameSessionInput,
|
||||
TaskWorkbenchSetSessionUnreadInput,
|
||||
TaskWorkbenchSendMessageInput,
|
||||
TaskWorkbenchUpdateDraftInput,
|
||||
SandboxProviderId,
|
||||
} from "@sandbox-agent/foundry-shared";
|
||||
import { expectQueueResponse } from "../../services/queue.js";
|
||||
import { selfTask } from "../handles.js";
|
||||
import { actor } from "rivetkit";
|
||||
import type { TaskRecord } from "@sandbox-agent/foundry-shared";
|
||||
import { taskDb } from "./db/db.js";
|
||||
import { getCurrentRecord } from "./workflow/common.js";
|
||||
import {
|
||||
changeWorkbenchModel,
|
||||
closeWorkbenchSession,
|
||||
createWorkbenchSession,
|
||||
getSessionDetail,
|
||||
getTaskDetail,
|
||||
getTaskSummary,
|
||||
markWorkbenchUnread,
|
||||
publishWorkbenchPr,
|
||||
renameWorkbenchBranch,
|
||||
renameWorkbenchTask,
|
||||
renameWorkbenchSession,
|
||||
revertWorkbenchFile,
|
||||
sendWorkbenchMessage,
|
||||
syncWorkbenchSessionStatus,
|
||||
setWorkbenchSessionUnread,
|
||||
stopWorkbenchSession,
|
||||
updateWorkbenchDraft,
|
||||
} from "./workbench.js";
|
||||
import { TASK_QUEUE_NAMES, taskWorkflowQueueName, runTaskWorkflow } from "./workflow/index.js";
|
||||
import { getSessionDetail, getTaskDetail, getTaskSummary } from "./workspace.js";
|
||||
import { taskCommandActions } from "./workflow/index.js";
|
||||
|
||||
export interface TaskInput {
|
||||
organizationId: string;
|
||||
repoId: string;
|
||||
taskId: string;
|
||||
repoRemote: string;
|
||||
branchName: string | null;
|
||||
title: string | null;
|
||||
task: string;
|
||||
sandboxProviderId: SandboxProviderId;
|
||||
agentType: AgentType | null;
|
||||
explicitTitle: string | null;
|
||||
explicitBranchName: string | null;
|
||||
initialPrompt: string | null;
|
||||
}
|
||||
|
||||
interface InitializeCommand {
|
||||
sandboxProviderId?: SandboxProviderId;
|
||||
}
|
||||
|
||||
interface TaskActionCommand {
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
interface TaskSessionCommand {
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
interface TaskStatusSyncCommand {
|
||||
sessionId: string;
|
||||
status: "running" | "idle" | "error";
|
||||
at: number;
|
||||
}
|
||||
|
||||
interface TaskWorkbenchValueCommand {
|
||||
value: string;
|
||||
}
|
||||
|
||||
interface TaskWorkbenchSessionTitleCommand {
|
||||
sessionId: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface TaskWorkbenchSessionUnreadCommand {
|
||||
sessionId: string;
|
||||
unread: boolean;
|
||||
}
|
||||
|
||||
interface TaskWorkbenchUpdateDraftCommand {
|
||||
sessionId: string;
|
||||
text: string;
|
||||
attachments: Array<any>;
|
||||
}
|
||||
|
||||
interface TaskWorkbenchChangeModelCommand {
|
||||
sessionId: string;
|
||||
model: string;
|
||||
}
|
||||
|
||||
interface TaskWorkbenchSendMessageCommand {
|
||||
sessionId: string;
|
||||
text: string;
|
||||
attachments: Array<any>;
|
||||
}
|
||||
|
||||
interface TaskWorkbenchCreateSessionCommand {
|
||||
model?: string;
|
||||
}
|
||||
|
||||
interface TaskWorkbenchCreateSessionAndSendCommand {
|
||||
model?: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface TaskWorkbenchSessionCommand {
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export const task = actor({
|
||||
db: taskDb,
|
||||
queues: Object.fromEntries(TASK_QUEUE_NAMES.map((name) => [name, queue()])),
|
||||
options: {
|
||||
name: "Task",
|
||||
icon: "wrench",
|
||||
actionTimeout: 5 * 60_000,
|
||||
actionTimeout: 10 * 60_000,
|
||||
},
|
||||
createState: (_c, input: TaskInput) => ({
|
||||
organizationId: input.organizationId,
|
||||
repoId: input.repoId,
|
||||
taskId: input.taskId,
|
||||
repoRemote: input.repoRemote,
|
||||
branchName: input.branchName,
|
||||
title: input.title,
|
||||
task: input.task,
|
||||
sandboxProviderId: input.sandboxProviderId,
|
||||
agentType: input.agentType,
|
||||
explicitTitle: input.explicitTitle,
|
||||
explicitBranchName: input.explicitBranchName,
|
||||
initialPrompt: input.initialPrompt,
|
||||
initialized: false,
|
||||
previousStatus: null as string | null,
|
||||
}),
|
||||
actions: {
|
||||
async initialize(c, cmd: InitializeCommand): Promise<TaskRecord> {
|
||||
const self = selfTask(c);
|
||||
const result = await self.send(taskWorkflowQueueName("task.command.initialize"), cmd ?? {}, {
|
||||
wait: true,
|
||||
timeout: 10_000,
|
||||
});
|
||||
return expectQueueResponse<TaskRecord>(result);
|
||||
},
|
||||
|
||||
async provision(c, cmd: InitializeCommand): Promise<{ ok: true }> {
|
||||
const self = selfTask(c);
|
||||
await self.send(taskWorkflowQueueName("task.command.provision"), cmd ?? {}, {
|
||||
wait: false,
|
||||
});
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
async attach(c, cmd?: TaskActionCommand): Promise<{ target: string; sessionId: string | null }> {
|
||||
const self = selfTask(c);
|
||||
const result = await self.send(taskWorkflowQueueName("task.command.attach"), cmd ?? {}, {
|
||||
wait: true,
|
||||
timeout: 10_000,
|
||||
});
|
||||
return expectQueueResponse<{ target: string; sessionId: string | null }>(result);
|
||||
},
|
||||
|
||||
async switch(c): Promise<{ switchTarget: string }> {
|
||||
const self = selfTask(c);
|
||||
const result = await self.send(
|
||||
taskWorkflowQueueName("task.command.switch"),
|
||||
{},
|
||||
{
|
||||
wait: true,
|
||||
timeout: 10_000,
|
||||
},
|
||||
);
|
||||
return expectQueueResponse<{ switchTarget: string }>(result);
|
||||
},
|
||||
|
||||
async push(c, cmd?: TaskActionCommand): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(taskWorkflowQueueName("task.command.push"), cmd ?? {}, {
|
||||
wait: false,
|
||||
});
|
||||
},
|
||||
|
||||
async sync(c, cmd?: TaskActionCommand): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(taskWorkflowQueueName("task.command.sync"), cmd ?? {}, {
|
||||
wait: false,
|
||||
});
|
||||
},
|
||||
|
||||
async merge(c, cmd?: TaskActionCommand): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(taskWorkflowQueueName("task.command.merge"), cmd ?? {}, {
|
||||
wait: false,
|
||||
});
|
||||
},
|
||||
|
||||
async archive(c, cmd?: TaskActionCommand): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(taskWorkflowQueueName("task.command.archive"), cmd ?? {}, {
|
||||
wait: false,
|
||||
});
|
||||
},
|
||||
|
||||
async kill(c, cmd?: TaskActionCommand): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(taskWorkflowQueueName("task.command.kill"), cmd ?? {}, {
|
||||
wait: false,
|
||||
});
|
||||
},
|
||||
|
||||
async get(c): Promise<TaskRecord> {
|
||||
return await getCurrentRecord({ db: c.db, state: c.state });
|
||||
return await getCurrentRecord(c);
|
||||
},
|
||||
|
||||
async getTaskSummary(c) {
|
||||
return await getTaskSummary(c);
|
||||
},
|
||||
|
||||
async getTaskDetail(c) {
|
||||
return await getTaskDetail(c);
|
||||
async getTaskDetail(c, input?: { authSessionId?: string }) {
|
||||
return await getTaskDetail(c, input?.authSessionId);
|
||||
},
|
||||
|
||||
async getSessionDetail(c, input: { sessionId: string }) {
|
||||
return await getSessionDetail(c, input.sessionId);
|
||||
async getSessionDetail(c, input: { sessionId: string; authSessionId?: string }) {
|
||||
return await getSessionDetail(c, input.sessionId, input.authSessionId);
|
||||
},
|
||||
|
||||
async markWorkbenchUnread(c): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(
|
||||
taskWorkflowQueueName("task.command.workbench.mark_unread"),
|
||||
{},
|
||||
{
|
||||
wait: true,
|
||||
timeout: 10_000,
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async renameWorkbenchTask(c, input: TaskWorkbenchRenameInput): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(taskWorkflowQueueName("task.command.workbench.rename_task"), { value: input.value } satisfies TaskWorkbenchValueCommand, {
|
||||
wait: true,
|
||||
timeout: 20_000,
|
||||
});
|
||||
},
|
||||
|
||||
async renameWorkbenchBranch(c, input: TaskWorkbenchRenameInput): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(taskWorkflowQueueName("task.command.workbench.rename_branch"), { value: input.value } satisfies TaskWorkbenchValueCommand, {
|
||||
wait: false,
|
||||
});
|
||||
},
|
||||
|
||||
async createWorkbenchSession(c, input?: { model?: string }): Promise<{ sessionId: string }> {
|
||||
const self = selfTask(c);
|
||||
const result = await self.send(
|
||||
taskWorkflowQueueName("task.command.workbench.create_session"),
|
||||
{ ...(input?.model ? { model: input.model } : {}) } satisfies TaskWorkbenchCreateSessionCommand,
|
||||
{
|
||||
wait: true,
|
||||
timeout: 10_000,
|
||||
},
|
||||
);
|
||||
return expectQueueResponse<{ sessionId: string }>(result);
|
||||
},
|
||||
|
||||
/**
|
||||
* Fire-and-forget: creates a workbench session and sends the initial message.
|
||||
* Used by createWorkbenchTask so the caller doesn't block on session creation.
|
||||
*/
|
||||
async createWorkbenchSessionAndSend(c, input: { model?: string; text: string }): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(
|
||||
taskWorkflowQueueName("task.command.workbench.create_session_and_send"),
|
||||
{ model: input.model, text: input.text } satisfies TaskWorkbenchCreateSessionAndSendCommand,
|
||||
{ wait: false },
|
||||
);
|
||||
},
|
||||
|
||||
async renameWorkbenchSession(c, input: TaskWorkbenchRenameSessionInput): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(
|
||||
taskWorkflowQueueName("task.command.workbench.rename_session"),
|
||||
{ sessionId: input.sessionId, title: input.title } satisfies TaskWorkbenchSessionTitleCommand,
|
||||
{
|
||||
wait: true,
|
||||
timeout: 10_000,
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async setWorkbenchSessionUnread(c, input: TaskWorkbenchSetSessionUnreadInput): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(
|
||||
taskWorkflowQueueName("task.command.workbench.set_session_unread"),
|
||||
{ sessionId: input.sessionId, unread: input.unread } satisfies TaskWorkbenchSessionUnreadCommand,
|
||||
{
|
||||
wait: true,
|
||||
timeout: 10_000,
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async updateWorkbenchDraft(c, input: TaskWorkbenchUpdateDraftInput): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(
|
||||
taskWorkflowQueueName("task.command.workbench.update_draft"),
|
||||
{
|
||||
sessionId: input.sessionId,
|
||||
text: input.text,
|
||||
attachments: input.attachments,
|
||||
} satisfies TaskWorkbenchUpdateDraftCommand,
|
||||
{
|
||||
wait: false,
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async changeWorkbenchModel(c, input: TaskWorkbenchChangeModelInput): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(
|
||||
taskWorkflowQueueName("task.command.workbench.change_model"),
|
||||
{ sessionId: input.sessionId, model: input.model } satisfies TaskWorkbenchChangeModelCommand,
|
||||
{
|
||||
wait: true,
|
||||
timeout: 10_000,
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async sendWorkbenchMessage(c, input: TaskWorkbenchSendMessageInput): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(
|
||||
taskWorkflowQueueName("task.command.workbench.send_message"),
|
||||
{
|
||||
sessionId: input.sessionId,
|
||||
text: input.text,
|
||||
attachments: input.attachments,
|
||||
} satisfies TaskWorkbenchSendMessageCommand,
|
||||
{
|
||||
wait: false,
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async stopWorkbenchSession(c, input: TaskSessionCommand): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(taskWorkflowQueueName("task.command.workbench.stop_session"), { sessionId: input.sessionId } satisfies TaskWorkbenchSessionCommand, {
|
||||
wait: false,
|
||||
});
|
||||
},
|
||||
|
||||
async syncWorkbenchSessionStatus(c, input: TaskStatusSyncCommand): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(taskWorkflowQueueName("task.command.workbench.sync_session_status"), input, {
|
||||
wait: true,
|
||||
timeout: 20_000,
|
||||
});
|
||||
},
|
||||
|
||||
async closeWorkbenchSession(c, input: TaskSessionCommand): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(taskWorkflowQueueName("task.command.workbench.close_session"), { sessionId: input.sessionId } satisfies TaskWorkbenchSessionCommand, {
|
||||
wait: false,
|
||||
});
|
||||
},
|
||||
|
||||
async publishWorkbenchPr(c): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(
|
||||
taskWorkflowQueueName("task.command.workbench.publish_pr"),
|
||||
{},
|
||||
{
|
||||
wait: false,
|
||||
},
|
||||
);
|
||||
},
|
||||
|
||||
async revertWorkbenchFile(c, input: { path: string }): Promise<void> {
|
||||
const self = selfTask(c);
|
||||
await self.send(taskWorkflowQueueName("task.command.workbench.revert_file"), input, {
|
||||
wait: false,
|
||||
});
|
||||
},
|
||||
...taskCommandActions,
|
||||
},
|
||||
run: workflow(runTaskWorkflow),
|
||||
});
|
||||
|
||||
export { TASK_QUEUE_NAMES };
|
||||
export { taskWorkflowQueueName } from "./workflow/index.js";
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@
|
|||
import { eq } from "drizzle-orm";
|
||||
import { getTaskSandbox } from "../../handles.js";
|
||||
import { logActorWarning, resolveErrorMessage } from "../../logging.js";
|
||||
import { task as taskTable, taskRuntime } from "../db/schema.js";
|
||||
import { TASK_ROW_ID, appendHistory, getCurrentRecord, setTaskState } from "./common.js";
|
||||
import { task as taskTable } from "../db/schema.js";
|
||||
import { TASK_ROW_ID, appendAuditLog, getCurrentRecord, setTaskState } from "./common.js";
|
||||
import { pushActiveBranchActivity } from "./push.js";
|
||||
|
||||
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
|
||||
|
|
@ -25,6 +25,7 @@ async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: str
|
|||
export async function handleAttachActivity(loopCtx: any, msg: any): Promise<void> {
|
||||
const record = await getCurrentRecord(loopCtx);
|
||||
let target = record.sandboxes.find((sandbox: any) => sandbox.sandboxId === record.activeSandboxId)?.switchTarget ?? "";
|
||||
const sessionId = msg.body?.sessionId ?? null;
|
||||
|
||||
if (record.activeSandboxId) {
|
||||
try {
|
||||
|
|
@ -38,14 +39,14 @@ export async function handleAttachActivity(loopCtx: any, msg: any): Promise<void
|
|||
}
|
||||
}
|
||||
|
||||
await appendHistory(loopCtx, "task.attach", {
|
||||
await appendAuditLog(loopCtx, "task.attach", {
|
||||
target,
|
||||
sessionId: record.activeSessionId,
|
||||
sessionId,
|
||||
});
|
||||
|
||||
await msg.complete({
|
||||
target,
|
||||
sessionId: record.activeSessionId,
|
||||
sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -64,20 +65,17 @@ export async function handlePushActivity(loopCtx: any, msg: any): Promise<void>
|
|||
await msg.complete({ ok: true });
|
||||
}
|
||||
|
||||
export async function handleSimpleCommandActivity(loopCtx: any, msg: any, statusMessage: string, historyKind: string): Promise<void> {
|
||||
const db = loopCtx.db;
|
||||
await db.update(taskRuntime).set({ statusMessage, updatedAt: Date.now() }).where(eq(taskRuntime.id, TASK_ROW_ID)).run();
|
||||
|
||||
await appendHistory(loopCtx, historyKind, { reason: msg.body?.reason ?? null });
|
||||
export async function handleSimpleCommandActivity(loopCtx: any, msg: any, historyKind: string): Promise<void> {
|
||||
await appendAuditLog(loopCtx, historyKind, { reason: msg.body?.reason ?? null });
|
||||
await msg.complete({ ok: true });
|
||||
}
|
||||
|
||||
export async function handleArchiveActivity(loopCtx: any, msg: any): Promise<void> {
|
||||
await setTaskState(loopCtx, "archive_stop_status_sync", "stopping status sync");
|
||||
await setTaskState(loopCtx, "archive_stop_status_sync");
|
||||
const record = await getCurrentRecord(loopCtx);
|
||||
|
||||
if (record.activeSandboxId) {
|
||||
await setTaskState(loopCtx, "archive_release_sandbox", "releasing sandbox");
|
||||
await setTaskState(loopCtx, "archive_release_sandbox");
|
||||
void withTimeout(getTaskSandbox(loopCtx, loopCtx.state.organizationId, record.activeSandboxId).destroy(), 45_000, "sandbox destroy").catch((error) => {
|
||||
logActorWarning("task.commands", "failed to release sandbox during archive", {
|
||||
organizationId: loopCtx.state.organizationId,
|
||||
|
|
@ -90,17 +88,15 @@ export async function handleArchiveActivity(loopCtx: any, msg: any): Promise<voi
|
|||
}
|
||||
|
||||
const db = loopCtx.db;
|
||||
await setTaskState(loopCtx, "archive_finalize", "finalizing archive");
|
||||
await setTaskState(loopCtx, "archive_finalize");
|
||||
await db.update(taskTable).set({ status: "archived", updatedAt: Date.now() }).where(eq(taskTable.id, TASK_ROW_ID)).run();
|
||||
|
||||
await db.update(taskRuntime).set({ activeSessionId: null, statusMessage: "archived", updatedAt: Date.now() }).where(eq(taskRuntime.id, TASK_ROW_ID)).run();
|
||||
|
||||
await appendHistory(loopCtx, "task.archive", { reason: msg.body?.reason ?? null });
|
||||
await appendAuditLog(loopCtx, "task.archive", { reason: msg.body?.reason ?? null });
|
||||
await msg.complete({ ok: true });
|
||||
}
|
||||
|
||||
export async function killDestroySandboxActivity(loopCtx: any): Promise<void> {
|
||||
await setTaskState(loopCtx, "kill_destroy_sandbox", "destroying sandbox");
|
||||
await setTaskState(loopCtx, "kill_destroy_sandbox");
|
||||
const record = await getCurrentRecord(loopCtx);
|
||||
if (!record.activeSandboxId) {
|
||||
return;
|
||||
|
|
@ -110,13 +106,11 @@ export async function killDestroySandboxActivity(loopCtx: any): Promise<void> {
|
|||
}
|
||||
|
||||
export async function killWriteDbActivity(loopCtx: any, msg: any): Promise<void> {
|
||||
await setTaskState(loopCtx, "kill_finalize", "finalizing kill");
|
||||
await setTaskState(loopCtx, "kill_finalize");
|
||||
const db = loopCtx.db;
|
||||
await db.update(taskTable).set({ status: "killed", updatedAt: Date.now() }).where(eq(taskTable.id, TASK_ROW_ID)).run();
|
||||
|
||||
await db.update(taskRuntime).set({ statusMessage: "killed", updatedAt: Date.now() }).where(eq(taskRuntime.id, TASK_ROW_ID)).run();
|
||||
|
||||
await appendHistory(loopCtx, "task.kill", { reason: msg.body?.reason ?? null });
|
||||
await appendAuditLog(loopCtx, "task.kill", { reason: msg.body?.reason ?? null });
|
||||
await msg.complete({ ok: true });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,10 @@
|
|||
import { eq } from "drizzle-orm";
|
||||
import type { TaskRecord, TaskStatus } from "@sandbox-agent/foundry-shared";
|
||||
import { task as taskTable, taskRuntime, taskSandboxes } from "../db/schema.js";
|
||||
import { historyKey } from "../../keys.js";
|
||||
import { broadcastTaskUpdate } from "../workbench.js";
|
||||
import { getOrCreateAuditLog, getOrCreateOrganization } from "../../handles.js";
|
||||
import { broadcastTaskUpdate } from "../workspace.js";
|
||||
import { getActorRuntimeContext } from "../../context.js";
|
||||
import { defaultSandboxProviderId } from "../../../sandbox-config.js";
|
||||
|
||||
export const TASK_ROW_ID = 1;
|
||||
|
||||
|
|
@ -56,50 +58,32 @@ export function buildAgentPrompt(task: string): string {
|
|||
return task.trim();
|
||||
}
|
||||
|
||||
export async function setTaskState(ctx: any, status: TaskStatus, statusMessage?: string): Promise<void> {
|
||||
export async function setTaskState(ctx: any, status: TaskStatus): Promise<void> {
|
||||
const now = Date.now();
|
||||
const db = ctx.db;
|
||||
await db.update(taskTable).set({ status, updatedAt: now }).where(eq(taskTable.id, TASK_ROW_ID)).run();
|
||||
|
||||
if (statusMessage != null) {
|
||||
await db
|
||||
.insert(taskRuntime)
|
||||
.values({
|
||||
id: TASK_ROW_ID,
|
||||
activeSandboxId: null,
|
||||
activeSessionId: null,
|
||||
activeSwitchTarget: null,
|
||||
activeCwd: null,
|
||||
statusMessage,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: taskRuntime.id,
|
||||
set: {
|
||||
statusMessage,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
await broadcastTaskUpdate(ctx);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the task's current record from its local SQLite DB.
|
||||
* If the task actor was lazily created (virtual task from PR sync) and has no
|
||||
* DB rows yet, auto-initializes by reading branch/title from the org actor's
|
||||
* getTaskIndexEntry. This is the self-initialization path for lazy task actors.
|
||||
*/
|
||||
export async function getCurrentRecord(ctx: any): Promise<TaskRecord> {
|
||||
const db = ctx.db;
|
||||
const row = await db
|
||||
const organization = await getOrCreateOrganization(ctx, ctx.state.organizationId);
|
||||
let row = await db
|
||||
.select({
|
||||
branchName: taskTable.branchName,
|
||||
title: taskTable.title,
|
||||
task: taskTable.task,
|
||||
sandboxProviderId: taskTable.sandboxProviderId,
|
||||
status: taskTable.status,
|
||||
statusMessage: taskRuntime.statusMessage,
|
||||
pullRequestJson: taskTable.pullRequestJson,
|
||||
activeSandboxId: taskRuntime.activeSandboxId,
|
||||
activeSessionId: taskRuntime.activeSessionId,
|
||||
agentType: taskTable.agentType,
|
||||
prSubmitted: taskTable.prSubmitted,
|
||||
createdAt: taskTable.createdAt,
|
||||
updatedAt: taskTable.updatedAt,
|
||||
})
|
||||
|
|
@ -109,7 +93,58 @@ export async function getCurrentRecord(ctx: any): Promise<TaskRecord> {
|
|||
.get();
|
||||
|
||||
if (!row) {
|
||||
throw new Error(`Task not found: ${ctx.state.taskId}`);
|
||||
// Virtual task — auto-initialize from org actor's task index data
|
||||
let branchName: string | null = null;
|
||||
let title = "Untitled";
|
||||
try {
|
||||
const entry = await organization.getTaskIndexEntry({ taskId: ctx.state.taskId });
|
||||
branchName = entry?.branchName ?? null;
|
||||
title = entry?.title ?? title;
|
||||
} catch {}
|
||||
|
||||
const { config } = getActorRuntimeContext();
|
||||
const { initBootstrapDbActivity, initCompleteActivity } = await import("./init.js");
|
||||
await initBootstrapDbActivity(ctx, {
|
||||
sandboxProviderId: defaultSandboxProviderId(config),
|
||||
branchName,
|
||||
title,
|
||||
task: title,
|
||||
});
|
||||
await initCompleteActivity(ctx, { sandboxProviderId: defaultSandboxProviderId(config) });
|
||||
|
||||
// Re-read the row after initialization
|
||||
const initialized = await db
|
||||
.select({
|
||||
branchName: taskTable.branchName,
|
||||
title: taskTable.title,
|
||||
task: taskTable.task,
|
||||
sandboxProviderId: taskTable.sandboxProviderId,
|
||||
status: taskTable.status,
|
||||
pullRequestJson: taskTable.pullRequestJson,
|
||||
activeSandboxId: taskRuntime.activeSandboxId,
|
||||
createdAt: taskTable.createdAt,
|
||||
updatedAt: taskTable.updatedAt,
|
||||
})
|
||||
.from(taskTable)
|
||||
.leftJoin(taskRuntime, eq(taskTable.id, taskRuntime.id))
|
||||
.where(eq(taskTable.id, TASK_ROW_ID))
|
||||
.get();
|
||||
|
||||
if (!initialized) {
|
||||
throw new Error(`Task not found after initialization: ${ctx.state.taskId}`);
|
||||
}
|
||||
|
||||
row = initialized;
|
||||
}
|
||||
|
||||
const repositoryMetadata = await organization.getRepositoryMetadata({ repoId: ctx.state.repoId });
|
||||
let pullRequest = null;
|
||||
if (row.pullRequestJson) {
|
||||
try {
|
||||
pullRequest = JSON.parse(row.pullRequestJson);
|
||||
} catch {
|
||||
pullRequest = null;
|
||||
}
|
||||
}
|
||||
|
||||
const sandboxes = await db
|
||||
|
|
@ -128,16 +163,15 @@ export async function getCurrentRecord(ctx: any): Promise<TaskRecord> {
|
|||
return {
|
||||
organizationId: ctx.state.organizationId,
|
||||
repoId: ctx.state.repoId,
|
||||
repoRemote: ctx.state.repoRemote,
|
||||
repoRemote: repositoryMetadata.remoteUrl,
|
||||
taskId: ctx.state.taskId,
|
||||
branchName: row.branchName,
|
||||
title: row.title,
|
||||
task: row.task,
|
||||
sandboxProviderId: row.sandboxProviderId,
|
||||
status: row.status,
|
||||
statusMessage: row.statusMessage ?? null,
|
||||
activeSandboxId: row.activeSandboxId ?? null,
|
||||
activeSessionId: row.activeSessionId ?? null,
|
||||
pullRequest,
|
||||
sandboxes: sandboxes.map((sb) => ({
|
||||
sandboxId: sb.sandboxId,
|
||||
sandboxProviderId: sb.sandboxProviderId,
|
||||
|
|
@ -147,31 +181,19 @@ export async function getCurrentRecord(ctx: any): Promise<TaskRecord> {
|
|||
createdAt: sb.createdAt,
|
||||
updatedAt: sb.updatedAt,
|
||||
})),
|
||||
agentType: row.agentType ?? null,
|
||||
prSubmitted: Boolean(row.prSubmitted),
|
||||
diffStat: null,
|
||||
hasUnpushed: null,
|
||||
conflictsWithMain: null,
|
||||
parentBranch: null,
|
||||
prUrl: null,
|
||||
prAuthor: null,
|
||||
ciStatus: null,
|
||||
reviewStatus: null,
|
||||
reviewer: null,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
} as TaskRecord;
|
||||
}
|
||||
|
||||
export async function appendHistory(ctx: any, kind: string, payload: Record<string, unknown>): Promise<void> {
|
||||
const client = ctx.client();
|
||||
const history = await client.history.getOrCreate(historyKey(ctx.state.organizationId, ctx.state.repoId), {
|
||||
createWithInput: { organizationId: ctx.state.organizationId, repoId: ctx.state.repoId },
|
||||
});
|
||||
await history.append({
|
||||
export async function appendAuditLog(ctx: any, kind: string, payload: Record<string, unknown>): Promise<void> {
|
||||
const row = await ctx.db.select({ branchName: taskTable.branchName }).from(taskTable).where(eq(taskTable.id, TASK_ROW_ID)).get();
|
||||
const auditLog = await getOrCreateAuditLog(ctx, ctx.state.organizationId);
|
||||
void auditLog.append({
|
||||
kind,
|
||||
repoId: ctx.state.repoId,
|
||||
taskId: ctx.state.taskId,
|
||||
branchName: ctx.state.branchName,
|
||||
branchName: row?.branchName ?? null,
|
||||
payload,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { Loop } from "rivetkit/workflow";
|
||||
import { logActorWarning, resolveErrorMessage } from "../../logging.js";
|
||||
import { getCurrentRecord } from "./common.js";
|
||||
import { initBootstrapDbActivity, initCompleteActivity, initEnqueueProvisionActivity, initFailedActivity } from "./init.js";
|
||||
|
|
@ -12,283 +11,254 @@ import {
|
|||
killDestroySandboxActivity,
|
||||
killWriteDbActivity,
|
||||
} from "./commands.js";
|
||||
import { TASK_QUEUE_NAMES } from "./queue.js";
|
||||
import {
|
||||
changeWorkbenchModel,
|
||||
closeWorkbenchSession,
|
||||
createWorkbenchSession,
|
||||
ensureWorkbenchSession,
|
||||
refreshWorkbenchDerivedState,
|
||||
refreshWorkbenchSessionTranscript,
|
||||
markWorkbenchUnread,
|
||||
publishWorkbenchPr,
|
||||
renameWorkbenchBranch,
|
||||
renameWorkbenchTask,
|
||||
renameWorkbenchSession,
|
||||
revertWorkbenchFile,
|
||||
sendWorkbenchMessage,
|
||||
setWorkbenchSessionUnread,
|
||||
stopWorkbenchSession,
|
||||
syncWorkbenchSessionStatus,
|
||||
updateWorkbenchDraft,
|
||||
} from "../workbench.js";
|
||||
changeWorkspaceModel,
|
||||
closeWorkspaceSession,
|
||||
createWorkspaceSession,
|
||||
ensureWorkspaceSession,
|
||||
refreshWorkspaceDerivedState,
|
||||
refreshWorkspaceSessionTranscript,
|
||||
markWorkspaceUnread,
|
||||
publishWorkspacePr,
|
||||
renameWorkspaceTask,
|
||||
renameWorkspaceSession,
|
||||
selectWorkspaceSession,
|
||||
revertWorkspaceFile,
|
||||
sendWorkspaceMessage,
|
||||
setWorkspaceSessionUnread,
|
||||
stopWorkspaceSession,
|
||||
syncTaskPullRequest,
|
||||
syncWorkspaceSessionStatus,
|
||||
updateWorkspaceDraft,
|
||||
} from "../workspace.js";
|
||||
|
||||
export { TASK_QUEUE_NAMES, taskWorkflowQueueName } from "./queue.js";
|
||||
export { taskWorkflowQueueName } from "./queue.js";
|
||||
|
||||
type TaskQueueName = (typeof TASK_QUEUE_NAMES)[number];
|
||||
/**
|
||||
* Task command actions — converted from queue/workflow handlers to direct actions.
|
||||
* Each export becomes an action on the task actor.
|
||||
*/
|
||||
export const taskCommandActions = {
|
||||
async initialize(c: any, body: any) {
|
||||
await initBootstrapDbActivity(c, body);
|
||||
await initEnqueueProvisionActivity(c, body);
|
||||
return await getCurrentRecord(c);
|
||||
},
|
||||
|
||||
type WorkflowHandler = (loopCtx: any, msg: { name: TaskQueueName; body: any; complete: (response: unknown) => Promise<void> }) => Promise<void>;
|
||||
|
||||
const commandHandlers: Record<TaskQueueName, WorkflowHandler> = {
|
||||
"task.command.initialize": async (loopCtx, msg) => {
|
||||
const body = msg.body;
|
||||
|
||||
await loopCtx.step("init-bootstrap-db", async () => initBootstrapDbActivity(loopCtx, body));
|
||||
await loopCtx.step("init-enqueue-provision", async () => initEnqueueProvisionActivity(loopCtx, body));
|
||||
await loopCtx.removed("init-dispatch-provision-v2", "step");
|
||||
const currentRecord = await loopCtx.step("init-read-current-record", async () => getCurrentRecord(loopCtx));
|
||||
async provision(c: any, body: any) {
|
||||
try {
|
||||
await msg.complete(currentRecord);
|
||||
await initCompleteActivity(c, body);
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
logActorWarning("task.workflow", "initialize completion failed", {
|
||||
error: resolveErrorMessage(error),
|
||||
});
|
||||
await initFailedActivity(c, error, body);
|
||||
return { ok: false, error: resolveErrorMessage(error) };
|
||||
}
|
||||
},
|
||||
|
||||
"task.command.provision": async (loopCtx, msg) => {
|
||||
await loopCtx.removed("init-failed", "step");
|
||||
await loopCtx.removed("init-failed-v2", "step");
|
||||
async attach(c: any, body: any) {
|
||||
// handleAttachActivity expects msg with complete — adapt
|
||||
const result = { value: undefined as any };
|
||||
const msg = {
|
||||
name: "task.command.attach",
|
||||
body,
|
||||
complete: async (v: any) => {
|
||||
result.value = v;
|
||||
},
|
||||
};
|
||||
await handleAttachActivity(c, msg);
|
||||
return result.value;
|
||||
},
|
||||
|
||||
async switchTask(c: any, body: any) {
|
||||
const result = { value: undefined as any };
|
||||
const msg = {
|
||||
name: "task.command.switch",
|
||||
body,
|
||||
complete: async (v: any) => {
|
||||
result.value = v;
|
||||
},
|
||||
};
|
||||
await handleSwitchActivity(c, msg);
|
||||
return result.value;
|
||||
},
|
||||
|
||||
async push(c: any, body: any) {
|
||||
const result = { value: undefined as any };
|
||||
const msg = {
|
||||
name: "task.command.push",
|
||||
body,
|
||||
complete: async (v: any) => {
|
||||
result.value = v;
|
||||
},
|
||||
};
|
||||
await handlePushActivity(c, msg);
|
||||
return result.value;
|
||||
},
|
||||
|
||||
async sync(c: any, body: any) {
|
||||
const result = { value: undefined as any };
|
||||
const msg = {
|
||||
name: "task.command.sync",
|
||||
body,
|
||||
complete: async (v: any) => {
|
||||
result.value = v;
|
||||
},
|
||||
};
|
||||
await handleSimpleCommandActivity(c, msg, "task.sync");
|
||||
return result.value;
|
||||
},
|
||||
|
||||
async merge(c: any, body: any) {
|
||||
const result = { value: undefined as any };
|
||||
const msg = {
|
||||
name: "task.command.merge",
|
||||
body,
|
||||
complete: async (v: any) => {
|
||||
result.value = v;
|
||||
},
|
||||
};
|
||||
await handleSimpleCommandActivity(c, msg, "task.merge");
|
||||
return result.value;
|
||||
},
|
||||
|
||||
async archive(c: any, body: any) {
|
||||
const result = { value: undefined as any };
|
||||
const msg = {
|
||||
name: "task.command.archive",
|
||||
body,
|
||||
complete: async (v: any) => {
|
||||
result.value = v;
|
||||
},
|
||||
};
|
||||
await handleArchiveActivity(c, msg);
|
||||
return result.value;
|
||||
},
|
||||
|
||||
async kill(c: any, body: any) {
|
||||
const result = { value: undefined as any };
|
||||
const msg = {
|
||||
name: "task.command.kill",
|
||||
body,
|
||||
complete: async (v: any) => {
|
||||
result.value = v;
|
||||
},
|
||||
};
|
||||
await killDestroySandboxActivity(c);
|
||||
await killWriteDbActivity(c, msg);
|
||||
return result.value;
|
||||
},
|
||||
|
||||
async getRecord(c: any, body: any) {
|
||||
const result = { value: undefined as any };
|
||||
const msg = {
|
||||
name: "task.command.get",
|
||||
body,
|
||||
complete: async (v: any) => {
|
||||
result.value = v;
|
||||
},
|
||||
};
|
||||
await handleGetActivity(c, msg);
|
||||
return result.value;
|
||||
},
|
||||
|
||||
async pullRequestSync(c: any, body: any) {
|
||||
await syncTaskPullRequest(c, body?.pullRequest ?? null);
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
async markUnread(c: any, body: any) {
|
||||
await markWorkspaceUnread(c, body?.authSessionId);
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
async renameTask(c: any, body: any) {
|
||||
await renameWorkspaceTask(c, body.value);
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
async createSession(c: any, body: any) {
|
||||
return await createWorkspaceSession(c, body?.model, body?.authSessionId);
|
||||
},
|
||||
|
||||
async createSessionAndSend(c: any, body: any) {
|
||||
try {
|
||||
await loopCtx.removed("init-ensure-name", "step");
|
||||
await loopCtx.removed("init-assert-name", "step");
|
||||
await loopCtx.removed("init-create-sandbox", "step");
|
||||
await loopCtx.removed("init-ensure-agent", "step");
|
||||
await loopCtx.removed("init-start-sandbox-instance", "step");
|
||||
await loopCtx.removed("init-expose-sandbox", "step");
|
||||
await loopCtx.removed("init-create-session", "step");
|
||||
await loopCtx.removed("init-write-db", "step");
|
||||
await loopCtx.removed("init-start-status-sync", "step");
|
||||
await loopCtx.step("init-complete", async () => initCompleteActivity(loopCtx, msg.body));
|
||||
await msg.complete({ ok: true });
|
||||
} catch (error) {
|
||||
await loopCtx.step("init-failed-v3", async () => initFailedActivity(loopCtx, error));
|
||||
await msg.complete({
|
||||
ok: false,
|
||||
error: resolveErrorMessage(error),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
"task.command.attach": async (loopCtx, msg) => {
|
||||
await loopCtx.step("handle-attach", async () => handleAttachActivity(loopCtx, msg));
|
||||
},
|
||||
|
||||
"task.command.switch": async (loopCtx, msg) => {
|
||||
await loopCtx.step("handle-switch", async () => handleSwitchActivity(loopCtx, msg));
|
||||
},
|
||||
|
||||
"task.command.push": async (loopCtx, msg) => {
|
||||
await loopCtx.step("handle-push", async () => handlePushActivity(loopCtx, msg));
|
||||
},
|
||||
|
||||
"task.command.sync": async (loopCtx, msg) => {
|
||||
await loopCtx.step("handle-sync", async () => handleSimpleCommandActivity(loopCtx, msg, "sync requested", "task.sync"));
|
||||
},
|
||||
|
||||
"task.command.merge": async (loopCtx, msg) => {
|
||||
await loopCtx.step("handle-merge", async () => handleSimpleCommandActivity(loopCtx, msg, "merge requested", "task.merge"));
|
||||
},
|
||||
|
||||
"task.command.archive": async (loopCtx, msg) => {
|
||||
await loopCtx.step("handle-archive", async () => handleArchiveActivity(loopCtx, msg));
|
||||
},
|
||||
|
||||
"task.command.kill": async (loopCtx, msg) => {
|
||||
await loopCtx.step("kill-destroy-sandbox", async () => killDestroySandboxActivity(loopCtx));
|
||||
await loopCtx.step("kill-write-db", async () => killWriteDbActivity(loopCtx, msg));
|
||||
},
|
||||
|
||||
"task.command.get": async (loopCtx, msg) => {
|
||||
await loopCtx.step("handle-get", async () => handleGetActivity(loopCtx, msg));
|
||||
},
|
||||
|
||||
"task.command.workbench.mark_unread": async (loopCtx, msg) => {
|
||||
await loopCtx.step("workbench-mark-unread", async () => markWorkbenchUnread(loopCtx));
|
||||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
"task.command.workbench.rename_task": async (loopCtx, msg) => {
|
||||
await loopCtx.step("workbench-rename-task", async () => renameWorkbenchTask(loopCtx, msg.body.value));
|
||||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
"task.command.workbench.rename_branch": async (loopCtx, msg) => {
|
||||
await loopCtx.step({
|
||||
name: "workbench-rename-branch",
|
||||
timeout: 5 * 60_000,
|
||||
run: async () => renameWorkbenchBranch(loopCtx, msg.body.value),
|
||||
});
|
||||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
"task.command.workbench.create_session": async (loopCtx, msg) => {
|
||||
try {
|
||||
const created = await loopCtx.step({
|
||||
name: "workbench-create-session",
|
||||
timeout: 5 * 60_000,
|
||||
run: async () => createWorkbenchSession(loopCtx, msg.body?.model),
|
||||
});
|
||||
await msg.complete(created);
|
||||
} catch (error) {
|
||||
await msg.complete({ error: resolveErrorMessage(error) });
|
||||
}
|
||||
},
|
||||
|
||||
"task.command.workbench.create_session_and_send": async (loopCtx, msg) => {
|
||||
try {
|
||||
const created = await loopCtx.step({
|
||||
name: "workbench-create-session-for-send",
|
||||
timeout: 5 * 60_000,
|
||||
run: async () => createWorkbenchSession(loopCtx, msg.body?.model),
|
||||
});
|
||||
await loopCtx.step({
|
||||
name: "workbench-send-initial-message",
|
||||
timeout: 5 * 60_000,
|
||||
run: async () => sendWorkbenchMessage(loopCtx, created.sessionId, msg.body.text, []),
|
||||
});
|
||||
const created = await createWorkspaceSession(c, body?.model, body?.authSessionId);
|
||||
await sendWorkspaceMessage(c, created.sessionId, body.text, [], body?.authSessionId);
|
||||
} catch (error) {
|
||||
logActorWarning("task.workflow", "create_session_and_send failed", {
|
||||
error: resolveErrorMessage(error),
|
||||
});
|
||||
}
|
||||
await msg.complete({ ok: true });
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
"task.command.workbench.ensure_session": async (loopCtx, msg) => {
|
||||
await loopCtx.step({
|
||||
name: "workbench-ensure-session",
|
||||
timeout: 5 * 60_000,
|
||||
run: async () => ensureWorkbenchSession(loopCtx, msg.body.sessionId, msg.body?.model),
|
||||
});
|
||||
await msg.complete({ ok: true });
|
||||
async ensureSession(c: any, body: any) {
|
||||
await ensureWorkspaceSession(c, body.sessionId, body?.model, body?.authSessionId);
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
"task.command.workbench.rename_session": async (loopCtx, msg) => {
|
||||
await loopCtx.step("workbench-rename-session", async () => renameWorkbenchSession(loopCtx, msg.body.sessionId, msg.body.title));
|
||||
await msg.complete({ ok: true });
|
||||
async renameSession(c: any, body: any) {
|
||||
await renameWorkspaceSession(c, body.sessionId, body.title);
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
"task.command.workbench.set_session_unread": async (loopCtx, msg) => {
|
||||
await loopCtx.step("workbench-set-session-unread", async () => setWorkbenchSessionUnread(loopCtx, msg.body.sessionId, msg.body.unread));
|
||||
await msg.complete({ ok: true });
|
||||
async selectSession(c: any, body: any) {
|
||||
await selectWorkspaceSession(c, body.sessionId, body?.authSessionId);
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
"task.command.workbench.update_draft": async (loopCtx, msg) => {
|
||||
await loopCtx.step("workbench-update-draft", async () => updateWorkbenchDraft(loopCtx, msg.body.sessionId, msg.body.text, msg.body.attachments));
|
||||
await msg.complete({ ok: true });
|
||||
async setSessionUnread(c: any, body: any) {
|
||||
await setWorkspaceSessionUnread(c, body.sessionId, body.unread, body?.authSessionId);
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
"task.command.workbench.change_model": async (loopCtx, msg) => {
|
||||
await loopCtx.step("workbench-change-model", async () => changeWorkbenchModel(loopCtx, msg.body.sessionId, msg.body.model));
|
||||
await msg.complete({ ok: true });
|
||||
async updateDraft(c: any, body: any) {
|
||||
await updateWorkspaceDraft(c, body.sessionId, body.text, body.attachments, body?.authSessionId);
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
"task.command.workbench.send_message": async (loopCtx, msg) => {
|
||||
try {
|
||||
await loopCtx.step({
|
||||
name: "workbench-send-message",
|
||||
timeout: 10 * 60_000,
|
||||
run: async () => sendWorkbenchMessage(loopCtx, msg.body.sessionId, msg.body.text, msg.body.attachments),
|
||||
});
|
||||
await msg.complete({ ok: true });
|
||||
} catch (error) {
|
||||
await msg.complete({ error: resolveErrorMessage(error) });
|
||||
}
|
||||
async changeModel(c: any, body: any) {
|
||||
await changeWorkspaceModel(c, body.sessionId, body.model, body?.authSessionId);
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
"task.command.workbench.stop_session": async (loopCtx, msg) => {
|
||||
await loopCtx.step({
|
||||
name: "workbench-stop-session",
|
||||
timeout: 5 * 60_000,
|
||||
run: async () => stopWorkbenchSession(loopCtx, msg.body.sessionId),
|
||||
});
|
||||
await msg.complete({ ok: true });
|
||||
async sendMessage(c: any, body: any) {
|
||||
await sendWorkspaceMessage(c, body.sessionId, body.text, body.attachments, body?.authSessionId);
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
"task.command.workbench.sync_session_status": async (loopCtx, msg) => {
|
||||
await loopCtx.step("workbench-sync-session-status", async () => syncWorkbenchSessionStatus(loopCtx, msg.body.sessionId, msg.body.status, msg.body.at));
|
||||
await msg.complete({ ok: true });
|
||||
async stopSession(c: any, body: any) {
|
||||
await stopWorkspaceSession(c, body.sessionId);
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
"task.command.workbench.refresh_derived": async (loopCtx, msg) => {
|
||||
await loopCtx.step({
|
||||
name: "workbench-refresh-derived",
|
||||
timeout: 5 * 60_000,
|
||||
run: async () => refreshWorkbenchDerivedState(loopCtx),
|
||||
});
|
||||
await msg.complete({ ok: true });
|
||||
async syncSessionStatus(c: any, body: any) {
|
||||
await syncWorkspaceSessionStatus(c, body.sessionId, body.status, body.at);
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
"task.command.workbench.refresh_session_transcript": async (loopCtx, msg) => {
|
||||
await loopCtx.step({
|
||||
name: "workbench-refresh-session-transcript",
|
||||
timeout: 60_000,
|
||||
run: async () => refreshWorkbenchSessionTranscript(loopCtx, msg.body.sessionId),
|
||||
});
|
||||
await msg.complete({ ok: true });
|
||||
async refreshDerived(c: any, _body: any) {
|
||||
await refreshWorkspaceDerivedState(c);
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
"task.command.workbench.close_session": async (loopCtx, msg) => {
|
||||
await loopCtx.step({
|
||||
name: "workbench-close-session",
|
||||
timeout: 5 * 60_000,
|
||||
run: async () => closeWorkbenchSession(loopCtx, msg.body.sessionId),
|
||||
});
|
||||
await msg.complete({ ok: true });
|
||||
async refreshSessionTranscript(c: any, body: any) {
|
||||
await refreshWorkspaceSessionTranscript(c, body.sessionId);
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
"task.command.workbench.publish_pr": async (loopCtx, msg) => {
|
||||
await loopCtx.step({
|
||||
name: "workbench-publish-pr",
|
||||
timeout: 10 * 60_000,
|
||||
run: async () => publishWorkbenchPr(loopCtx),
|
||||
});
|
||||
await msg.complete({ ok: true });
|
||||
async closeSession(c: any, body: any) {
|
||||
await closeWorkspaceSession(c, body.sessionId, body?.authSessionId);
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
"task.command.workbench.revert_file": async (loopCtx, msg) => {
|
||||
await loopCtx.step({
|
||||
name: "workbench-revert-file",
|
||||
timeout: 5 * 60_000,
|
||||
run: async () => revertWorkbenchFile(loopCtx, msg.body.path),
|
||||
});
|
||||
await msg.complete({ ok: true });
|
||||
async publishPr(c: any, _body: any) {
|
||||
await publishWorkspacePr(c);
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
async revertFile(c: any, body: any) {
|
||||
await revertWorkspaceFile(c, body.path);
|
||||
return { ok: true };
|
||||
},
|
||||
};
|
||||
|
||||
export async function runTaskWorkflow(ctx: any): Promise<void> {
|
||||
await ctx.loop("task-command-loop", async (loopCtx: any) => {
|
||||
const msg = await loopCtx.queue.next("next-command", {
|
||||
names: [...TASK_QUEUE_NAMES],
|
||||
completable: true,
|
||||
});
|
||||
if (!msg) {
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
const handler = commandHandlers[msg.name as TaskQueueName];
|
||||
if (handler) {
|
||||
try {
|
||||
await handler(loopCtx, msg);
|
||||
} catch (error) {
|
||||
const message = resolveErrorMessage(error);
|
||||
logActorWarning("task.workflow", "task workflow command failed", {
|
||||
queueName: msg.name,
|
||||
error: message,
|
||||
});
|
||||
await msg.complete({ error: message }).catch(() => {});
|
||||
}
|
||||
}
|
||||
return Loop.continue(undefined);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,49 +1,44 @@
|
|||
// @ts-nocheck
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getActorRuntimeContext } from "../../context.js";
|
||||
import { getOrCreateHistory, selfTask } from "../../handles.js";
|
||||
import { selfTask } from "../../handles.js";
|
||||
import { resolveErrorMessage } from "../../logging.js";
|
||||
import { defaultSandboxProviderId } from "../../../sandbox-config.js";
|
||||
import { task as taskTable, taskRuntime } from "../db/schema.js";
|
||||
import { TASK_ROW_ID, appendHistory, collectErrorMessages, resolveErrorDetail, setTaskState } from "./common.js";
|
||||
import { taskWorkflowQueueName } from "./queue.js";
|
||||
|
||||
async function ensureTaskRuntimeCacheColumns(db: any): Promise<void> {
|
||||
await db.execute(`ALTER TABLE task_runtime ADD COLUMN git_state_json text`).catch(() => {});
|
||||
await db.execute(`ALTER TABLE task_runtime ADD COLUMN git_state_updated_at integer`).catch(() => {});
|
||||
await db.execute(`ALTER TABLE task_runtime ADD COLUMN provision_stage text`).catch(() => {});
|
||||
await db.execute(`ALTER TABLE task_runtime ADD COLUMN provision_stage_updated_at integer`).catch(() => {});
|
||||
}
|
||||
import { TASK_ROW_ID, appendAuditLog, collectErrorMessages, resolveErrorDetail, setTaskState } from "./common.js";
|
||||
// task actions called directly (no queue)
|
||||
|
||||
export async function initBootstrapDbActivity(loopCtx: any, body: any): Promise<void> {
|
||||
const { config } = getActorRuntimeContext();
|
||||
const sandboxProviderId = body?.sandboxProviderId ?? loopCtx.state.sandboxProviderId ?? defaultSandboxProviderId(config);
|
||||
const sandboxProviderId = body?.sandboxProviderId ?? defaultSandboxProviderId(config);
|
||||
const task = body?.task;
|
||||
if (typeof task !== "string" || task.trim().length === 0) {
|
||||
throw new Error("task initialize requires the task prompt");
|
||||
}
|
||||
const now = Date.now();
|
||||
|
||||
await ensureTaskRuntimeCacheColumns(loopCtx.db);
|
||||
|
||||
await loopCtx.db
|
||||
.insert(taskTable)
|
||||
.values({
|
||||
id: TASK_ROW_ID,
|
||||
branchName: loopCtx.state.branchName,
|
||||
title: loopCtx.state.title,
|
||||
task: loopCtx.state.task,
|
||||
branchName: body?.branchName ?? null,
|
||||
title: body?.title ?? null,
|
||||
task,
|
||||
sandboxProviderId,
|
||||
status: "init_bootstrap_db",
|
||||
agentType: loopCtx.state.agentType ?? config.default_agent,
|
||||
pullRequestJson: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: taskTable.id,
|
||||
set: {
|
||||
branchName: loopCtx.state.branchName,
|
||||
title: loopCtx.state.title,
|
||||
task: loopCtx.state.task,
|
||||
branchName: body?.branchName ?? null,
|
||||
title: body?.title ?? null,
|
||||
task,
|
||||
sandboxProviderId,
|
||||
status: "init_bootstrap_db",
|
||||
agentType: loopCtx.state.agentType ?? config.default_agent,
|
||||
pullRequestJson: null,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
|
|
@ -54,26 +49,18 @@ export async function initBootstrapDbActivity(loopCtx: any, body: any): Promise<
|
|||
.values({
|
||||
id: TASK_ROW_ID,
|
||||
activeSandboxId: null,
|
||||
activeSessionId: null,
|
||||
activeSwitchTarget: null,
|
||||
activeCwd: null,
|
||||
statusMessage: "provisioning",
|
||||
gitStateJson: null,
|
||||
gitStateUpdatedAt: null,
|
||||
provisionStage: "queued",
|
||||
provisionStageUpdatedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: taskRuntime.id,
|
||||
set: {
|
||||
activeSandboxId: null,
|
||||
activeSessionId: null,
|
||||
activeSwitchTarget: null,
|
||||
activeCwd: null,
|
||||
statusMessage: "provisioning",
|
||||
provisionStage: "queued",
|
||||
provisionStageUpdatedAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
|
|
@ -81,22 +68,11 @@ export async function initBootstrapDbActivity(loopCtx: any, body: any): Promise<
|
|||
}
|
||||
|
||||
export async function initEnqueueProvisionActivity(loopCtx: any, body: any): Promise<void> {
|
||||
await setTaskState(loopCtx, "init_enqueue_provision", "provision queued");
|
||||
await loopCtx.db
|
||||
.update(taskRuntime)
|
||||
.set({
|
||||
provisionStage: "queued",
|
||||
provisionStageUpdatedAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
.where(eq(taskRuntime.id, TASK_ROW_ID))
|
||||
.run();
|
||||
await setTaskState(loopCtx, "init_enqueue_provision");
|
||||
|
||||
const self = selfTask(loopCtx);
|
||||
try {
|
||||
await self.send(taskWorkflowQueueName("task.command.provision"), body, {
|
||||
wait: false,
|
||||
});
|
||||
void self.provision(body).catch(() => {});
|
||||
} catch (error) {
|
||||
logActorWarning("task.init", "background provision command failed", {
|
||||
organizationId: loopCtx.state.organizationId,
|
||||
|
|
@ -111,60 +87,52 @@ export async function initEnqueueProvisionActivity(loopCtx: any, body: any): Pro
|
|||
export async function initCompleteActivity(loopCtx: any, body: any): Promise<void> {
|
||||
const now = Date.now();
|
||||
const { config } = getActorRuntimeContext();
|
||||
const sandboxProviderId = body?.sandboxProviderId ?? loopCtx.state.sandboxProviderId ?? defaultSandboxProviderId(config);
|
||||
const sandboxProviderId = body?.sandboxProviderId ?? defaultSandboxProviderId(config);
|
||||
|
||||
await setTaskState(loopCtx, "init_complete", "task initialized");
|
||||
await setTaskState(loopCtx, "init_complete");
|
||||
await loopCtx.db
|
||||
.update(taskRuntime)
|
||||
.set({
|
||||
statusMessage: "ready",
|
||||
provisionStage: "ready",
|
||||
provisionStageUpdatedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(taskRuntime.id, TASK_ROW_ID))
|
||||
.run();
|
||||
|
||||
const history = await getOrCreateHistory(loopCtx, loopCtx.state.organizationId, loopCtx.state.repoId);
|
||||
await history.append({
|
||||
kind: "task.initialized",
|
||||
taskId: loopCtx.state.taskId,
|
||||
branchName: loopCtx.state.branchName,
|
||||
await appendAuditLog(loopCtx, "task.initialized", {
|
||||
payload: { sandboxProviderId },
|
||||
});
|
||||
|
||||
loopCtx.state.initialized = true;
|
||||
}
|
||||
|
||||
export async function initFailedActivity(loopCtx: any, error: unknown): Promise<void> {
|
||||
export async function initFailedActivity(loopCtx: any, error: unknown, body?: any): Promise<void> {
|
||||
const now = Date.now();
|
||||
const detail = resolveErrorDetail(error);
|
||||
const messages = collectErrorMessages(error);
|
||||
const { config } = getActorRuntimeContext();
|
||||
const sandboxProviderId = loopCtx.state.sandboxProviderId ?? defaultSandboxProviderId(config);
|
||||
const sandboxProviderId = defaultSandboxProviderId(config);
|
||||
const task = typeof body?.task === "string" ? body.task : null;
|
||||
|
||||
await loopCtx.db
|
||||
.insert(taskTable)
|
||||
.values({
|
||||
id: TASK_ROW_ID,
|
||||
branchName: loopCtx.state.branchName ?? null,
|
||||
title: loopCtx.state.title ?? null,
|
||||
task: loopCtx.state.task,
|
||||
branchName: body?.branchName ?? null,
|
||||
title: body?.title ?? null,
|
||||
task: task ?? detail,
|
||||
sandboxProviderId,
|
||||
status: "error",
|
||||
agentType: loopCtx.state.agentType ?? config.default_agent,
|
||||
pullRequestJson: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: taskTable.id,
|
||||
set: {
|
||||
branchName: loopCtx.state.branchName ?? null,
|
||||
title: loopCtx.state.title ?? null,
|
||||
task: loopCtx.state.task,
|
||||
branchName: body?.branchName ?? null,
|
||||
title: body?.title ?? null,
|
||||
task: task ?? detail,
|
||||
sandboxProviderId,
|
||||
status: "error",
|
||||
agentType: loopCtx.state.agentType ?? config.default_agent,
|
||||
pullRequestJson: null,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
|
|
@ -175,30 +143,22 @@ export async function initFailedActivity(loopCtx: any, error: unknown): Promise<
|
|||
.values({
|
||||
id: TASK_ROW_ID,
|
||||
activeSandboxId: null,
|
||||
activeSessionId: null,
|
||||
activeSwitchTarget: null,
|
||||
activeCwd: null,
|
||||
statusMessage: detail,
|
||||
provisionStage: "error",
|
||||
provisionStageUpdatedAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: taskRuntime.id,
|
||||
set: {
|
||||
activeSandboxId: null,
|
||||
activeSessionId: null,
|
||||
activeSwitchTarget: null,
|
||||
activeCwd: null,
|
||||
statusMessage: detail,
|
||||
provisionStage: "error",
|
||||
provisionStageUpdatedAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
|
||||
await appendHistory(loopCtx, "task.error", {
|
||||
await appendAuditLog(loopCtx, "task.error", {
|
||||
detail,
|
||||
messages,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
// @ts-nocheck
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getTaskSandbox } from "../../handles.js";
|
||||
import { resolveOrganizationGithubAuth } from "../../../services/github-auth.js";
|
||||
import { taskRuntime, taskSandboxes } from "../db/schema.js";
|
||||
import { TASK_ROW_ID, appendHistory, getCurrentRecord } from "./common.js";
|
||||
import { appendAuditLog, getCurrentRecord } from "./common.js";
|
||||
|
||||
export interface PushActiveBranchOptions {
|
||||
reason?: string | null;
|
||||
|
|
@ -13,7 +11,7 @@ export interface PushActiveBranchOptions {
|
|||
export async function pushActiveBranchActivity(loopCtx: any, options: PushActiveBranchOptions = {}): Promise<void> {
|
||||
const record = await getCurrentRecord(loopCtx);
|
||||
const activeSandboxId = record.activeSandboxId;
|
||||
const branchName = loopCtx.state.branchName ?? record.branchName;
|
||||
const branchName = record.branchName;
|
||||
|
||||
if (!activeSandboxId) {
|
||||
throw new Error("cannot push: no active sandbox");
|
||||
|
|
@ -28,19 +26,6 @@ export async function pushActiveBranchActivity(loopCtx: any, options: PushActive
|
|||
throw new Error("cannot push: active sandbox cwd is not set");
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
await loopCtx.db
|
||||
.update(taskRuntime)
|
||||
.set({ statusMessage: `pushing branch ${branchName}`, updatedAt: now })
|
||||
.where(eq(taskRuntime.id, TASK_ROW_ID))
|
||||
.run();
|
||||
|
||||
await loopCtx.db
|
||||
.update(taskSandboxes)
|
||||
.set({ statusMessage: `pushing branch ${branchName}`, updatedAt: now })
|
||||
.where(eq(taskSandboxes.sandboxId, activeSandboxId))
|
||||
.run();
|
||||
|
||||
const script = [
|
||||
"set -euo pipefail",
|
||||
`cd ${JSON.stringify(cwd)}`,
|
||||
|
|
@ -68,20 +53,7 @@ export async function pushActiveBranchActivity(loopCtx: any, options: PushActive
|
|||
throw new Error(`git push failed (${result.exitCode ?? 1}): ${[result.stdout, result.stderr].filter(Boolean).join("")}`);
|
||||
}
|
||||
|
||||
const updatedAt = Date.now();
|
||||
await loopCtx.db
|
||||
.update(taskRuntime)
|
||||
.set({ statusMessage: `push complete for ${branchName}`, updatedAt })
|
||||
.where(eq(taskRuntime.id, TASK_ROW_ID))
|
||||
.run();
|
||||
|
||||
await loopCtx.db
|
||||
.update(taskSandboxes)
|
||||
.set({ statusMessage: `push complete for ${branchName}`, updatedAt })
|
||||
.where(eq(taskSandboxes.sandboxId, activeSandboxId))
|
||||
.run();
|
||||
|
||||
await appendHistory(loopCtx, options.historyKind ?? "task.push", {
|
||||
await appendAuditLog(loopCtx, options.historyKind ?? "task.push", {
|
||||
reason: options.reason ?? null,
|
||||
branchName,
|
||||
sandboxId: activeSandboxId,
|
||||
|
|
|
|||
|
|
@ -9,24 +9,25 @@ export const TASK_QUEUE_NAMES = [
|
|||
"task.command.archive",
|
||||
"task.command.kill",
|
||||
"task.command.get",
|
||||
"task.command.workbench.mark_unread",
|
||||
"task.command.workbench.rename_task",
|
||||
"task.command.workbench.rename_branch",
|
||||
"task.command.workbench.create_session",
|
||||
"task.command.workbench.create_session_and_send",
|
||||
"task.command.workbench.ensure_session",
|
||||
"task.command.workbench.rename_session",
|
||||
"task.command.workbench.set_session_unread",
|
||||
"task.command.workbench.update_draft",
|
||||
"task.command.workbench.change_model",
|
||||
"task.command.workbench.send_message",
|
||||
"task.command.workbench.stop_session",
|
||||
"task.command.workbench.sync_session_status",
|
||||
"task.command.workbench.refresh_derived",
|
||||
"task.command.workbench.refresh_session_transcript",
|
||||
"task.command.workbench.close_session",
|
||||
"task.command.workbench.publish_pr",
|
||||
"task.command.workbench.revert_file",
|
||||
"task.command.pull_request.sync",
|
||||
"task.command.workspace.mark_unread",
|
||||
"task.command.workspace.rename_task",
|
||||
"task.command.workspace.create_session",
|
||||
"task.command.workspace.create_session_and_send",
|
||||
"task.command.workspace.ensure_session",
|
||||
"task.command.workspace.rename_session",
|
||||
"task.command.workspace.select_session",
|
||||
"task.command.workspace.set_session_unread",
|
||||
"task.command.workspace.update_draft",
|
||||
"task.command.workspace.change_model",
|
||||
"task.command.workspace.send_message",
|
||||
"task.command.workspace.stop_session",
|
||||
"task.command.workspace.sync_session_status",
|
||||
"task.command.workspace.refresh_derived",
|
||||
"task.command.workspace.refresh_session_transcript",
|
||||
"task.command.workspace.close_session",
|
||||
"task.command.workspace.publish_pr",
|
||||
"task.command.workspace.revert_file",
|
||||
] as const;
|
||||
|
||||
export function taskWorkflowQueueName(name: string): string {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,47 @@
|
|||
import { asc, count as sqlCount, desc } from "drizzle-orm";
|
||||
import { applyJoinToRow, applyJoinToRows, buildWhere, columnFor, tableFor } from "../query-helpers.js";
|
||||
|
||||
export const betterAuthActions = {
|
||||
// Better Auth adapter action — called by the Better Auth adapter in better-auth.ts.
|
||||
// Schema and behavior are constrained by Better Auth.
|
||||
async betterAuthFindOneRecord(c, input: { model: string; where: any[]; join?: any }) {
|
||||
const table = tableFor(input.model);
|
||||
const predicate = buildWhere(table, input.where);
|
||||
const row = predicate ? await c.db.select().from(table).where(predicate).get() : await c.db.select().from(table).get();
|
||||
return await applyJoinToRow(c, input.model, row ?? null, input.join);
|
||||
},
|
||||
|
||||
// Better Auth adapter action — called by the Better Auth adapter in better-auth.ts.
|
||||
// Schema and behavior are constrained by Better Auth.
|
||||
async betterAuthFindManyRecords(c, input: { model: string; where?: any[]; limit?: number; offset?: number; sortBy?: any; join?: any }) {
|
||||
const table = tableFor(input.model);
|
||||
const predicate = buildWhere(table, input.where);
|
||||
let query: any = c.db.select().from(table);
|
||||
if (predicate) {
|
||||
query = query.where(predicate);
|
||||
}
|
||||
if (input.sortBy?.field) {
|
||||
const column = columnFor(input.model, table, input.sortBy.field);
|
||||
query = query.orderBy(input.sortBy.direction === "asc" ? asc(column) : desc(column));
|
||||
}
|
||||
if (typeof input.limit === "number") {
|
||||
query = query.limit(input.limit);
|
||||
}
|
||||
if (typeof input.offset === "number") {
|
||||
query = query.offset(input.offset);
|
||||
}
|
||||
const rows = await query.all();
|
||||
return await applyJoinToRows(c, input.model, rows, input.join);
|
||||
},
|
||||
|
||||
// Better Auth adapter action — called by the Better Auth adapter in better-auth.ts.
|
||||
// Schema and behavior are constrained by Better Auth.
|
||||
async betterAuthCountRecords(c, input: { model: string; where?: any[] }) {
|
||||
const table = tableFor(input.model);
|
||||
const predicate = buildWhere(table, input.where);
|
||||
const row = predicate
|
||||
? await c.db.select({ value: sqlCount() }).from(table).where(predicate).get()
|
||||
: await c.db.select({ value: sqlCount() }).from(table).get();
|
||||
return row?.value ?? 0;
|
||||
},
|
||||
};
|
||||
44
foundry/packages/backend/src/actors/user/actions/user.ts
Normal file
44
foundry/packages/backend/src/actors/user/actions/user.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { eq } from "drizzle-orm";
|
||||
import { authAccounts, authSessions, authUsers, sessionState, userProfiles, userTaskState } from "../db/schema.js";
|
||||
import { materializeRow } from "../query-helpers.js";
|
||||
|
||||
export const userActions = {
|
||||
// Custom Foundry action — not part of Better Auth.
|
||||
async getAppAuthState(c, input: { sessionId: string }) {
|
||||
const session = await c.db.select().from(authSessions).where(eq(authSessions.id, input.sessionId)).get();
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
const [user, profile, currentSessionState, accounts] = await Promise.all([
|
||||
c.db.select().from(authUsers).where(eq(authUsers.authUserId, session.userId)).get(),
|
||||
c.db.select().from(userProfiles).where(eq(userProfiles.userId, session.userId)).get(),
|
||||
c.db.select().from(sessionState).where(eq(sessionState.sessionId, input.sessionId)).get(),
|
||||
c.db.select().from(authAccounts).where(eq(authAccounts.userId, session.userId)).all(),
|
||||
]);
|
||||
return {
|
||||
session,
|
||||
user: materializeRow("user", user),
|
||||
profile: profile ?? null,
|
||||
sessionState: currentSessionState ?? null,
|
||||
accounts,
|
||||
};
|
||||
},
|
||||
|
||||
// Custom Foundry action — not part of Better Auth.
|
||||
async getTaskState(c, input: { taskId: string }) {
|
||||
const rows = await c.db.select().from(userTaskState).where(eq(userTaskState.taskId, input.taskId)).all();
|
||||
const activeSessionId = rows.find((row) => typeof row.activeSessionId === "string" && row.activeSessionId.length > 0)?.activeSessionId ?? null;
|
||||
return {
|
||||
taskId: input.taskId,
|
||||
activeSessionId,
|
||||
sessions: rows.map((row) => ({
|
||||
sessionId: row.sessionId,
|
||||
unread: row.unread === 1,
|
||||
draftText: row.draftText,
|
||||
draftAttachmentsJson: row.draftAttachmentsJson,
|
||||
draftUpdatedAt: row.draftUpdatedAt ?? null,
|
||||
updatedAt: row.updatedAt,
|
||||
})),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
@ -2,4 +2,4 @@ import { db } from "rivetkit/db/drizzle";
|
|||
import * as schema from "./schema.js";
|
||||
import migrations from "./migrations.js";
|
||||
|
||||
export const historyDb = db({ schema, migrations });
|
||||
export const userDb = db({ schema, migrations });
|
||||
|
|
@ -10,6 +10,12 @@ const journal = {
|
|||
tag: "0000_auth_user",
|
||||
breakpoints: true,
|
||||
},
|
||||
{
|
||||
idx: 1,
|
||||
when: 1773532800000,
|
||||
tag: "0001_user_task_state",
|
||||
breakpoints: true,
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
|
||||
|
|
@ -17,15 +23,19 @@ export default {
|
|||
journal,
|
||||
migrations: {
|
||||
m0000: `CREATE TABLE \`user\` (
|
||||
\`id\` text PRIMARY KEY NOT NULL,
|
||||
\`id\` integer PRIMARY KEY NOT NULL,
|
||||
\`auth_user_id\` text NOT NULL,
|
||||
\`name\` text NOT NULL,
|
||||
\`email\` text NOT NULL,
|
||||
\`email_verified\` integer NOT NULL,
|
||||
\`image\` text,
|
||||
\`created_at\` integer NOT NULL,
|
||||
\`updated_at\` integer NOT NULL
|
||||
\`updated_at\` integer NOT NULL,
|
||||
CONSTRAINT \`user_singleton_id_check\` CHECK(\`id\` = 1)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX \`user_auth_user_id_idx\` ON \`user\` (\`auth_user_id\`);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE \`session\` (
|
||||
\`id\` text PRIMARY KEY NOT NULL,
|
||||
\`token\` text NOT NULL,
|
||||
|
|
@ -58,23 +68,39 @@ CREATE TABLE \`account\` (
|
|||
CREATE UNIQUE INDEX \`account_provider_account_idx\` ON \`account\` (\`provider_id\`, \`account_id\`);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE \`user_profiles\` (
|
||||
\`user_id\` text PRIMARY KEY NOT NULL,
|
||||
\`id\` integer PRIMARY KEY NOT NULL,
|
||||
\`user_id\` text NOT NULL,
|
||||
\`github_account_id\` text,
|
||||
\`github_login\` text,
|
||||
\`role_label\` text NOT NULL,
|
||||
\`default_model\` text DEFAULT 'gpt-5.3-codex' NOT NULL,
|
||||
\`eligible_organization_ids_json\` text NOT NULL,
|
||||
\`starter_repo_status\` text NOT NULL,
|
||||
\`starter_repo_starred_at\` integer,
|
||||
\`starter_repo_skipped_at\` integer,
|
||||
\`created_at\` integer NOT NULL,
|
||||
\`updated_at\` integer NOT NULL
|
||||
\`updated_at\` integer NOT NULL,
|
||||
CONSTRAINT \`user_profiles_singleton_id_check\` CHECK(\`id\` = 1)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX \`user_profiles_user_id_idx\` ON \`user_profiles\` (\`user_id\`);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE \`session_state\` (
|
||||
\`session_id\` text PRIMARY KEY NOT NULL,
|
||||
\`active_organization_id\` text,
|
||||
\`created_at\` integer NOT NULL,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);`,
|
||||
m0001: `CREATE TABLE \`user_task_state\` (
|
||||
\`task_id\` text NOT NULL,
|
||||
\`session_id\` text NOT NULL,
|
||||
\`active_session_id\` text,
|
||||
\`unread\` integer DEFAULT 0 NOT NULL,
|
||||
\`draft_text\` text DEFAULT '' NOT NULL,
|
||||
\`draft_attachments_json\` text DEFAULT '[]' NOT NULL,
|
||||
\`draft_updated_at\` integer,
|
||||
\`updated_at\` integer NOT NULL,
|
||||
PRIMARY KEY(\`task_id\`, \`session_id\`)
|
||||
);`,
|
||||
} as const,
|
||||
};
|
||||
112
foundry/packages/backend/src/actors/user/db/schema.ts
Normal file
112
foundry/packages/backend/src/actors/user/db/schema.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import { check, integer, primaryKey, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { DEFAULT_WORKSPACE_MODEL_ID } from "@sandbox-agent/foundry-shared";
|
||||
|
||||
/** Better Auth core model — schema defined at https://better-auth.com/docs/concepts/database */
|
||||
export const authUsers = sqliteTable(
|
||||
"user",
|
||||
{
|
||||
id: integer("id").primaryKey(),
|
||||
authUserId: text("auth_user_id").notNull(),
|
||||
name: text("name").notNull(),
|
||||
email: text("email").notNull(),
|
||||
emailVerified: integer("email_verified").notNull(),
|
||||
image: text("image"),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
authUserIdIdx: uniqueIndex("user_auth_user_id_idx").on(table.authUserId),
|
||||
singletonCheck: check("user_singleton_id_check", sql`${table.id} = 1`),
|
||||
}),
|
||||
);
|
||||
|
||||
/** Better Auth core model — schema defined at https://better-auth.com/docs/concepts/database */
|
||||
export const authSessions = sqliteTable(
|
||||
"session",
|
||||
{
|
||||
id: text("id").notNull().primaryKey(),
|
||||
token: text("token").notNull(),
|
||||
userId: text("user_id").notNull(),
|
||||
expiresAt: integer("expires_at").notNull(),
|
||||
ipAddress: text("ip_address"),
|
||||
userAgent: text("user_agent"),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
tokenIdx: uniqueIndex("session_token_idx").on(table.token),
|
||||
}),
|
||||
);
|
||||
|
||||
/** Better Auth core model — schema defined at https://better-auth.com/docs/concepts/database */
|
||||
export const authAccounts = sqliteTable(
|
||||
"account",
|
||||
{
|
||||
id: text("id").notNull().primaryKey(),
|
||||
accountId: text("account_id").notNull(),
|
||||
providerId: text("provider_id").notNull(),
|
||||
userId: text("user_id").notNull(),
|
||||
accessToken: text("access_token"),
|
||||
refreshToken: text("refresh_token"),
|
||||
idToken: text("id_token"),
|
||||
accessTokenExpiresAt: integer("access_token_expires_at"),
|
||||
refreshTokenExpiresAt: integer("refresh_token_expires_at"),
|
||||
scope: text("scope"),
|
||||
password: text("password"),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
providerAccountIdx: uniqueIndex("account_provider_account_idx").on(table.providerId, table.accountId),
|
||||
}),
|
||||
);
|
||||
|
||||
/** Custom Foundry table — not part of Better Auth. */
|
||||
export const userProfiles = sqliteTable(
|
||||
"user_profiles",
|
||||
{
|
||||
id: integer("id").primaryKey(),
|
||||
userId: text("user_id").notNull(),
|
||||
githubAccountId: text("github_account_id"),
|
||||
githubLogin: text("github_login"),
|
||||
roleLabel: text("role_label").notNull(),
|
||||
defaultModel: text("default_model").notNull().default(DEFAULT_WORKSPACE_MODEL_ID),
|
||||
eligibleOrganizationIdsJson: text("eligible_organization_ids_json").notNull(),
|
||||
starterRepoStatus: text("starter_repo_status").notNull(),
|
||||
starterRepoStarredAt: integer("starter_repo_starred_at"),
|
||||
starterRepoSkippedAt: integer("starter_repo_skipped_at"),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
userIdIdx: uniqueIndex("user_profiles_user_id_idx").on(table.userId),
|
||||
singletonCheck: check("user_profiles_singleton_id_check", sql`${table.id} = 1`),
|
||||
}),
|
||||
);
|
||||
|
||||
/** Custom Foundry table — not part of Better Auth. */
|
||||
export const sessionState = sqliteTable("session_state", {
|
||||
sessionId: text("session_id").notNull().primaryKey(),
|
||||
activeOrganizationId: text("active_organization_id"),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
/** Custom Foundry table — not part of Better Auth. Stores per-user task/session UI state. */
|
||||
export const userTaskState = sqliteTable(
|
||||
"user_task_state",
|
||||
{
|
||||
taskId: text("task_id").notNull(),
|
||||
sessionId: text("session_id").notNull(),
|
||||
activeSessionId: text("active_session_id"),
|
||||
unread: integer("unread").notNull().default(0),
|
||||
draftText: text("draft_text").notNull().default(""),
|
||||
draftAttachmentsJson: text("draft_attachments_json").notNull().default("[]"),
|
||||
draftUpdatedAt: integer("draft_updated_at"),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
pk: primaryKey({ columns: [table.taskId, table.sessionId] }),
|
||||
}),
|
||||
);
|
||||
60
foundry/packages/backend/src/actors/user/index.ts
Normal file
60
foundry/packages/backend/src/actors/user/index.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { actor } from "rivetkit";
|
||||
import { userDb } from "./db/db.js";
|
||||
import { betterAuthActions } from "./actions/better-auth.js";
|
||||
import { userActions } from "./actions/user.js";
|
||||
import {
|
||||
createAuthRecordMutation,
|
||||
updateAuthRecordMutation,
|
||||
updateManyAuthRecordsMutation,
|
||||
deleteAuthRecordMutation,
|
||||
deleteManyAuthRecordsMutation,
|
||||
upsertUserProfileMutation,
|
||||
upsertSessionStateMutation,
|
||||
upsertTaskStateMutation,
|
||||
deleteTaskStateMutation,
|
||||
} from "./workflow.js";
|
||||
|
||||
export const user = actor({
|
||||
db: userDb,
|
||||
options: {
|
||||
name: "User",
|
||||
icon: "shield",
|
||||
actionTimeout: 60_000,
|
||||
},
|
||||
createState: (_c, input: { userId: string }) => ({
|
||||
userId: input.userId,
|
||||
}),
|
||||
actions: {
|
||||
...betterAuthActions,
|
||||
...userActions,
|
||||
async authCreate(c, body) {
|
||||
return await createAuthRecordMutation(c, body);
|
||||
},
|
||||
async authUpdate(c, body) {
|
||||
return await updateAuthRecordMutation(c, body);
|
||||
},
|
||||
async authUpdateMany(c, body) {
|
||||
return await updateManyAuthRecordsMutation(c, body);
|
||||
},
|
||||
async authDelete(c, body) {
|
||||
await deleteAuthRecordMutation(c, body);
|
||||
return { ok: true };
|
||||
},
|
||||
async authDeleteMany(c, body) {
|
||||
return await deleteManyAuthRecordsMutation(c, body);
|
||||
},
|
||||
async profileUpsert(c, body) {
|
||||
return await upsertUserProfileMutation(c, body);
|
||||
},
|
||||
async sessionStateUpsert(c, body) {
|
||||
return await upsertSessionStateMutation(c, body);
|
||||
},
|
||||
async taskStateUpsert(c, body) {
|
||||
return await upsertTaskStateMutation(c, body);
|
||||
},
|
||||
async taskStateDelete(c, body) {
|
||||
await deleteTaskStateMutation(c, body);
|
||||
return { ok: true };
|
||||
},
|
||||
},
|
||||
});
|
||||
197
foundry/packages/backend/src/actors/user/query-helpers.ts
Normal file
197
foundry/packages/backend/src/actors/user/query-helpers.ts
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import { and, eq, inArray, isNotNull, isNull, like, lt, lte, gt, gte, ne, notInArray, or } from "drizzle-orm";
|
||||
import { authAccounts, authSessions, authUsers, sessionState, userProfiles, userTaskState } from "./db/schema.js";
|
||||
|
||||
export const userTables = {
|
||||
user: authUsers,
|
||||
session: authSessions,
|
||||
account: authAccounts,
|
||||
userProfiles,
|
||||
sessionState,
|
||||
userTaskState,
|
||||
} as const;
|
||||
|
||||
export function tableFor(model: string) {
|
||||
const table = userTables[model as keyof typeof userTables];
|
||||
if (!table) {
|
||||
throw new Error(`Unsupported user model: ${model}`);
|
||||
}
|
||||
return table as any;
|
||||
}
|
||||
|
||||
function dbFieldFor(model: string, field: string): string {
|
||||
if (model === "user" && field === "id") {
|
||||
return "authUserId";
|
||||
}
|
||||
return field;
|
||||
}
|
||||
|
||||
export function materializeRow(model: string, row: any) {
|
||||
if (!row || model !== "user") {
|
||||
return row;
|
||||
}
|
||||
|
||||
const { id: _singletonId, authUserId, ...rest } = row;
|
||||
return {
|
||||
id: authUserId,
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
|
||||
export function persistInput(model: string, data: Record<string, unknown>) {
|
||||
if (model !== "user") {
|
||||
return data;
|
||||
}
|
||||
|
||||
const { id, ...rest } = data;
|
||||
return {
|
||||
id: 1,
|
||||
authUserId: id,
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
|
||||
export function persistPatch(model: string, data: Record<string, unknown>) {
|
||||
if (model !== "user") {
|
||||
return data;
|
||||
}
|
||||
|
||||
const { id, ...rest } = data;
|
||||
return {
|
||||
...(id !== undefined ? { authUserId: id } : {}),
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
|
||||
export function columnFor(model: string, table: any, field: string) {
|
||||
const column = table[dbFieldFor(model, field)];
|
||||
if (!column) {
|
||||
throw new Error(`Unsupported user field: ${model}.${field}`);
|
||||
}
|
||||
return column;
|
||||
}
|
||||
|
||||
export function normalizeValue(value: unknown): unknown {
|
||||
if (value instanceof Date) {
|
||||
return value.getTime();
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entry) => normalizeValue(entry));
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function clauseToExpr(table: any, clause: any) {
|
||||
const model = table === authUsers ? "user" : table === authSessions ? "session" : table === authAccounts ? "account" : "";
|
||||
const column = columnFor(model, table, clause.field);
|
||||
const value = normalizeValue(clause.value);
|
||||
|
||||
switch (clause.operator) {
|
||||
case "ne":
|
||||
return value === null ? isNotNull(column) : ne(column, value as any);
|
||||
case "lt":
|
||||
return lt(column, value as any);
|
||||
case "lte":
|
||||
return lte(column, value as any);
|
||||
case "gt":
|
||||
return gt(column, value as any);
|
||||
case "gte":
|
||||
return gte(column, value as any);
|
||||
case "in":
|
||||
return inArray(column, Array.isArray(value) ? (value as any[]) : [value as any]);
|
||||
case "not_in":
|
||||
return notInArray(column, Array.isArray(value) ? (value as any[]) : [value as any]);
|
||||
case "contains":
|
||||
return like(column, `%${String(value ?? "")}%`);
|
||||
case "starts_with":
|
||||
return like(column, `${String(value ?? "")}%`);
|
||||
case "ends_with":
|
||||
return like(column, `%${String(value ?? "")}`);
|
||||
case "eq":
|
||||
default:
|
||||
return value === null ? isNull(column) : eq(column, value as any);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildWhere(table: any, where: any[] | undefined) {
|
||||
if (!where || where.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let expr = clauseToExpr(table, where[0]);
|
||||
for (const clause of where.slice(1)) {
|
||||
const next = clauseToExpr(table, clause);
|
||||
expr = clause.connector === "OR" ? or(expr, next) : and(expr, next);
|
||||
}
|
||||
return expr;
|
||||
}
|
||||
|
||||
export function applyJoinToRow(c: any, model: string, row: any, join: any) {
|
||||
const materialized = materializeRow(model, row);
|
||||
if (!materialized || !join) {
|
||||
return materialized;
|
||||
}
|
||||
|
||||
if (model === "session" && join.user) {
|
||||
return c.db
|
||||
.select()
|
||||
.from(authUsers)
|
||||
.where(eq(authUsers.authUserId, materialized.userId))
|
||||
.get()
|
||||
.then((user: any) => ({ ...materialized, user: materializeRow("user", user) ?? null }));
|
||||
}
|
||||
|
||||
if (model === "account" && join.user) {
|
||||
return c.db
|
||||
.select()
|
||||
.from(authUsers)
|
||||
.where(eq(authUsers.authUserId, materialized.userId))
|
||||
.get()
|
||||
.then((user: any) => ({ ...materialized, user: materializeRow("user", user) ?? null }));
|
||||
}
|
||||
|
||||
if (model === "user" && join.account) {
|
||||
return c.db
|
||||
.select()
|
||||
.from(authAccounts)
|
||||
.where(eq(authAccounts.userId, materialized.id))
|
||||
.all()
|
||||
.then((accounts: any[]) => ({ ...materialized, account: accounts }));
|
||||
}
|
||||
|
||||
return Promise.resolve(materialized);
|
||||
}
|
||||
|
||||
export async function applyJoinToRows(c: any, model: string, rows: any[], join: any) {
|
||||
if (!join || rows.length === 0) {
|
||||
return rows.map((row) => materializeRow(model, row));
|
||||
}
|
||||
|
||||
if (model === "session" && join.user) {
|
||||
const userIds = [...new Set(rows.map((row) => row.userId).filter(Boolean))];
|
||||
const users = userIds.length > 0 ? await c.db.select().from(authUsers).where(inArray(authUsers.authUserId, userIds)).all() : [];
|
||||
const userMap = new Map(users.map((user: any) => [user.authUserId, materializeRow("user", user)]));
|
||||
return rows.map((row) => ({ ...row, user: userMap.get(row.userId) ?? null }));
|
||||
}
|
||||
|
||||
if (model === "account" && join.user) {
|
||||
const userIds = [...new Set(rows.map((row) => row.userId).filter(Boolean))];
|
||||
const users = userIds.length > 0 ? await c.db.select().from(authUsers).where(inArray(authUsers.authUserId, userIds)).all() : [];
|
||||
const userMap = new Map(users.map((user: any) => [user.authUserId, materializeRow("user", user)]));
|
||||
return rows.map((row) => ({ ...row, user: userMap.get(row.userId) ?? null }));
|
||||
}
|
||||
|
||||
if (model === "user" && join.account) {
|
||||
const materializedRows = rows.map((row) => materializeRow("user", row));
|
||||
const userIds = materializedRows.map((row) => row.id);
|
||||
const accounts = userIds.length > 0 ? await c.db.select().from(authAccounts).where(inArray(authAccounts.userId, userIds)).all() : [];
|
||||
const accountsByUserId = new Map<string, any[]>();
|
||||
for (const account of accounts) {
|
||||
const entries = accountsByUserId.get(account.userId) ?? [];
|
||||
entries.push(account);
|
||||
accountsByUserId.set(account.userId, entries);
|
||||
}
|
||||
return materializedRows.map((row) => ({ ...row, account: accountsByUserId.get(row.id) ?? [] }));
|
||||
}
|
||||
|
||||
return rows.map((row) => materializeRow(model, row));
|
||||
}
|
||||
197
foundry/packages/backend/src/actors/user/workflow.ts
Normal file
197
foundry/packages/backend/src/actors/user/workflow.ts
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import { eq, count as sqlCount, and } from "drizzle-orm";
|
||||
import { DEFAULT_WORKSPACE_MODEL_ID } from "@sandbox-agent/foundry-shared";
|
||||
import { authUsers, sessionState, userProfiles, userTaskState } from "./db/schema.js";
|
||||
import { buildWhere, columnFor, materializeRow, persistInput, persistPatch, tableFor } from "./query-helpers.js";
|
||||
|
||||
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);
|
||||
await c.db
|
||||
.insert(table)
|
||||
.values(persisted as any)
|
||||
.run();
|
||||
const row = await c.db
|
||||
.select()
|
||||
.from(table)
|
||||
.where(eq(columnFor(input.model, table, "id"), input.data.id as any))
|
||||
.get();
|
||||
return materializeRow(input.model, row);
|
||||
}
|
||||
|
||||
export async function updateAuthRecordMutation(c: any, input: { model: string; where: any[]; update: Record<string, unknown> }) {
|
||||
const table = tableFor(input.model);
|
||||
const predicate = buildWhere(table, input.where);
|
||||
if (!predicate) throw new Error("updateAuthRecord requires a where clause");
|
||||
await c.db
|
||||
.update(table)
|
||||
.set(persistPatch(input.model, input.update) as any)
|
||||
.where(predicate)
|
||||
.run();
|
||||
return materializeRow(input.model, await c.db.select().from(table).where(predicate).get());
|
||||
}
|
||||
|
||||
export async function updateManyAuthRecordsMutation(c: any, input: { model: string; where: any[]; update: Record<string, unknown> }) {
|
||||
const table = tableFor(input.model);
|
||||
const predicate = buildWhere(table, input.where);
|
||||
if (!predicate) throw new Error("updateManyAuthRecords requires a where clause");
|
||||
await c.db
|
||||
.update(table)
|
||||
.set(persistPatch(input.model, input.update) as any)
|
||||
.where(predicate)
|
||||
.run();
|
||||
const row = await c.db.select({ value: sqlCount() }).from(table).where(predicate).get();
|
||||
return row?.value ?? 0;
|
||||
}
|
||||
|
||||
export async function deleteAuthRecordMutation(c: any, input: { model: string; where: any[] }) {
|
||||
const table = tableFor(input.model);
|
||||
const predicate = buildWhere(table, input.where);
|
||||
if (!predicate) throw new Error("deleteAuthRecord requires a where clause");
|
||||
await c.db.delete(table).where(predicate).run();
|
||||
}
|
||||
|
||||
export async function deleteManyAuthRecordsMutation(c: any, input: { model: string; where: any[] }) {
|
||||
const table = tableFor(input.model);
|
||||
const predicate = buildWhere(table, input.where);
|
||||
if (!predicate) throw new Error("deleteManyAuthRecords requires a where clause");
|
||||
const rows = await c.db.select().from(table).where(predicate).all();
|
||||
await c.db.delete(table).where(predicate).run();
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
export async function upsertUserProfileMutation(
|
||||
c: any,
|
||||
input: {
|
||||
userId: string;
|
||||
patch: {
|
||||
githubAccountId?: string | null;
|
||||
githubLogin?: string | null;
|
||||
roleLabel?: string;
|
||||
defaultModel?: string;
|
||||
eligibleOrganizationIdsJson?: string;
|
||||
starterRepoStatus?: string;
|
||||
starterRepoStarredAt?: number | null;
|
||||
starterRepoSkippedAt?: number | null;
|
||||
};
|
||||
},
|
||||
) {
|
||||
const now = Date.now();
|
||||
await c.db
|
||||
.insert(userProfiles)
|
||||
.values({
|
||||
id: 1,
|
||||
userId: input.userId,
|
||||
githubAccountId: input.patch.githubAccountId ?? null,
|
||||
githubLogin: input.patch.githubLogin ?? null,
|
||||
roleLabel: input.patch.roleLabel ?? "GitHub user",
|
||||
defaultModel: input.patch.defaultModel ?? DEFAULT_WORKSPACE_MODEL_ID,
|
||||
eligibleOrganizationIdsJson: input.patch.eligibleOrganizationIdsJson ?? "[]",
|
||||
starterRepoStatus: input.patch.starterRepoStatus ?? "pending",
|
||||
starterRepoStarredAt: input.patch.starterRepoStarredAt ?? null,
|
||||
starterRepoSkippedAt: input.patch.starterRepoSkippedAt ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: userProfiles.userId,
|
||||
set: {
|
||||
...(input.patch.githubAccountId !== undefined ? { githubAccountId: input.patch.githubAccountId } : {}),
|
||||
...(input.patch.githubLogin !== undefined ? { githubLogin: input.patch.githubLogin } : {}),
|
||||
...(input.patch.roleLabel !== undefined ? { roleLabel: input.patch.roleLabel } : {}),
|
||||
...(input.patch.defaultModel !== undefined ? { defaultModel: input.patch.defaultModel } : {}),
|
||||
...(input.patch.eligibleOrganizationIdsJson !== undefined ? { eligibleOrganizationIdsJson: input.patch.eligibleOrganizationIdsJson } : {}),
|
||||
...(input.patch.starterRepoStatus !== undefined ? { starterRepoStatus: input.patch.starterRepoStatus } : {}),
|
||||
...(input.patch.starterRepoStarredAt !== undefined ? { starterRepoStarredAt: input.patch.starterRepoStarredAt } : {}),
|
||||
...(input.patch.starterRepoSkippedAt !== undefined ? { starterRepoSkippedAt: input.patch.starterRepoSkippedAt } : {}),
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
return await c.db.select().from(userProfiles).where(eq(userProfiles.userId, input.userId)).get();
|
||||
}
|
||||
|
||||
export async function upsertSessionStateMutation(c: any, input: { sessionId: string; activeOrganizationId: string | null }) {
|
||||
const now = Date.now();
|
||||
await c.db
|
||||
.insert(sessionState)
|
||||
.values({
|
||||
sessionId: input.sessionId,
|
||||
activeOrganizationId: input.activeOrganizationId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: sessionState.sessionId,
|
||||
set: { activeOrganizationId: input.activeOrganizationId, updatedAt: now },
|
||||
})
|
||||
.run();
|
||||
return await c.db.select().from(sessionState).where(eq(sessionState.sessionId, input.sessionId)).get();
|
||||
}
|
||||
|
||||
export async function upsertTaskStateMutation(
|
||||
c: any,
|
||||
input: {
|
||||
taskId: string;
|
||||
sessionId: string;
|
||||
patch: {
|
||||
activeSessionId?: string | null;
|
||||
unread?: boolean;
|
||||
draftText?: string;
|
||||
draftAttachmentsJson?: string;
|
||||
draftUpdatedAt?: number | null;
|
||||
};
|
||||
},
|
||||
) {
|
||||
const now = Date.now();
|
||||
const existing = await c.db
|
||||
.select()
|
||||
.from(userTaskState)
|
||||
.where(and(eq(userTaskState.taskId, input.taskId), eq(userTaskState.sessionId, input.sessionId)))
|
||||
.get();
|
||||
|
||||
if (input.patch.activeSessionId !== undefined) {
|
||||
await c.db.update(userTaskState).set({ activeSessionId: input.patch.activeSessionId, updatedAt: now }).where(eq(userTaskState.taskId, input.taskId)).run();
|
||||
}
|
||||
|
||||
await c.db
|
||||
.insert(userTaskState)
|
||||
.values({
|
||||
taskId: input.taskId,
|
||||
sessionId: input.sessionId,
|
||||
activeSessionId: input.patch.activeSessionId ?? existing?.activeSessionId ?? null,
|
||||
unread: input.patch.unread !== undefined ? (input.patch.unread ? 1 : 0) : (existing?.unread ?? 0),
|
||||
draftText: input.patch.draftText ?? existing?.draftText ?? "",
|
||||
draftAttachmentsJson: input.patch.draftAttachmentsJson ?? existing?.draftAttachmentsJson ?? "[]",
|
||||
draftUpdatedAt: input.patch.draftUpdatedAt === undefined ? (existing?.draftUpdatedAt ?? null) : input.patch.draftUpdatedAt,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [userTaskState.taskId, userTaskState.sessionId],
|
||||
set: {
|
||||
...(input.patch.activeSessionId !== undefined ? { activeSessionId: input.patch.activeSessionId } : {}),
|
||||
...(input.patch.unread !== undefined ? { unread: input.patch.unread ? 1 : 0 } : {}),
|
||||
...(input.patch.draftText !== undefined ? { draftText: input.patch.draftText } : {}),
|
||||
...(input.patch.draftAttachmentsJson !== undefined ? { draftAttachmentsJson: input.patch.draftAttachmentsJson } : {}),
|
||||
...(input.patch.draftUpdatedAt !== undefined ? { draftUpdatedAt: input.patch.draftUpdatedAt } : {}),
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
|
||||
return await c.db
|
||||
.select()
|
||||
.from(userTaskState)
|
||||
.where(and(eq(userTaskState.taskId, input.taskId), eq(userTaskState.sessionId, input.sessionId)))
|
||||
.get();
|
||||
}
|
||||
|
||||
export async function deleteTaskStateMutation(c: any, input: { taskId: string; sessionId?: string }) {
|
||||
if (input.sessionId) {
|
||||
await c.db
|
||||
.delete(userTaskState)
|
||||
.where(and(eq(userTaskState.taskId, input.taskId), eq(userTaskState.sessionId, input.sessionId)))
|
||||
.run();
|
||||
return;
|
||||
}
|
||||
await c.db.delete(userTaskState).where(eq(userTaskState.taskId, input.taskId)).run();
|
||||
}
|
||||
|
|
@ -10,7 +10,7 @@ import { createDefaultDriver } from "./driver.js";
|
|||
import { createClient } from "rivetkit/client";
|
||||
import { initBetterAuthService } from "./services/better-auth.js";
|
||||
import { createDefaultAppShellServices } from "./services/app-shell-runtime.js";
|
||||
import { APP_SHELL_ORGANIZATION_ID } from "./actors/organization/app-shell.js";
|
||||
import { APP_SHELL_ORGANIZATION_ID } from "./actors/organization/constants.js";
|
||||
import { logger } from "./logging.js";
|
||||
|
||||
export interface BackendStartOptions {
|
||||
|
|
@ -48,6 +48,19 @@ function isRivetRequest(request: Request): boolean {
|
|||
}
|
||||
|
||||
export async function startBackend(options: BackendStartOptions = {}): Promise<void> {
|
||||
// Prevent the sandbox-agent SDK's unhandled SQLite constraint errors from
|
||||
// crashing the entire process. The SDK has a bug where duplicate event
|
||||
// inserts (sandbox_agent_events UNIQUE constraint) throw from an internal
|
||||
// async path with no catch. Log and continue.
|
||||
process.on("uncaughtException", (error) => {
|
||||
logger.error({ error: error?.message ?? String(error), stack: error?.stack }, "uncaughtException (kept alive)");
|
||||
});
|
||||
process.on("unhandledRejection", (reason) => {
|
||||
const msg = reason instanceof Error ? reason.message : String(reason);
|
||||
const stack = reason instanceof Error ? reason.stack : undefined;
|
||||
logger.error({ error: msg, stack }, "unhandledRejection (kept alive)");
|
||||
});
|
||||
|
||||
// sandbox-agent agent plugins vary on which env var they read for OpenAI/Codex auth.
|
||||
// Normalize to keep local dev + docker-compose simple.
|
||||
if (!process.env.CODEX_API_KEY && process.env.OPENAI_API_KEY) {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
import { betterAuth } from "better-auth";
|
||||
import { createAdapterFactory } from "better-auth/adapters";
|
||||
import { APP_SHELL_ORGANIZATION_ID } from "../actors/organization/app-shell.js";
|
||||
import { authUserKey, organizationKey } from "../actors/keys.js";
|
||||
import { APP_SHELL_ORGANIZATION_ID } from "../actors/organization/constants.js";
|
||||
// organization actions are called directly (no queue)
|
||||
// user actor actions are called directly (no queue)
|
||||
import { organizationKey, userKey } from "../actors/keys.js";
|
||||
import { logger } from "../logging.js";
|
||||
// expectQueueResponse removed — actions return values directly
|
||||
|
||||
const AUTH_BASE_PATH = "/v1/auth";
|
||||
const SESSION_COOKIE = "better-auth.session_token";
|
||||
|
|
@ -59,6 +62,8 @@ function resolveRouteUserId(organization: any, resolved: any): string | null {
|
|||
return null;
|
||||
}
|
||||
|
||||
// sendOrganizationCommand removed — org actions are called directly
|
||||
|
||||
export interface BetterAuthService {
|
||||
auth: any;
|
||||
resolveSession(headers: Headers): Promise<{ session: any; user: any } | null>;
|
||||
|
|
@ -75,7 +80,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
|
|||
}
|
||||
|
||||
// getOrCreate is intentional here: the adapter runs during Better Auth callbacks
|
||||
// which can fire before any explicit create path. The app organization and auth user
|
||||
// which can fire before any explicit create path. The app organization and user
|
||||
// actors must exist by the time the adapter needs them.
|
||||
const appOrganization = () =>
|
||||
actorClient.organization.getOrCreate(organizationKey(APP_SHELL_ORGANIZATION_ID), {
|
||||
|
|
@ -83,9 +88,9 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
|
|||
});
|
||||
|
||||
// getOrCreate is intentional: Better Auth creates user records during OAuth
|
||||
// callbacks, so the auth-user actor must be lazily provisioned on first access.
|
||||
const getAuthUser = async (userId: string) =>
|
||||
await actorClient.authUser.getOrCreate(authUserKey(userId), {
|
||||
// callbacks, so the user actor must be lazily provisioned on first access.
|
||||
const getUser = async (userId: string) =>
|
||||
await actorClient.user.getOrCreate(userKey(userId), {
|
||||
createWithInput: { userId },
|
||||
});
|
||||
|
||||
|
|
@ -110,7 +115,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
|
|||
const email = direct("email");
|
||||
if (typeof email === "string" && email.length > 0) {
|
||||
const organization = await appOrganization();
|
||||
const resolved = await organization.authFindEmailIndex({ email: email.toLowerCase() });
|
||||
const resolved = await organization.betterAuthFindEmailIndex({ email: email.toLowerCase() });
|
||||
return resolveRouteUserId(organization, resolved);
|
||||
}
|
||||
return null;
|
||||
|
|
@ -125,7 +130,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
|
|||
const sessionToken = direct("token") ?? data?.token;
|
||||
if (typeof sessionId === "string" || typeof sessionToken === "string") {
|
||||
const organization = await appOrganization();
|
||||
const resolved = await organization.authFindSessionIndex({
|
||||
const resolved = await organization.betterAuthFindSessionIndex({
|
||||
...(typeof sessionId === "string" ? { sessionId } : {}),
|
||||
...(typeof sessionToken === "string" ? { sessionToken } : {}),
|
||||
});
|
||||
|
|
@ -144,11 +149,11 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
|
|||
const accountId = direct("accountId") ?? data?.accountId;
|
||||
const organization = await appOrganization();
|
||||
if (typeof accountRecordId === "string" && accountRecordId.length > 0) {
|
||||
const resolved = await organization.authFindAccountIndex({ id: accountRecordId });
|
||||
const resolved = await organization.betterAuthFindAccountIndex({ id: accountRecordId });
|
||||
return resolveRouteUserId(organization, resolved);
|
||||
}
|
||||
if (typeof providerId === "string" && providerId.length > 0 && typeof accountId === "string" && accountId.length > 0) {
|
||||
const resolved = await organization.authFindAccountIndex({ providerId, accountId });
|
||||
const resolved = await organization.betterAuthFindAccountIndex({ providerId, accountId });
|
||||
return resolveRouteUserId(organization, resolved);
|
||||
}
|
||||
return null;
|
||||
|
|
@ -157,9 +162,9 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
|
|||
return null;
|
||||
};
|
||||
|
||||
const ensureOrganizationVerification = async (method: string, payload: Record<string, unknown>) => {
|
||||
const ensureOrganizationVerification = async (actionName: string, payload: Record<string, unknown>) => {
|
||||
const organization = await appOrganization();
|
||||
return await organization[method](payload);
|
||||
return await (organization as any)[actionName](payload);
|
||||
};
|
||||
|
||||
return {
|
||||
|
|
@ -170,7 +175,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
|
|||
create: async ({ model, data }) => {
|
||||
const transformed = await transformInput(data, model, "create", true);
|
||||
if (model === "verification") {
|
||||
return await ensureOrganizationVerification("authCreateVerification", { data: transformed });
|
||||
return await ensureOrganizationVerification("commandBetterAuthVerificationCreate", { data: transformed });
|
||||
}
|
||||
|
||||
const userId = await resolveUserIdForQuery(model, undefined, transformed);
|
||||
|
|
@ -178,19 +183,19 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
|
|||
throw new Error(`Unable to resolve auth actor for create(${model})`);
|
||||
}
|
||||
|
||||
const userActor = await getAuthUser(userId);
|
||||
const created = await userActor.createAuthRecord({ model, data: transformed });
|
||||
const userActor = await getUser(userId);
|
||||
const created = await userActor.authCreate({ model, data: transformed });
|
||||
const organization = await appOrganization();
|
||||
|
||||
if (model === "user" && typeof transformed.email === "string" && transformed.email.length > 0) {
|
||||
await organization.authUpsertEmailIndex({
|
||||
await organization.commandBetterAuthEmailIndexUpsert({
|
||||
email: transformed.email.toLowerCase(),
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
if (model === "session") {
|
||||
await organization.authUpsertSessionIndex({
|
||||
await organization.commandBetterAuthSessionIndexUpsert({
|
||||
sessionId: String(created.id),
|
||||
sessionToken: String(created.token),
|
||||
userId,
|
||||
|
|
@ -198,7 +203,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
|
|||
}
|
||||
|
||||
if (model === "account") {
|
||||
await organization.authUpsertAccountIndex({
|
||||
await organization.commandBetterAuthAccountIndexUpsert({
|
||||
id: String(created.id),
|
||||
providerId: String(created.providerId),
|
||||
accountId: String(created.accountId),
|
||||
|
|
@ -212,7 +217,8 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
|
|||
findOne: async ({ model, where, join }) => {
|
||||
const transformedWhere = transformWhereClause({ model, where, action: "findOne" });
|
||||
if (model === "verification") {
|
||||
return await ensureOrganizationVerification("authFindOneVerification", { where: transformedWhere, join });
|
||||
const organization = await appOrganization();
|
||||
return await organization.betterAuthFindOneVerification({ where: transformedWhere, join });
|
||||
}
|
||||
|
||||
const userId = await resolveUserIdForQuery(model, transformedWhere);
|
||||
|
|
@ -220,15 +226,16 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
|
|||
return null;
|
||||
}
|
||||
|
||||
const userActor = await getAuthUser(userId);
|
||||
const found = await userActor.findOneAuthRecord({ model, where: transformedWhere, join });
|
||||
const userActor = await getUser(userId);
|
||||
const found = await userActor.betterAuthFindOneRecord({ model, where: transformedWhere, join });
|
||||
return found ? ((await transformOutput(found, model, undefined, join)) as any) : null;
|
||||
},
|
||||
|
||||
findMany: async ({ model, where, limit, sortBy, offset, join }) => {
|
||||
const transformedWhere = transformWhereClause({ model, where, action: "findMany" });
|
||||
if (model === "verification") {
|
||||
return await ensureOrganizationVerification("authFindManyVerification", {
|
||||
const organization = await appOrganization();
|
||||
return await organization.betterAuthFindManyVerification({
|
||||
where: transformedWhere,
|
||||
limit,
|
||||
sortBy,
|
||||
|
|
@ -244,7 +251,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
|
|||
const resolved = await Promise.all(
|
||||
(tokenClause.value as string[]).map(async (sessionToken: string) => ({
|
||||
sessionToken,
|
||||
route: await organization.authFindSessionIndex({ sessionToken }),
|
||||
route: await organization.betterAuthFindSessionIndex({ sessionToken }),
|
||||
})),
|
||||
);
|
||||
const byUser = new Map<string, string[]>();
|
||||
|
|
@ -259,11 +266,11 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
|
|||
|
||||
const rows = [];
|
||||
for (const [userId, tokens] of byUser) {
|
||||
const userActor = await getAuthUser(userId);
|
||||
const userActor = await getUser(userId);
|
||||
const scopedWhere = transformedWhere.map((entry: any) =>
|
||||
entry.field === "token" && entry.operator === "in" ? { ...entry, value: tokens } : entry,
|
||||
);
|
||||
const found = await userActor.findManyAuthRecords({ model, where: scopedWhere, limit, sortBy, offset, join });
|
||||
const found = await userActor.betterAuthFindManyRecords({ model, where: scopedWhere, limit, sortBy, offset, join });
|
||||
rows.push(...found);
|
||||
}
|
||||
return await Promise.all(rows.map(async (row: any) => await transformOutput(row, model, undefined, join)));
|
||||
|
|
@ -275,8 +282,8 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
|
|||
return [];
|
||||
}
|
||||
|
||||
const userActor = await getAuthUser(userId);
|
||||
const found = await userActor.findManyAuthRecords({ model, where: transformedWhere, limit, sortBy, offset, join });
|
||||
const userActor = await getUser(userId);
|
||||
const found = await userActor.betterAuthFindManyRecords({ model, where: transformedWhere, limit, sortBy, offset, join });
|
||||
return await Promise.all(found.map(async (row: any) => await transformOutput(row, model, undefined, join)));
|
||||
},
|
||||
|
||||
|
|
@ -284,7 +291,10 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
|
|||
const transformedWhere = transformWhereClause({ model, where, action: "update" });
|
||||
const transformedUpdate = (await transformInput(update as Record<string, unknown>, model, "update", true)) as Record<string, unknown>;
|
||||
if (model === "verification") {
|
||||
return await ensureOrganizationVerification("authUpdateVerification", { where: transformedWhere, update: transformedUpdate });
|
||||
return await ensureOrganizationVerification("commandBetterAuthVerificationUpdate", {
|
||||
where: transformedWhere,
|
||||
update: transformedUpdate,
|
||||
});
|
||||
}
|
||||
|
||||
const userId = await resolveUserIdForQuery(model, transformedWhere, transformedUpdate);
|
||||
|
|
@ -292,29 +302,34 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
|
|||
return null;
|
||||
}
|
||||
|
||||
const userActor = await getAuthUser(userId);
|
||||
const userActor = await getUser(userId);
|
||||
const before =
|
||||
model === "user"
|
||||
? await userActor.findOneAuthRecord({ model, where: transformedWhere })
|
||||
? await userActor.betterAuthFindOneRecord({ model, where: transformedWhere })
|
||||
: model === "account"
|
||||
? await userActor.findOneAuthRecord({ model, where: transformedWhere })
|
||||
? await userActor.betterAuthFindOneRecord({ model, where: transformedWhere })
|
||||
: model === "session"
|
||||
? await userActor.findOneAuthRecord({ model, where: transformedWhere })
|
||||
? await userActor.betterAuthFindOneRecord({ model, where: transformedWhere })
|
||||
: null;
|
||||
const updated = await userActor.updateAuthRecord({ model, where: transformedWhere, update: transformedUpdate });
|
||||
const updated = await userActor.authUpdate({ model, where: transformedWhere, update: transformedUpdate });
|
||||
const organization = await appOrganization();
|
||||
|
||||
if (model === "user" && updated) {
|
||||
if (before?.email && before.email !== updated.email) {
|
||||
await organization.authDeleteEmailIndex({ email: before.email.toLowerCase() });
|
||||
await organization.commandBetterAuthEmailIndexDelete({
|
||||
email: before.email.toLowerCase(),
|
||||
});
|
||||
}
|
||||
if (updated.email) {
|
||||
await organization.authUpsertEmailIndex({ email: updated.email.toLowerCase(), userId });
|
||||
await organization.commandBetterAuthEmailIndexUpsert({
|
||||
email: updated.email.toLowerCase(),
|
||||
userId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (model === "session" && updated) {
|
||||
await organization.authUpsertSessionIndex({
|
||||
await organization.commandBetterAuthSessionIndexUpsert({
|
||||
sessionId: String(updated.id),
|
||||
sessionToken: String(updated.token),
|
||||
userId,
|
||||
|
|
@ -322,7 +337,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
|
|||
}
|
||||
|
||||
if (model === "account" && updated) {
|
||||
await organization.authUpsertAccountIndex({
|
||||
await organization.commandBetterAuthAccountIndexUpsert({
|
||||
id: String(updated.id),
|
||||
providerId: String(updated.providerId),
|
||||
accountId: String(updated.accountId),
|
||||
|
|
@ -337,7 +352,10 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
|
|||
const transformedWhere = transformWhereClause({ model, where, action: "updateMany" });
|
||||
const transformedUpdate = (await transformInput(update as Record<string, unknown>, model, "update", true)) as Record<string, unknown>;
|
||||
if (model === "verification") {
|
||||
return await ensureOrganizationVerification("authUpdateManyVerification", { where: transformedWhere, update: transformedUpdate });
|
||||
return await ensureOrganizationVerification("commandBetterAuthVerificationUpdateMany", {
|
||||
where: transformedWhere,
|
||||
update: transformedUpdate,
|
||||
});
|
||||
}
|
||||
|
||||
const userId = await resolveUserIdForQuery(model, transformedWhere, transformedUpdate);
|
||||
|
|
@ -345,14 +363,15 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
|
|||
return 0;
|
||||
}
|
||||
|
||||
const userActor = await getAuthUser(userId);
|
||||
return await userActor.updateManyAuthRecords({ model, where: transformedWhere, update: transformedUpdate });
|
||||
const userActor = await getUser(userId);
|
||||
return await userActor.authUpdateMany({ model, where: transformedWhere, update: transformedUpdate });
|
||||
},
|
||||
|
||||
delete: async ({ model, where }) => {
|
||||
const transformedWhere = transformWhereClause({ model, where, action: "delete" });
|
||||
if (model === "verification") {
|
||||
await ensureOrganizationVerification("authDeleteVerification", { where: transformedWhere });
|
||||
const organization = await appOrganization();
|
||||
await organization.commandBetterAuthVerificationDelete({ where: transformedWhere });
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -361,20 +380,20 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
|
|||
return;
|
||||
}
|
||||
|
||||
const userActor = await getAuthUser(userId);
|
||||
const userActor = await getUser(userId);
|
||||
const organization = await appOrganization();
|
||||
const before = await userActor.findOneAuthRecord({ model, where: transformedWhere });
|
||||
await userActor.deleteAuthRecord({ model, where: transformedWhere });
|
||||
const before = await userActor.betterAuthFindOneRecord({ model, where: transformedWhere });
|
||||
await userActor.authDelete({ model, where: transformedWhere });
|
||||
|
||||
if (model === "session" && before) {
|
||||
await organization.authDeleteSessionIndex({
|
||||
await organization.commandBetterAuthSessionIndexDelete({
|
||||
sessionId: before.id,
|
||||
sessionToken: before.token,
|
||||
});
|
||||
}
|
||||
|
||||
if (model === "account" && before) {
|
||||
await organization.authDeleteAccountIndex({
|
||||
await organization.commandBetterAuthAccountIndexDelete({
|
||||
id: before.id,
|
||||
providerId: before.providerId,
|
||||
accountId: before.accountId,
|
||||
|
|
@ -382,14 +401,16 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
|
|||
}
|
||||
|
||||
if (model === "user" && before?.email) {
|
||||
await organization.authDeleteEmailIndex({ email: before.email.toLowerCase() });
|
||||
await organization.commandBetterAuthEmailIndexDelete({
|
||||
email: before.email.toLowerCase(),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
deleteMany: async ({ model, where }) => {
|
||||
const transformedWhere = transformWhereClause({ model, where, action: "deleteMany" });
|
||||
if (model === "verification") {
|
||||
return await ensureOrganizationVerification("authDeleteManyVerification", { where: transformedWhere });
|
||||
return await ensureOrganizationVerification("commandBetterAuthVerificationDeleteMany", { where: transformedWhere });
|
||||
}
|
||||
|
||||
if (model === "session") {
|
||||
|
|
@ -397,12 +418,12 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
|
|||
if (!userId) {
|
||||
return 0;
|
||||
}
|
||||
const userActor = await getAuthUser(userId);
|
||||
const userActor = await getUser(userId);
|
||||
const organization = await appOrganization();
|
||||
const sessions = await userActor.findManyAuthRecords({ model, where: transformedWhere, limit: 5000 });
|
||||
const deleted = await userActor.deleteManyAuthRecords({ model, where: transformedWhere });
|
||||
const sessions = await userActor.betterAuthFindManyRecords({ model, where: transformedWhere, limit: 5000 });
|
||||
const deleted = await userActor.authDeleteMany({ model, where: transformedWhere });
|
||||
for (const session of sessions) {
|
||||
await organization.authDeleteSessionIndex({
|
||||
await organization.commandBetterAuthSessionIndexDelete({
|
||||
sessionId: session.id,
|
||||
sessionToken: session.token,
|
||||
});
|
||||
|
|
@ -415,15 +436,16 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
|
|||
return 0;
|
||||
}
|
||||
|
||||
const userActor = await getAuthUser(userId);
|
||||
const deleted = await userActor.deleteManyAuthRecords({ model, where: transformedWhere });
|
||||
const userActor = await getUser(userId);
|
||||
const deleted = await userActor.authDeleteMany({ model, where: transformedWhere });
|
||||
return deleted;
|
||||
},
|
||||
|
||||
count: async ({ model, where }) => {
|
||||
const transformedWhere = transformWhereClause({ model, where, action: "count" });
|
||||
if (model === "verification") {
|
||||
return await ensureOrganizationVerification("authCountVerification", { where: transformedWhere });
|
||||
const organization = await appOrganization();
|
||||
return await organization.betterAuthCountVerification({ where: transformedWhere });
|
||||
}
|
||||
|
||||
const userId = await resolveUserIdForQuery(model, transformedWhere);
|
||||
|
|
@ -431,8 +453,8 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
|
|||
return 0;
|
||||
}
|
||||
|
||||
const userActor = await getAuthUser(userId);
|
||||
return await userActor.countAuthRecords({ model, where: transformedWhere });
|
||||
const userActor = await getUser(userId);
|
||||
return await userActor.betterAuthCountRecords({ model, where: transformedWhere });
|
||||
},
|
||||
};
|
||||
},
|
||||
|
|
@ -477,17 +499,17 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
|
|||
|
||||
async getAuthState(sessionId: string) {
|
||||
const organization = await appOrganization();
|
||||
const route = await organization.authFindSessionIndex({ sessionId });
|
||||
const route = await organization.betterAuthFindSessionIndex({ sessionId });
|
||||
if (!route?.userId) {
|
||||
return null;
|
||||
}
|
||||
const userActor = await getAuthUser(route.userId);
|
||||
const userActor = await getUser(route.userId);
|
||||
return await userActor.getAppAuthState({ sessionId });
|
||||
},
|
||||
|
||||
async upsertUserProfile(userId: string, patch: Record<string, unknown>) {
|
||||
const userActor = await getAuthUser(userId);
|
||||
return await userActor.upsertUserProfile({ userId, patch });
|
||||
const userActor = await getUser(userId);
|
||||
return await userActor.profileUpsert({ userId, patch });
|
||||
},
|
||||
|
||||
async setActiveOrganization(sessionId: string, activeOrganizationId: string | null) {
|
||||
|
|
@ -495,8 +517,8 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
|
|||
if (!authState?.user?.id) {
|
||||
throw new Error(`Unknown auth session ${sessionId}`);
|
||||
}
|
||||
const userActor = await getAuthUser(authState.user.id);
|
||||
return await userActor.upsertSessionState({ sessionId, activeOrganizationId });
|
||||
const userActor = await getUser(authState.user.id);
|
||||
return await userActor.sessionStateUpsert({ sessionId, activeOrganizationId });
|
||||
},
|
||||
|
||||
async getAccessTokenForSession(sessionId: string) {
|
||||
|
|
|
|||
584
foundry/packages/backend/src/services/branch-name-prefixes.ts
Normal file
584
foundry/packages/backend/src/services/branch-name-prefixes.ts
Normal file
|
|
@ -0,0 +1,584 @@
|
|||
// Auto-generated list of branch name prefixes.
|
||||
// Source: McMaster-Carr product catalog.
|
||||
export const BRANCH_NAME_PREFIXES: readonly string[] = [
|
||||
"abrasive-blasters",
|
||||
"ac-motors",
|
||||
"access-doors",
|
||||
"adjustable-handles",
|
||||
"aerosol-paint",
|
||||
"air-cleaners",
|
||||
"air-cylinders",
|
||||
"air-filters",
|
||||
"air-hose",
|
||||
"air-knives",
|
||||
"air-nozzles",
|
||||
"air-regulators",
|
||||
"air-ride-wheels",
|
||||
"air-slides",
|
||||
"alligator-clips",
|
||||
"alloy-steel",
|
||||
"aluminum-honeycomb",
|
||||
"angle-indicators",
|
||||
"antiseize-lubricants",
|
||||
"antislip-fluid",
|
||||
"backlight-panel-kits",
|
||||
"ball-bearings",
|
||||
"ball-end-mills",
|
||||
"ball-joint-linkages",
|
||||
"ball-transfers",
|
||||
"band-clamps",
|
||||
"band-saw-blades",
|
||||
"bar-clamps",
|
||||
"bar-grating",
|
||||
"barbed-hose-fittings",
|
||||
"barbed-tube-fittings",
|
||||
"basket-strainers",
|
||||
"batch-cans",
|
||||
"battery-chargers",
|
||||
"battery-holders",
|
||||
"bead-chain",
|
||||
"beam-clamps",
|
||||
"belt-conveyors",
|
||||
"bench-scales",
|
||||
"bench-vises",
|
||||
"bin-boxes",
|
||||
"bin-storage",
|
||||
"binding-posts",
|
||||
"blank-tags",
|
||||
"blasting-cabinets",
|
||||
"blind-rivets",
|
||||
"bluetooth-padlocks",
|
||||
"boring-lathe-tools",
|
||||
"box-reducers",
|
||||
"box-wrenches",
|
||||
"braided-hose",
|
||||
"brass-pipe-fittings",
|
||||
"breather-vents",
|
||||
"butt-splices",
|
||||
"c-clamps",
|
||||
"cable-cutters",
|
||||
"cable-holders",
|
||||
"cable-tie-mounts",
|
||||
"cable-ties",
|
||||
"cam-handles",
|
||||
"cam-latches",
|
||||
"cam-locks",
|
||||
"cap-nuts",
|
||||
"captive-panel-screws",
|
||||
"carbide-burs",
|
||||
"carbide-inserts",
|
||||
"carbon-fiber",
|
||||
"carbon-steel",
|
||||
"cardstock-tags",
|
||||
"carriage-bolts",
|
||||
"cast-acrylic",
|
||||
"cast-iron",
|
||||
"cast-nylon",
|
||||
"casting-compounds",
|
||||
"ceiling-lights",
|
||||
"ceramic-adhesives",
|
||||
"chain-slings",
|
||||
"check-valves",
|
||||
"chemical-hose",
|
||||
"chemistry-meters",
|
||||
"chemistry-testing",
|
||||
"chip-clearing-tools",
|
||||
"chucking-reamers",
|
||||
"cinching-straps",
|
||||
"circuit-breakers",
|
||||
"circular-saw-blades",
|
||||
"circular-saws",
|
||||
"clamping-hangers",
|
||||
"clevis-pins",
|
||||
"clevis-rod-ends",
|
||||
"clip-on-nuts",
|
||||
"coaxial-connectors",
|
||||
"coaxial-cords",
|
||||
"coiled-spring-pins",
|
||||
"compact-connectors",
|
||||
"computer-adapters",
|
||||
"concrete-adhesives",
|
||||
"concrete-repair",
|
||||
"contour-transfers",
|
||||
"conveyor-belt-lacing",
|
||||
"conveyor-belting",
|
||||
"conveyor-brushes",
|
||||
"conveyor-rollers",
|
||||
"coolant-hose",
|
||||
"copper-tube-fittings",
|
||||
"copper-tubing",
|
||||
"cord-grips",
|
||||
"cord-reels",
|
||||
"cotter-pins",
|
||||
"coupling-nuts",
|
||||
"cpvc-pipe-fittings",
|
||||
"cup-brushes",
|
||||
"cutoff-wheels",
|
||||
"cylinder-hones",
|
||||
"cylinder-racks",
|
||||
"cylinder-trucks",
|
||||
"data-cable",
|
||||
"data-connectors",
|
||||
"dc-motors",
|
||||
"dead-blow-hammers",
|
||||
"delrin-acetal-resin",
|
||||
"desiccant-air-dryers",
|
||||
"desktop-cranes",
|
||||
"dial-calipers",
|
||||
"dial-indicators",
|
||||
"die-springs",
|
||||
"direct-heaters",
|
||||
"disconnect-switches",
|
||||
"dispensing-needles",
|
||||
"dispensing-pumps",
|
||||
"disposable-clothing",
|
||||
"disposable-gloves",
|
||||
"document-protectors",
|
||||
"door-closers",
|
||||
"door-handles",
|
||||
"door-holders",
|
||||
"dowel-pins",
|
||||
"drafting-equipment",
|
||||
"drain-cleaners",
|
||||
"drainage-mats",
|
||||
"draw-latches",
|
||||
"drawer-cabinets",
|
||||
"drawer-slides",
|
||||
"drill-bit-sets",
|
||||
"drill-bits",
|
||||
"drill-bushings",
|
||||
"drill-chucks",
|
||||
"drill-presses",
|
||||
"drilling-screws",
|
||||
"drinking-fountains",
|
||||
"drive-anchors",
|
||||
"drive-rollers",
|
||||
"drive-shafts",
|
||||
"drum-faucets",
|
||||
"drum-pumps",
|
||||
"drum-top-vacuums",
|
||||
"drum-trucks",
|
||||
"dry-box-gloves",
|
||||
"dry-erase-boards",
|
||||
"dry-film-lubricants",
|
||||
"duct-fans",
|
||||
"duct-hose",
|
||||
"duct-tape",
|
||||
"dust-collectors",
|
||||
"dustless-chalk",
|
||||
"edge-trim",
|
||||
"electric-actuators",
|
||||
"electric-drills",
|
||||
"electric-drum-pumps",
|
||||
"electric-mixers",
|
||||
"electrical-switches",
|
||||
"electrical-tape",
|
||||
"electronic-calipers",
|
||||
"enclosure-heaters",
|
||||
"enclosure-panels",
|
||||
"ethernet-cords",
|
||||
"exhaust-fans",
|
||||
"exit-lights",
|
||||
"expansion-joints",
|
||||
"expansion-plugs",
|
||||
"extension-cords",
|
||||
"extension-springs",
|
||||
"fabric-snaps",
|
||||
"fan-blades",
|
||||
"fep-tubing",
|
||||
"fiberglass-grating",
|
||||
"file-holders",
|
||||
"filter-bag-housings",
|
||||
"filter-bags",
|
||||
"filter-cartridges",
|
||||
"fire-fighting-hose",
|
||||
"first-aid-supplies",
|
||||
"fixture-clamps",
|
||||
"flange-locknuts",
|
||||
"flange-mount-seals",
|
||||
"flap-sanding-discs",
|
||||
"flap-sanding-wheels",
|
||||
"flared-tube-fittings",
|
||||
"flashing-lights",
|
||||
"flat-washers",
|
||||
"flexible-shafts",
|
||||
"flexible-shank-burs",
|
||||
"flexible-trays",
|
||||
"float-valves",
|
||||
"floor-locks",
|
||||
"floor-marking-tape",
|
||||
"floor-scales",
|
||||
"floor-squeegees",
|
||||
"flow-sights",
|
||||
"flow-switches",
|
||||
"flowmeter-totalizers",
|
||||
"foot-switches",
|
||||
"force-gauges",
|
||||
"fume-exhausters",
|
||||
"garbage-bags",
|
||||
"garden-hose",
|
||||
"gas-hose",
|
||||
"gas-regulators",
|
||||
"gas-springs",
|
||||
"gauge-blocks",
|
||||
"glass-sights",
|
||||
"gold-wire",
|
||||
"grab-latches",
|
||||
"grease-fittings",
|
||||
"grinding-bits",
|
||||
"grinding-wheels",
|
||||
"hand-brushes",
|
||||
"hand-chain-hoists",
|
||||
"hand-reamers",
|
||||
"hand-trucks",
|
||||
"hand-wheels",
|
||||
"hand-winches",
|
||||
"hanging-scales",
|
||||
"hard-hats",
|
||||
"hardened-shafts",
|
||||
"hardness-testers",
|
||||
"heat-exchangers",
|
||||
"heat-guns",
|
||||
"heat-lamps",
|
||||
"heat-sealable-bags",
|
||||
"heat-set-inserts",
|
||||
"heat-shrink-tubing",
|
||||
"heat-sinks",
|
||||
"heated-scrapers",
|
||||
"helical-inserts",
|
||||
"hex-bit-sockets",
|
||||
"hex-head-screws",
|
||||
"hex-nuts",
|
||||
"high-accuracy-rulers",
|
||||
"high-amp-relays",
|
||||
"high-vacuum-filters",
|
||||
"high-vacuum-sights",
|
||||
"hinge-adjusters",
|
||||
"hoist-rings",
|
||||
"hole-saws",
|
||||
"hose-couplings",
|
||||
"hose-reels",
|
||||
"hot-melt-glue",
|
||||
"hydraulic-cylinders",
|
||||
"hydraulic-hose",
|
||||
"hydraulic-jacks",
|
||||
"iec-connectors",
|
||||
"immersion-heaters",
|
||||
"impression-foam",
|
||||
"indicating-lights",
|
||||
"inflatable-wedges",
|
||||
"ink-markers",
|
||||
"insertion-heaters",
|
||||
"inspection-mirrors",
|
||||
"instrument-carts",
|
||||
"insulation-jacketing",
|
||||
"jam-removers",
|
||||
"jigsaw-blades",
|
||||
"key-cabinets",
|
||||
"key-locking-inserts",
|
||||
"key-stock",
|
||||
"keyed-drive-shafts",
|
||||
"keyseat-end-mills",
|
||||
"l-key-sets",
|
||||
"l-keys",
|
||||
"label-holders",
|
||||
"latching-connectors",
|
||||
"lathe-tools",
|
||||
"lavatory-partitions",
|
||||
"lead-screws",
|
||||
"leveling-lasers",
|
||||
"leveling-mounts",
|
||||
"lid-supports",
|
||||
"lift-off-hinges",
|
||||
"lift-trucks",
|
||||
"light-bulbs",
|
||||
"limit-switches",
|
||||
"linear-ball-bearings",
|
||||
"liquid-level-gauges",
|
||||
"lock-washers",
|
||||
"lockout-devices",
|
||||
"loop-clamps",
|
||||
"loop-hangers",
|
||||
"machine-brackets",
|
||||
"machine-handles",
|
||||
"machine-keys",
|
||||
"magnetic-base-drills",
|
||||
"magnetic-bumpers",
|
||||
"masking-tape",
|
||||
"masonry-drill-bits",
|
||||
"medium-amp-relays",
|
||||
"metal-cable-ties",
|
||||
"metal-panels",
|
||||
"metal-plates",
|
||||
"metal-tags",
|
||||
"metering-pumps",
|
||||
"metric-o-rings",
|
||||
"mil-spec-connectors",
|
||||
"mobile-lift-tables",
|
||||
"motor-controls",
|
||||
"motor-starters",
|
||||
"mountable-cable-ties",
|
||||
"mounting-tape",
|
||||
"neoprene-foam",
|
||||
"nickel-titanium",
|
||||
"nonmarring-hammers",
|
||||
"nonslip-bumpers",
|
||||
"nylon-rivets",
|
||||
"nylon-tubing",
|
||||
"o-rings",
|
||||
"oil-level-indicators",
|
||||
"oil-reservoirs",
|
||||
"oil-skimmers",
|
||||
"on-off-valves",
|
||||
"open-end-wrenches",
|
||||
"outlet-boxes",
|
||||
"outlet-strips",
|
||||
"packaging-tape",
|
||||
"paint-brushes",
|
||||
"paint-markers",
|
||||
"paint-sprayers",
|
||||
"pallet-racks",
|
||||
"pallet-trucks",
|
||||
"panel-air-filters",
|
||||
"parts-baskets",
|
||||
"pendant-switches",
|
||||
"perforated-sheets",
|
||||
"pest-control",
|
||||
"petroleum-hose",
|
||||
"piano-hinges",
|
||||
"pipe-couplings",
|
||||
"pipe-gaskets",
|
||||
"pipe-markers",
|
||||
"pipe-wrenches",
|
||||
"plank-grating",
|
||||
"plastic-clamps",
|
||||
"plastic-mesh",
|
||||
"plate-lifting-clamps",
|
||||
"platinum-wire",
|
||||
"plier-clamps",
|
||||
"plug-gauges",
|
||||
"portable-lights",
|
||||
"power-cords",
|
||||
"power-supplied",
|
||||
"power-supplies",
|
||||
"precision-knives",
|
||||
"press-fit-nuts",
|
||||
"press-in-nuts",
|
||||
"protecting-tape",
|
||||
"protective-coatings",
|
||||
"protective-curtains",
|
||||
"protective-panels",
|
||||
"protective-wrap",
|
||||
"proximity-switches",
|
||||
"pull-handles",
|
||||
"push-brooms",
|
||||
"push-nuts",
|
||||
"push-on-seals",
|
||||
"pvc-pipe-fittings",
|
||||
"pvc-tubing",
|
||||
"quick-release-pins",
|
||||
"ratchet-pullers",
|
||||
"recycled-plastics",
|
||||
"repair-adhesives",
|
||||
"repair-clamps",
|
||||
"reusable-cable-ties",
|
||||
"ring-terminals",
|
||||
"rivet-nuts",
|
||||
"robot-base-mounts",
|
||||
"robot-bases",
|
||||
"rocker-switches",
|
||||
"rod-wipers",
|
||||
"roller-bearings",
|
||||
"roller-chain",
|
||||
"roller-conveyors",
|
||||
"roof-exhaust-fans",
|
||||
"roof-repair",
|
||||
"rotary-broaches",
|
||||
"rotary-hammers",
|
||||
"rotary-shaft-seals",
|
||||
"rotating-cranes",
|
||||
"rotating-joints",
|
||||
"router-bits",
|
||||
"rtd-probes",
|
||||
"rubber-edge-seals",
|
||||
"rubber-tread-wheels",
|
||||
"rubber-tubing",
|
||||
"safety-cabinets",
|
||||
"safety-glasses",
|
||||
"safety-mirrors",
|
||||
"sanding-belts",
|
||||
"sanding-discs",
|
||||
"sanding-guides",
|
||||
"sanding-rolls",
|
||||
"sanding-sheets",
|
||||
"screw-extractors",
|
||||
"screw-jacks",
|
||||
"scrub-brushes",
|
||||
"sealing-washers",
|
||||
"security-lights",
|
||||
"sensor-connectors",
|
||||
"set-screws",
|
||||
"setup-clamps",
|
||||
"shaft-collars",
|
||||
"shaft-couplings",
|
||||
"shaft-repair-sleeves",
|
||||
"shaft-supports",
|
||||
"sharpening-stones",
|
||||
"sheet-metal-cutters",
|
||||
"shelf-cabinets",
|
||||
"shim-stock",
|
||||
"shim-tape",
|
||||
"shipping-pails",
|
||||
"shock-absorbers",
|
||||
"shoulder-screws",
|
||||
"shower-stations",
|
||||
"silicone-foam",
|
||||
"sleeve-bearings",
|
||||
"slide-bolts",
|
||||
"slitting-saws",
|
||||
"slotted-spring-pins",
|
||||
"sludge-samplers",
|
||||
"small-parts-storage",
|
||||
"snap-acting-switches",
|
||||
"soap-dispensers",
|
||||
"socket-head-screws",
|
||||
"socket-organizers",
|
||||
"socket-wrenches",
|
||||
"soldering-irons",
|
||||
"solid-rivets",
|
||||
"solid-rod-ends",
|
||||
"sound-insulation",
|
||||
"space-heaters",
|
||||
"spacing-beads",
|
||||
"spanner-wrenches",
|
||||
"specialty-pliers",
|
||||
"specialty-vises",
|
||||
"specialty-washers",
|
||||
"speed-reducers",
|
||||
"splicing-connectors",
|
||||
"spray-bottles",
|
||||
"spray-nozzles",
|
||||
"spring-clamps",
|
||||
"spring-plungers",
|
||||
"spring-steel",
|
||||
"square-drive-sockets",
|
||||
"square-end-mills",
|
||||
"square-nuts",
|
||||
"squeeze-bottles",
|
||||
"stack-lights",
|
||||
"stainless-steel",
|
||||
"stair-treads",
|
||||
"static-control-mats",
|
||||
"steel-carts",
|
||||
"steel-pipe-fittings",
|
||||
"steel-pipe-flanges",
|
||||
"steel-stamps",
|
||||
"steel-tubing",
|
||||
"step-ladders",
|
||||
"stepper-motors",
|
||||
"storage-bags",
|
||||
"storage-boxes",
|
||||
"storage-chests",
|
||||
"straight-ladders",
|
||||
"strap-hinges",
|
||||
"stretch-wrap",
|
||||
"strip-doors",
|
||||
"strip-springs",
|
||||
"strobe-lights",
|
||||
"structural-adhesives",
|
||||
"strut-channel",
|
||||
"strut-channel-nuts",
|
||||
"strut-mount-clamps",
|
||||
"suction-cup-lifters",
|
||||
"suction-strainers",
|
||||
"super-absorbent-foam",
|
||||
"super-flexible-glass",
|
||||
"surface-fillers",
|
||||
"surface-mount-hinges",
|
||||
"t-handle-keys",
|
||||
"t-slotted-framing",
|
||||
"tamper-seals",
|
||||
"tank-level-measurers",
|
||||
"tape-dispensers",
|
||||
"tape-measures",
|
||||
"taper-pins",
|
||||
"tapping-screws",
|
||||
"teflon-ptfe",
|
||||
"terminal-blocks",
|
||||
"test-indicators",
|
||||
"test-leads",
|
||||
"test-weights",
|
||||
"tethered-knobs",
|
||||
"thermal-insulation",
|
||||
"thread-adapters",
|
||||
"thread-sealant-tape",
|
||||
"thread-sealants",
|
||||
"threaded-inserts",
|
||||
"threaded-standoffs",
|
||||
"threaded-studs",
|
||||
"thrust-ball-bearings",
|
||||
"thrust-bearings",
|
||||
"thumb-nuts",
|
||||
"thumb-screws",
|
||||
"tie-down-rings",
|
||||
"time-clocks",
|
||||
"timer-relays",
|
||||
"timer-switches",
|
||||
"toggle-clamps",
|
||||
"toggle-switches",
|
||||
"tool-holders",
|
||||
"tool-sets",
|
||||
"tool-steel",
|
||||
"torque-wrenches",
|
||||
"torsion-springs",
|
||||
"tote-boxes",
|
||||
"touch-bars",
|
||||
"track-casters",
|
||||
"track-rollers",
|
||||
"track-wheels",
|
||||
"traction-mats",
|
||||
"trolley-systems",
|
||||
"tube-brushes",
|
||||
"tube-fittings",
|
||||
"tubular-light-bulbs",
|
||||
"turn-lock-connectors",
|
||||
"twist-ties",
|
||||
"u-bolts",
|
||||
"u-joints",
|
||||
"ul-class-fuses",
|
||||
"unthreaded-spacers",
|
||||
"usb-adapters",
|
||||
"usb-cords",
|
||||
"utility-knives",
|
||||
"v-belts",
|
||||
"vacuum-cups",
|
||||
"vacuum-pumps",
|
||||
"wall-louvers",
|
||||
"wash-fountains",
|
||||
"wash-guns",
|
||||
"waste-containers",
|
||||
"water-deionizers",
|
||||
"water-filters",
|
||||
"water-hose",
|
||||
"water-removal-pumps",
|
||||
"weather-stations",
|
||||
"web-slings",
|
||||
"weld-nuts",
|
||||
"welding-clothing",
|
||||
"welding-helmets",
|
||||
"wet-dry-vacuums",
|
||||
"wet-mops",
|
||||
"wheel-brushes",
|
||||
"wing-nuts",
|
||||
"wire-cloth",
|
||||
"wire-connectors",
|
||||
"wire-cutting-pliers",
|
||||
"wire-partitions",
|
||||
"wire-rope",
|
||||
"wire-rope-clamps",
|
||||
"wire-wrap",
|
||||
"wool-felt",
|
||||
"work-platforms",
|
||||
"workbench-legs",
|
||||
"woven-wire-cloth",
|
||||
] as const;
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import { BRANCH_NAME_PREFIXES } from "./branch-name-prefixes.js";
|
||||
|
||||
export interface ResolveCreateFlowDecisionInput {
|
||||
task: string;
|
||||
explicitTitle?: string;
|
||||
|
|
@ -89,30 +91,42 @@ export function sanitizeBranchName(input: string): string {
|
|||
return trimmed.slice(0, 50).replace(/-+$/g, "");
|
||||
}
|
||||
|
||||
function generateRandomSuffix(length: number): string {
|
||||
const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
let result = "";
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars[Math.floor(Math.random() * chars.length)];
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function generateBranchName(): string {
|
||||
const prefix = BRANCH_NAME_PREFIXES[Math.floor(Math.random() * BRANCH_NAME_PREFIXES.length)]!;
|
||||
const suffix = generateRandomSuffix(4);
|
||||
return `${prefix}-${suffix}`;
|
||||
}
|
||||
|
||||
export function resolveCreateFlowDecision(input: ResolveCreateFlowDecisionInput): ResolveCreateFlowDecisionResult {
|
||||
const explicitBranch = input.explicitBranchName?.trim();
|
||||
const title = deriveFallbackTitle(input.task, input.explicitTitle);
|
||||
const generatedBase = sanitizeBranchName(title) || "task";
|
||||
|
||||
const branchBase = explicitBranch && explicitBranch.length > 0 ? explicitBranch : generatedBase;
|
||||
|
||||
const existingBranches = new Set(input.localBranches.map((value) => value.trim()).filter((value) => value.length > 0));
|
||||
const existingTaskBranches = new Set(input.taskBranches.map((value) => value.trim()).filter((value) => value.length > 0));
|
||||
const conflicts = (name: string): boolean => existingBranches.has(name) || existingTaskBranches.has(name);
|
||||
|
||||
if (explicitBranch && conflicts(branchBase)) {
|
||||
throw new Error(`Branch '${branchBase}' already exists. Choose a different --name/--branch value.`);
|
||||
if (explicitBranch && explicitBranch.length > 0) {
|
||||
if (conflicts(explicitBranch)) {
|
||||
throw new Error(`Branch '${explicitBranch}' already exists. Choose a different --name/--branch value.`);
|
||||
}
|
||||
return { title, branchName: explicitBranch };
|
||||
}
|
||||
|
||||
if (explicitBranch) {
|
||||
return { title, branchName: branchBase };
|
||||
}
|
||||
|
||||
let candidate = branchBase;
|
||||
let index = 2;
|
||||
while (conflicts(candidate)) {
|
||||
candidate = `${branchBase}-${index}`;
|
||||
index += 1;
|
||||
// Generate a random McMaster-Carr-style branch name, retrying on conflicts
|
||||
let candidate = generateBranchName();
|
||||
let attempts = 0;
|
||||
while (conflicts(candidate) && attempts < 100) {
|
||||
candidate = generateBranchName();
|
||||
attempts += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { getOrCreateOrganization } from "../actors/handles.js";
|
||||
import { APP_SHELL_ORGANIZATION_ID } from "../actors/organization/app-shell.js";
|
||||
import { APP_SHELL_ORGANIZATION_ID } from "../actors/organization/constants.js";
|
||||
|
||||
export interface ResolvedGithubAuth {
|
||||
githubToken: string;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue