Fix Foundry UI bugs: org names, sessions, and repo selection (#250)

* Fix Foundry auth: migrate to Better Auth adapter, fix access token retrieval

- Remove @ts-nocheck from better-auth.ts, auth-user/index.ts, app-shell.ts
  and fix all type errors
- Fix getAccessTokenForSession: read GitHub token directly from account
  record instead of calling Better Auth's internal /get-access-token
  endpoint which returns 403 on server-side calls
- Re-implement workspaceAuth helper functions (workspaceAuthColumn,
  normalizeAuthValue, workspaceAuthClause, workspaceAuthWhere) that were
  accidentally deleted
- Remove all retry logic (withRetries, isRetryableAppActorError)
- Implement CORS origin allowlist from configured environment
- Document cachedAppWorkspace singleton pattern
- Add inline org sync fallback in buildAppSnapshot for post-OAuth flow
- Add no-retry rule to CLAUDE.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add Foundry dev panel from fix-git-data branch

Port the dev panel component that was left out when PR #243 was replaced
by PR #247. Adapted to remove runtime/mock-debug references that don't
exist on the current branch.

- Toggle with Shift+D, persists visibility to localStorage
- Shows context, session, GitHub sync status sections
- Dev-only (import.meta.env.DEV)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add full Docker image defaults, fix actor deadlocks, and improve dev experience

- Add Dockerfile.full and --all flag to install-agent CLI for pre-built images
- Centralize Docker image constant (FULL_IMAGE) pinned to 0.3.1-full
- Remove examples/shared/Dockerfile{,.dev} and daytona snapshot example
- Expand Docker docs with full runnable Dockerfile
- Fix self-deadlock in createWorkbenchSession (fire-and-forget provisioning)
- Audit and convert 12 task actions from wait:true to wait:false
- Add bun --hot for dev backend hot reload
- Remove --force from pnpm install in dev Dockerfile for faster startup
- Add env_file support to compose.dev.yaml for automatic credential loading
- Add mock frontend compose config and dev panel
- Update CLAUDE.md with wait:true policy and dev environment setup

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* WIP: async action fixes and interest manager

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Fix Foundry UI bugs: org names, hanging sessions, and wrong repo creation

- Fix org display name using GitHub description instead of name field
- Fix createWorkbenchSession hanging when sandbox is provisioning
- Fix auto-session creation retry storm on errors
- Fix task creation using wrong repo due to React state race conditions
- Remove Bun hot-reload from backend Dockerfile (causes port drift)
- Add GitHub sync/install status to dev panel

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Nathan Flurry 2026-03-13 20:48:22 -07:00 committed by GitHub
parent 58c54156f1
commit d8b8b49f37
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
88 changed files with 9252 additions and 1933 deletions

View file

@ -10,7 +10,7 @@ import { foundryRepoClonePath } from "../../services/foundry-paths.js";
import { resolveWorkspaceGithubAuth } from "../../services/github-auth.js";
import { expectQueueResponse } from "../../services/queue.js";
import { withRepoGitLock } from "../../services/repo-git-lock.js";
import { branches, taskIndex, prCache, repoMeta } from "./db/schema.js";
import { branches, taskIndex, prCache, repoActionJobs, repoMeta } from "./db/schema.js";
import { deriveFallbackTitle } from "../../services/create-flow.js";
import { normalizeBaseBranchName } from "../../integrations/git-spice/index.js";
import { sortBranchesForOverview } from "./stack-model.js";
@ -87,6 +87,7 @@ interface BranchSyncResult {
interface RepoOverviewCommand {}
interface RunRepoStackActionCommand {
jobId?: string;
action: RepoStackAction;
branchName?: string;
parentBranch?: string;
@ -133,6 +134,90 @@ async function ensureProjectSyncActors(c: any, localPath: string): Promise<void>
c.state.syncActorsStarted = true;
}
async function ensureRepoActionJobsTable(c: any): Promise<void> {
await c.db.execute(`
CREATE TABLE IF NOT EXISTS repo_action_jobs (
job_id text PRIMARY KEY NOT NULL,
action text NOT NULL,
branch_name text,
parent_branch text,
status text NOT NULL,
message text NOT NULL,
created_at integer NOT NULL,
updated_at integer NOT NULL,
completed_at integer
)
`);
}
async function writeRepoActionJob(
c: any,
input: {
jobId: string;
action: RepoStackAction;
branchName: string | null;
parentBranch: string | null;
status: "queued" | "running" | "completed" | "error";
message: string;
createdAt?: number;
completedAt?: number | null;
},
): Promise<void> {
await ensureRepoActionJobsTable(c);
const now = Date.now();
await c.db
.insert(repoActionJobs)
.values({
jobId: input.jobId,
action: input.action,
branchName: input.branchName,
parentBranch: input.parentBranch,
status: input.status,
message: input.message,
createdAt: input.createdAt ?? now,
updatedAt: now,
completedAt: input.completedAt ?? null,
})
.onConflictDoUpdate({
target: repoActionJobs.jobId,
set: {
status: input.status,
message: input.message,
updatedAt: now,
completedAt: input.completedAt ?? null,
},
})
.run();
}
async function listRepoActionJobRows(c: any): Promise<
Array<{
jobId: string;
action: RepoStackAction;
branchName: string | null;
parentBranch: string | null;
status: "queued" | "running" | "completed" | "error";
message: string;
createdAt: number;
updatedAt: number;
completedAt: number | null;
}>
> {
await ensureRepoActionJobsTable(c);
const rows = await c.db.select().from(repoActionJobs).orderBy(desc(repoActionJobs.updatedAt)).limit(20).all();
return rows.map((row: any) => ({
jobId: row.jobId,
action: row.action,
branchName: row.branchName ?? null,
parentBranch: row.parentBranch ?? null,
status: row.status,
message: row.message,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
completedAt: row.completedAt ?? null,
}));
}
async function deleteStaleTaskIndexRow(c: any, taskId: string): Promise<void> {
try {
await c.db.delete(taskIndex).where(eq(taskIndex.taskId, taskId)).run();
@ -359,8 +444,6 @@ async function createTaskMutation(c: any, cmd: CreateTaskCommand): Promise<TaskR
const taskId = randomUUID();
if (onBranch) {
await forceProjectSync(c, localPath);
const branchRow = await c.db.select({ branchName: branches.branchName }).from(branches).where(eq(branches.branchName, onBranch)).get();
if (!branchRow) {
throw new Error(`Branch not found in repo snapshot: ${onBranch}`);
@ -573,14 +656,37 @@ async function runRepoStackActionMutation(c: any, cmd: RunRepoStackActionCommand
const { driver } = getActorRuntimeContext();
const at = Date.now();
const jobId = cmd.jobId ?? randomUUID();
const action = cmd.action;
const branchName = cmd.branchName?.trim() || null;
const parentBranch = cmd.parentBranch?.trim() || null;
await writeRepoActionJob(c, {
jobId,
action,
branchName,
parentBranch,
status: "running",
message: `Running ${action}`,
createdAt: at,
});
if (!(await driver.stack.available(localPath).catch(() => false))) {
await writeRepoActionJob(c, {
jobId,
action,
branchName,
parentBranch,
status: "error",
message: "git-spice is not available for this repo",
createdAt: at,
completedAt: Date.now(),
});
return {
jobId,
action,
executed: false,
status: "error",
message: "git-spice is not available for this repo",
at,
};
@ -615,48 +721,77 @@ async function runRepoStackActionMutation(c: any, cmd: RunRepoStackActionCommand
}
}
await withRepoGitLock(localPath, async () => {
if (action === "sync_repo") {
await driver.stack.syncRepo(localPath);
} else if (action === "restack_repo") {
await driver.stack.restackRepo(localPath);
} else if (action === "restack_subtree") {
await driver.stack.restackSubtree(localPath, branchName!);
} else if (action === "rebase_branch") {
await driver.stack.rebaseBranch(localPath, branchName!);
} else if (action === "reparent_branch") {
await driver.stack.reparentBranch(localPath, branchName!, parentBranch!);
} else {
throw new Error(`Unsupported repo stack action: ${action}`);
}
});
await forceProjectSync(c, localPath);
try {
const history = await getOrCreateHistory(c, c.state.workspaceId, c.state.repoId);
await history.append({
kind: "repo.stack_action",
branchName: branchName ?? null,
payload: {
action,
await withRepoGitLock(localPath, async () => {
if (action === "sync_repo") {
await driver.stack.syncRepo(localPath);
} else if (action === "restack_repo") {
await driver.stack.restackRepo(localPath);
} else if (action === "restack_subtree") {
await driver.stack.restackSubtree(localPath, branchName!);
} else if (action === "rebase_branch") {
await driver.stack.rebaseBranch(localPath, branchName!);
} else if (action === "reparent_branch") {
await driver.stack.reparentBranch(localPath, branchName!, parentBranch!);
} else {
throw new Error(`Unsupported repo stack action: ${action}`);
}
});
try {
const history = await getOrCreateHistory(c, c.state.workspaceId, c.state.repoId);
await history.append({
kind: "repo.stack_action",
branchName: branchName ?? null,
parentBranch: parentBranch ?? null,
},
payload: {
action,
branchName: branchName ?? null,
parentBranch: parentBranch ?? null,
jobId,
},
});
} catch (error) {
logActorWarning("project", "failed appending repo stack history event", {
workspaceId: c.state.workspaceId,
repoId: c.state.repoId,
action,
error: resolveErrorMessage(error),
});
}
await forceProjectSync(c, localPath);
await writeRepoActionJob(c, {
jobId,
action,
branchName,
parentBranch,
status: "completed",
message: `Completed ${action}`,
createdAt: at,
completedAt: Date.now(),
});
} catch (error) {
logActorWarning("project", "failed appending repo stack history event", {
workspaceId: c.state.workspaceId,
repoId: c.state.repoId,
const message = resolveErrorMessage(error);
await writeRepoActionJob(c, {
jobId,
action,
error: resolveErrorMessage(error),
branchName,
parentBranch,
status: "error",
message,
createdAt: at,
completedAt: Date.now(),
});
throw error;
}
return {
jobId,
action,
executed: true,
message: `stack action executed: ${action}`,
status: "completed",
message: `Completed ${action}`,
at,
};
}
@ -999,7 +1134,6 @@ export const projectActions = {
async getRepoOverview(c: any, _cmd?: RepoOverviewCommand): Promise<RepoOverview> {
const localPath = await ensureProjectReadyForRead(c);
await ensureTaskIndexHydratedForRead(c);
await forceProjectSync(c, localPath);
const { driver } = getActorRuntimeContext();
const now = Date.now();
@ -1118,6 +1252,9 @@ export const projectActions = {
};
});
const latestBranchSync = await c.db.select({ updatedAt: branches.updatedAt }).from(branches).orderBy(desc(branches.updatedAt)).limit(1).get();
const latestPrSync = await c.db.select({ updatedAt: prCache.updatedAt }).from(prCache).orderBy(desc(prCache.updatedAt)).limit(1).get();
return {
workspaceId: c.state.workspaceId,
repoId: c.state.repoId,
@ -1125,6 +1262,11 @@ export const projectActions = {
baseRef,
stackAvailable,
fetchedAt: now,
branchSyncAt: latestBranchSync?.updatedAt ?? null,
prSyncAt: latestPrSync?.updatedAt ?? null,
branchSyncStatus: latestBranchSync ? "synced" : "pending",
prSyncStatus: latestPrSync ? "synced" : "pending",
repoActionJobs: await listRepoActionJobRows(c),
branches: branchRows,
};
},
@ -1156,12 +1298,41 @@ export const projectActions = {
async runRepoStackAction(c: any, cmd: RunRepoStackActionCommand): Promise<RepoStackActionResult> {
const self = selfProject(c);
return expectQueueResponse<RepoStackActionResult>(
await self.send(projectWorkflowQueueName("project.command.runRepoStackAction"), cmd, {
wait: true,
timeout: 12 * 60_000,
}),
const jobId = randomUUID();
const at = Date.now();
const action = cmd.action;
const branchName = cmd.branchName?.trim() || null;
const parentBranch = cmd.parentBranch?.trim() || null;
await writeRepoActionJob(c, {
jobId,
action,
branchName,
parentBranch,
status: "queued",
message: `Queued ${action}`,
createdAt: at,
});
await self.send(
projectWorkflowQueueName("project.command.runRepoStackAction"),
{
...cmd,
jobId,
},
{
wait: false,
},
);
return {
jobId,
action,
executed: true,
status: "queued",
message: `Queued ${action}`,
at,
};
},
async applyPrSyncResult(c: any, body: PrSyncResult): Promise<void> {

View file

@ -42,3 +42,15 @@ export const taskIndex = sqliteTable("task_index", {
createdAt: integer("created_at").notNull(),
updatedAt: integer("updated_at").notNull(),
});
export const repoActionJobs = sqliteTable("repo_action_jobs", {
jobId: text("job_id").notNull().primaryKey(),
action: text("action").notNull(),
branchName: text("branch_name"),
parentBranch: text("parent_branch"),
status: text("status").notNull(),
message: text("message").notNull(),
createdAt: integer("created_at").notNull(),
updatedAt: integer("updated_at").notNull(),
completedAt: integer("completed_at"),
});