mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-17 05:00:20 +00:00
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>
This commit is contained in:
parent
14d5413f8a
commit
689d968397
17 changed files with 2569 additions and 479 deletions
|
|
@ -50,6 +50,7 @@ Use `pnpm` workspaces and Turborepo.
|
||||||
- `compose.dev.yaml` loads `foundry/.env` (optional) for credentials needed by the backend (GitHub OAuth, Stripe, Daytona, API keys, etc.).
|
- `compose.dev.yaml` loads `foundry/.env` (optional) for credentials needed by the backend (GitHub OAuth, Stripe, Daytona, API keys, etc.).
|
||||||
- The canonical source for these credentials is `~/misc/the-foundry.env`. If `foundry/.env` does not exist, copy it: `cp ~/misc/the-foundry.env foundry/.env`
|
- The canonical source for these credentials is `~/misc/the-foundry.env`. If `foundry/.env` does not exist, copy it: `cp ~/misc/the-foundry.env foundry/.env`
|
||||||
- `foundry/.env` is gitignored and must never be committed.
|
- `foundry/.env` is gitignored and must never be committed.
|
||||||
|
- The backend does **not** hot reload. Bun's `--hot` flag causes the server to re-bind on a different port (e.g. 6421 instead of 6420), breaking all client connections while the container still exposes the original port. After backend code changes, restart the backend container: `just foundry-dev-down && just foundry-dev`.
|
||||||
|
|
||||||
## Railway Logs
|
## Railway Logs
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,4 +39,8 @@ ENV SANDBOX_AGENT_BIN="/root/.local/bin/sandbox-agent"
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
CMD ["bash", "-lc", "git config --global --add safe.directory /app >/dev/null 2>&1 || true; pnpm install --frozen-lockfile --filter @sandbox-agent/foundry-backend... && exec bun --hot foundry/packages/backend/src/index.ts start --host 0.0.0.0 --port 7741"]
|
# NOTE: Do NOT use `bun --hot` here. Bun's hot reloading re-initializes the
|
||||||
|
# server on a new port (e.g. 6421 instead of 6420) while the container still
|
||||||
|
# exposes the original port, breaking all client connections. Restart the
|
||||||
|
# backend container instead: `just foundry-dev-down && just foundry-dev`
|
||||||
|
CMD ["bash", "-lc", "git config --global --add safe.directory /app >/dev/null 2>&1 || true; pnpm install --frozen-lockfile --filter @sandbox-agent/foundry-backend... && exec bun foundry/packages/backend/src/index.ts start --host 0.0.0.0 --port 7741"]
|
||||||
|
|
|
||||||
|
|
@ -168,12 +168,16 @@ const commandHandlers: Record<TaskQueueName, WorkflowHandler> = {
|
||||||
},
|
},
|
||||||
|
|
||||||
"task.command.workbench.create_session": async (loopCtx, msg) => {
|
"task.command.workbench.create_session": async (loopCtx, msg) => {
|
||||||
const created = await loopCtx.step({
|
try {
|
||||||
name: "workbench-create-session",
|
const created = await loopCtx.step({
|
||||||
timeout: 30_000,
|
name: "workbench-create-session",
|
||||||
run: async () => createWorkbenchSession(loopCtx, msg.body?.model),
|
timeout: 30_000,
|
||||||
});
|
run: async () => createWorkbenchSession(loopCtx, msg.body?.model),
|
||||||
await msg.complete(created);
|
});
|
||||||
|
await msg.complete(created);
|
||||||
|
} catch (error) {
|
||||||
|
await msg.complete({ error: resolveErrorMessage(error) });
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
"task.command.workbench.ensure_session": async (loopCtx, msg) => {
|
"task.command.workbench.ensure_session": async (loopCtx, msg) => {
|
||||||
|
|
|
||||||
|
|
@ -636,6 +636,12 @@ export async function syncGithubOrganizationRepos(c: any, input: { sessionId: st
|
||||||
installationStatus,
|
installationStatus,
|
||||||
lastSyncLabel: repositories.length > 0 ? "Synced just now" : "No repositories available",
|
lastSyncLabel: repositories.length > 0 ? "Synced just now" : "No repositories available",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Broadcast updated app snapshot so connected clients see the new repos
|
||||||
|
c.broadcast("appUpdated", {
|
||||||
|
type: "appUpdated",
|
||||||
|
snapshot: await buildAppSnapshot(c, input.sessionId),
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const installationStatus =
|
const installationStatus =
|
||||||
error instanceof GitHubAppError && (error.status === 403 || error.status === 404)
|
error instanceof GitHubAppError && (error.status === 403 || error.status === 404)
|
||||||
|
|
@ -645,6 +651,12 @@ export async function syncGithubOrganizationRepos(c: any, input: { sessionId: st
|
||||||
message: error instanceof Error ? error.message : "GitHub import failed",
|
message: error instanceof Error ? error.message : "GitHub import failed",
|
||||||
installationStatus,
|
installationStatus,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Broadcast sync failure so the client updates status
|
||||||
|
c.broadcast("appUpdated", {
|
||||||
|
type: "appUpdated",
|
||||||
|
snapshot: await buildAppSnapshot(c, input.sessionId),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,4 +2,5 @@ import { createFoundryLogger } from "@sandbox-agent/foundry-shared";
|
||||||
|
|
||||||
export const logger = createFoundryLogger({
|
export const logger = createFoundryLogger({
|
||||||
service: "foundry-backend",
|
service: "foundry-backend",
|
||||||
|
format: "logfmt",
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -262,11 +262,11 @@ export class GitHubAppClient {
|
||||||
}
|
}
|
||||||
|
|
||||||
async listOrganizations(accessToken: string): Promise<GitHubOrgIdentity[]> {
|
async listOrganizations(accessToken: string): Promise<GitHubOrgIdentity[]> {
|
||||||
const organizations = await this.paginate<{ id: number; login: string; description?: string | null }>("/user/orgs?per_page=100", accessToken);
|
const organizations = await this.paginate<{ id: number; login: string; name?: string | null }>("/user/orgs?per_page=100", accessToken);
|
||||||
return organizations.map((organization) => ({
|
return organizations.map((organization) => ({
|
||||||
id: String(organization.id),
|
id: String(organization.id),
|
||||||
login: organization.login,
|
login: organization.login,
|
||||||
name: organization.description?.trim() || organization.login,
|
name: organization.name?.trim() || organization.login,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { injectMockLatency } from "./mock/latency.js";
|
import { injectMockLatency } from "./mock/latency.js";
|
||||||
|
import rivetDevFixture from "../../../scripts/data/rivet-dev.json" with { type: "json" };
|
||||||
|
|
||||||
export type MockBillingPlanId = "free" | "team";
|
export type MockBillingPlanId = "free" | "team";
|
||||||
export type MockBillingStatus = "active" | "trialing" | "past_due" | "scheduled_cancel";
|
export type MockBillingStatus = "active" | "trialing" | "past_due" | "scheduled_cancel";
|
||||||
|
|
@ -140,6 +141,69 @@ function syncStatusFromLegacy(value: unknown): MockGithubSyncStatus {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the "rivet" mock organization from real public GitHub data.
|
||||||
|
* Fixture sourced from: scripts/pull-org-data.ts (run against rivet-dev).
|
||||||
|
* Members that don't exist in the public fixture get synthetic entries
|
||||||
|
* so the mock still has realistic owner/admin/member role distribution.
|
||||||
|
*/
|
||||||
|
function buildRivetOrganization(): MockFoundryOrganization {
|
||||||
|
const repos = rivetDevFixture.repos.map((r) => r.fullName);
|
||||||
|
const fixtureMembers: MockFoundryOrganizationMember[] = rivetDevFixture.members.map((m) => ({
|
||||||
|
id: `member-rivet-${m.login.toLowerCase()}`,
|
||||||
|
name: m.login,
|
||||||
|
email: `${m.login.toLowerCase()}@rivet.dev`,
|
||||||
|
role: "member" as const,
|
||||||
|
state: "active" as const,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Ensure we have named owner/admin roles for the mock user personas
|
||||||
|
// that may not appear in the public members list
|
||||||
|
const knownMembers: MockFoundryOrganizationMember[] = [
|
||||||
|
{ id: "member-rivet-jamie", name: "Jamie", email: "jamie@rivet.dev", role: "owner", state: "active" },
|
||||||
|
{ id: "member-rivet-nathan", name: "Nathan", email: "nathan@acme.dev", role: "member", state: "active" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Merge: known members take priority, then fixture members not already covered
|
||||||
|
const knownIds = new Set(knownMembers.map((m) => m.id));
|
||||||
|
const members = [...knownMembers, ...fixtureMembers.filter((m) => !knownIds.has(m.id))];
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: "rivet",
|
||||||
|
workspaceId: "rivet",
|
||||||
|
kind: "organization",
|
||||||
|
settings: {
|
||||||
|
displayName: rivetDevFixture.name ?? rivetDevFixture.login,
|
||||||
|
slug: "rivet",
|
||||||
|
primaryDomain: "rivet.dev",
|
||||||
|
seatAccrualMode: "first_prompt",
|
||||||
|
defaultModel: "o3",
|
||||||
|
autoImportRepos: true,
|
||||||
|
},
|
||||||
|
github: {
|
||||||
|
connectedAccount: rivetDevFixture.login,
|
||||||
|
installationStatus: "connected",
|
||||||
|
syncStatus: "synced",
|
||||||
|
importedRepoCount: repos.length,
|
||||||
|
lastSyncLabel: "Synced just now",
|
||||||
|
lastSyncAt: Date.now() - 60_000,
|
||||||
|
},
|
||||||
|
billing: {
|
||||||
|
planId: "team",
|
||||||
|
status: "trialing",
|
||||||
|
seatsIncluded: 5,
|
||||||
|
trialEndsAt: isoDate(12),
|
||||||
|
renewalAt: isoDate(12),
|
||||||
|
stripeCustomerId: "cus_mock_rivet_team",
|
||||||
|
paymentMethodLabel: "Visa ending in 4242",
|
||||||
|
invoices: [{ id: "inv-rivet-001", label: "Team pilot", issuedAt: "2026-03-04", amountUsd: 0, status: "paid" }],
|
||||||
|
},
|
||||||
|
members,
|
||||||
|
seatAssignments: ["jamie@rivet.dev"],
|
||||||
|
repoCatalog: repos,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function buildDefaultSnapshot(): MockFoundryAppSnapshot {
|
function buildDefaultSnapshot(): MockFoundryAppSnapshot {
|
||||||
return {
|
return {
|
||||||
auth: {
|
auth: {
|
||||||
|
|
@ -259,44 +323,7 @@ function buildDefaultSnapshot(): MockFoundryAppSnapshot {
|
||||||
seatAssignments: ["nathan@acme.dev", "maya@acme.dev"],
|
seatAssignments: ["nathan@acme.dev", "maya@acme.dev"],
|
||||||
repoCatalog: ["acme/backend", "acme/frontend", "acme/infra"],
|
repoCatalog: ["acme/backend", "acme/frontend", "acme/infra"],
|
||||||
},
|
},
|
||||||
{
|
buildRivetOrganization(),
|
||||||
id: "rivet",
|
|
||||||
workspaceId: "rivet",
|
|
||||||
kind: "organization",
|
|
||||||
settings: {
|
|
||||||
displayName: "Rivet",
|
|
||||||
slug: "rivet",
|
|
||||||
primaryDomain: "rivet.dev",
|
|
||||||
seatAccrualMode: "first_prompt",
|
|
||||||
defaultModel: "o3",
|
|
||||||
autoImportRepos: true,
|
|
||||||
},
|
|
||||||
github: {
|
|
||||||
connectedAccount: "rivet-dev",
|
|
||||||
installationStatus: "reconnect_required",
|
|
||||||
syncStatus: "error",
|
|
||||||
importedRepoCount: 4,
|
|
||||||
lastSyncLabel: "Sync stalled 2 hours ago",
|
|
||||||
lastSyncAt: Date.now() - 2 * 60 * 60_000,
|
|
||||||
},
|
|
||||||
billing: {
|
|
||||||
planId: "team",
|
|
||||||
status: "trialing",
|
|
||||||
seatsIncluded: 5,
|
|
||||||
trialEndsAt: isoDate(12),
|
|
||||||
renewalAt: isoDate(12),
|
|
||||||
stripeCustomerId: "cus_mock_rivet_team",
|
|
||||||
paymentMethodLabel: "Visa ending in 4242",
|
|
||||||
invoices: [{ id: "inv-rivet-001", label: "Team pilot", issuedAt: "2026-03-04", amountUsd: 0, status: "paid" }],
|
|
||||||
},
|
|
||||||
members: [
|
|
||||||
{ id: "member-rivet-jamie", name: "Jamie", email: "jamie@rivet.dev", role: "owner", state: "active" },
|
|
||||||
{ id: "member-rivet-nathan", name: "Nathan", email: "nathan@acme.dev", role: "member", state: "active" },
|
|
||||||
{ id: "member-rivet-lena", name: "Lena", email: "lena@rivet.dev", role: "admin", state: "active" },
|
|
||||||
],
|
|
||||||
seatAssignments: ["jamie@rivet.dev"],
|
|
||||||
repoCatalog: ["rivet/dashboard", "rivet/agents", "rivet/billing", "rivet/infrastructure"],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "personal-jamie",
|
id: "personal-jamie",
|
||||||
workspaceId: "personal-jamie",
|
workspaceId: "personal-jamie",
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import type {
|
||||||
WorkbenchRepo,
|
WorkbenchRepo,
|
||||||
WorkbenchTranscriptEvent as TranscriptEvent,
|
WorkbenchTranscriptEvent as TranscriptEvent,
|
||||||
} from "@sandbox-agent/foundry-shared";
|
} from "@sandbox-agent/foundry-shared";
|
||||||
|
import rivetDevFixture from "../../../scripts/data/rivet-dev.json" with { type: "json" };
|
||||||
|
|
||||||
export const MODEL_GROUPS: ModelGroup[] = [
|
export const MODEL_GROUPS: ModelGroup[] = [
|
||||||
{
|
{
|
||||||
|
|
@ -801,13 +802,13 @@ export function buildInitialTasks(): Task[] {
|
||||||
fileTree: [],
|
fileTree: [],
|
||||||
minutesUsed: 312,
|
minutesUsed: 312,
|
||||||
},
|
},
|
||||||
// ── rivet-dev/cloud ──
|
// ── rivet-dev/vbare ──
|
||||||
{
|
{
|
||||||
id: "h6",
|
id: "h6",
|
||||||
repoId: "cloud",
|
repoId: "vbare",
|
||||||
title: "Use full cloud run pool name for routing",
|
title: "Use full cloud run pool name for routing",
|
||||||
status: "idle",
|
status: "idle",
|
||||||
repoName: "rivet-dev/cloud",
|
repoName: "rivet-dev/vbare",
|
||||||
updatedAtMs: minutesAgo(25),
|
updatedAtMs: minutesAgo(25),
|
||||||
branch: "fix-use-full-cloud-run-pool-name",
|
branch: "fix-use-full-cloud-run-pool-name",
|
||||||
pullRequest: { number: 235, status: "ready" },
|
pullRequest: { number: 235, status: "ready" },
|
||||||
|
|
@ -910,13 +911,13 @@ export function buildInitialTasks(): Task[] {
|
||||||
],
|
],
|
||||||
minutesUsed: 0,
|
minutesUsed: 0,
|
||||||
},
|
},
|
||||||
// ── rivet-dev/engine-ee ──
|
// ── rivet-dev/skills ──
|
||||||
{
|
{
|
||||||
id: "h7",
|
id: "h7",
|
||||||
repoId: "engine-ee",
|
repoId: "skills",
|
||||||
title: "Route compute gateway path correctly",
|
title: "Route compute gateway path correctly",
|
||||||
status: "idle",
|
status: "idle",
|
||||||
repoName: "rivet-dev/engine-ee",
|
repoName: "rivet-dev/skills",
|
||||||
updatedAtMs: minutesAgo(50),
|
updatedAtMs: minutesAgo(50),
|
||||||
branch: "fix-guard-support-https-targets",
|
branch: "fix-guard-support-https-targets",
|
||||||
pullRequest: { number: 125, status: "ready" },
|
pullRequest: { number: 125, status: "ready" },
|
||||||
|
|
@ -1024,13 +1025,13 @@ export function buildInitialTasks(): Task[] {
|
||||||
],
|
],
|
||||||
minutesUsed: 78,
|
minutesUsed: 78,
|
||||||
},
|
},
|
||||||
// ── rivet-dev/engine-ee (archived) ──
|
// ── rivet-dev/skills (archived) ──
|
||||||
{
|
{
|
||||||
id: "h8",
|
id: "h8",
|
||||||
repoId: "engine-ee",
|
repoId: "skills",
|
||||||
title: "Move compute gateway to guard",
|
title: "Move compute gateway to guard",
|
||||||
status: "archived",
|
status: "archived",
|
||||||
repoName: "rivet-dev/engine-ee",
|
repoName: "rivet-dev/skills",
|
||||||
updatedAtMs: minutesAgo(2 * 24 * 60),
|
updatedAtMs: minutesAgo(2 * 24 * 60),
|
||||||
branch: "chore-move-compute-gateway-to",
|
branch: "chore-move-compute-gateway-to",
|
||||||
pullRequest: { number: 123, status: "ready" },
|
pullRequest: { number: 123, status: "ready" },
|
||||||
|
|
@ -1066,13 +1067,13 @@ export function buildInitialTasks(): Task[] {
|
||||||
fileTree: [],
|
fileTree: [],
|
||||||
minutesUsed: 15,
|
minutesUsed: 15,
|
||||||
},
|
},
|
||||||
// ── rivet-dev/secure-exec ──
|
// ── rivet-dev/deploy-action ──
|
||||||
{
|
{
|
||||||
id: "h9",
|
id: "h9",
|
||||||
repoId: "secure-exec",
|
repoId: "deploy-action",
|
||||||
title: "Harden namespace isolation for nested containers",
|
title: "Harden namespace isolation for nested containers",
|
||||||
status: "idle",
|
status: "idle",
|
||||||
repoName: "rivet-dev/secure-exec",
|
repoName: "rivet-dev/deploy-action",
|
||||||
updatedAtMs: minutesAgo(90),
|
updatedAtMs: minutesAgo(90),
|
||||||
branch: "fix/namespace-isolation",
|
branch: "fix/namespace-isolation",
|
||||||
pullRequest: null,
|
pullRequest: null,
|
||||||
|
|
@ -1122,15 +1123,63 @@ export function buildInitialTasks(): Task[] {
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build repos list from the rivet-dev fixture data (scripts/data/rivet-dev.json).
|
||||||
|
* Uses real public repos so the mock sidebar matches what an actual rivet-dev
|
||||||
|
* workspace would show after a GitHub sync.
|
||||||
|
*/
|
||||||
|
function buildMockRepos(): WorkbenchRepo[] {
|
||||||
|
return rivetDevFixture.repos.map((r) => ({
|
||||||
|
id: repoIdFromFullName(r.fullName),
|
||||||
|
label: r.fullName,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Derive a stable short id from a "org/repo" full name (e.g. "rivet-dev/rivet" → "rivet"). */
|
||||||
|
function repoIdFromFullName(fullName: string): string {
|
||||||
|
const parts = fullName.split("/");
|
||||||
|
return parts[parts.length - 1] ?? fullName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build task entries from open PR fixture data.
|
||||||
|
* Maps to the backend's PR sync behavior (ProjectPrSyncActor) where PRs
|
||||||
|
* appear as first-class sidebar items even without an associated task.
|
||||||
|
* Each open PR gets a lightweight task entry so it shows in the sidebar.
|
||||||
|
*/
|
||||||
|
function buildPrTasks(): Task[] {
|
||||||
|
// Collect branch names already claimed by hand-written tasks so we don't duplicate
|
||||||
|
const existingBranches = new Set(
|
||||||
|
buildInitialTasks()
|
||||||
|
.map((t) => t.branch)
|
||||||
|
.filter(Boolean),
|
||||||
|
);
|
||||||
|
|
||||||
|
return rivetDevFixture.openPullRequests
|
||||||
|
.filter((pr) => !existingBranches.has(pr.headRefName))
|
||||||
|
.map((pr) => {
|
||||||
|
const repoId = repoIdFromFullName(pr.repoFullName);
|
||||||
|
return {
|
||||||
|
id: `pr-${repoId}-${pr.number}`,
|
||||||
|
repoId,
|
||||||
|
title: pr.title,
|
||||||
|
status: "idle" as const,
|
||||||
|
repoName: pr.repoFullName,
|
||||||
|
updatedAtMs: new Date(pr.updatedAt).getTime(),
|
||||||
|
branch: pr.headRefName,
|
||||||
|
pullRequest: { number: pr.number, status: pr.draft ? ("draft" as const) : ("ready" as const) },
|
||||||
|
tabs: [],
|
||||||
|
fileChanges: [],
|
||||||
|
diffs: {},
|
||||||
|
fileTree: [],
|
||||||
|
minutesUsed: 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function buildInitialMockLayoutViewModel(): TaskWorkbenchSnapshot {
|
export function buildInitialMockLayoutViewModel(): TaskWorkbenchSnapshot {
|
||||||
const repos: WorkbenchRepo[] = [
|
const repos = buildMockRepos();
|
||||||
{ id: "sandbox-agent", label: "rivet-dev/sandbox-agent" },
|
const tasks = [...buildInitialTasks(), ...buildPrTasks()];
|
||||||
{ id: "rivet", label: "rivet-dev/rivet" },
|
|
||||||
{ id: "cloud", label: "rivet-dev/cloud" },
|
|
||||||
{ id: "engine-ee", label: "rivet-dev/engine-ee" },
|
|
||||||
{ id: "secure-exec", label: "rivet-dev/secure-exec" },
|
|
||||||
];
|
|
||||||
const tasks = buildInitialTasks();
|
|
||||||
return {
|
return {
|
||||||
workspaceId: "default",
|
workspaceId: "default",
|
||||||
repos,
|
repos,
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import { setFrontendErrorContext } from "@sandbox-agent/foundry-frontend-errors/
|
||||||
import type { FoundryBillingPlanId } from "@sandbox-agent/foundry-shared";
|
import type { FoundryBillingPlanId } from "@sandbox-agent/foundry-shared";
|
||||||
import { useInterest } from "@sandbox-agent/foundry-client";
|
import { useInterest } from "@sandbox-agent/foundry-client";
|
||||||
import { Navigate, Outlet, createRootRoute, createRoute, createRouter, useRouterState } from "@tanstack/react-router";
|
import { Navigate, Outlet, createRootRoute, createRoute, createRouter, useRouterState } from "@tanstack/react-router";
|
||||||
import { DevPanel } from "../components/dev-panel";
|
|
||||||
import { MockLayout } from "../components/mock-layout";
|
import { MockLayout } from "../components/mock-layout";
|
||||||
import {
|
import {
|
||||||
MockAccountSettingsPage,
|
MockAccountSettingsPage,
|
||||||
|
|
@ -346,7 +345,6 @@ function RootLayout() {
|
||||||
<>
|
<>
|
||||||
<RouteContextSync />
|
<RouteContextSync />
|
||||||
<Outlet />
|
<Outlet />
|
||||||
<DevPanel />
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,304 +1,379 @@
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { memo, useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useRouterState } from "@tanstack/react-router";
|
import { useStyletron } from "baseui";
|
||||||
import { Bug, RefreshCw, Wifi } from "lucide-react";
|
|
||||||
import { useFoundryTokens } from "../app/theme";
|
import { useFoundryTokens } from "../app/theme";
|
||||||
import { isMockFrontendClient } from "../lib/env";
|
import { isMockFrontendClient } from "../lib/env";
|
||||||
import { activeMockOrganization, activeMockUser, eligibleOrganizations, useMockAppClient, useMockAppSnapshot } from "../lib/mock-app";
|
import type { FoundryOrganization, TaskWorkbenchSnapshot, WorkbenchTask } from "@sandbox-agent/foundry-shared";
|
||||||
|
|
||||||
const DEV_PANEL_STORAGE_KEY = "sandbox-agent-foundry:dev-panel-visible";
|
interface DevPanelProps {
|
||||||
|
workspaceId: string;
|
||||||
|
snapshot: TaskWorkbenchSnapshot;
|
||||||
|
organization?: FoundryOrganization | null;
|
||||||
|
}
|
||||||
|
|
||||||
function readStoredVisibility(): boolean {
|
interface TopicInfo {
|
||||||
if (typeof window === "undefined") {
|
label: string;
|
||||||
return true;
|
key: string;
|
||||||
}
|
listenerCount: number;
|
||||||
try {
|
hasConnection: boolean;
|
||||||
const stored = window.localStorage.getItem(DEV_PANEL_STORAGE_KEY);
|
lastRefresh: number | null;
|
||||||
return stored == null ? true : stored === "true";
|
}
|
||||||
} catch {
|
|
||||||
return true;
|
function timeAgo(ts: number | null): string {
|
||||||
|
if (!ts) return "never";
|
||||||
|
const seconds = Math.floor((Date.now() - ts) / 1000);
|
||||||
|
if (seconds < 5) return "now";
|
||||||
|
if (seconds < 60) return `${seconds}s`;
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes}m`;
|
||||||
|
return `${Math.floor(minutes / 60)}h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function taskStatusLabel(task: WorkbenchTask): string {
|
||||||
|
if (task.status === "archived") return "archived";
|
||||||
|
const hasRunning = task.tabs?.some((tab) => tab.status === "running");
|
||||||
|
if (hasRunning) return "running";
|
||||||
|
return task.status ?? "idle";
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusColor(status: string, t: ReturnType<typeof useFoundryTokens>): string {
|
||||||
|
switch (status) {
|
||||||
|
case "running":
|
||||||
|
return t.statusSuccess;
|
||||||
|
case "archived":
|
||||||
|
return t.textMuted;
|
||||||
|
case "error":
|
||||||
|
case "failed":
|
||||||
|
return t.statusError;
|
||||||
|
default:
|
||||||
|
return t.textTertiary;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeStoredVisibility(value: boolean): void {
|
function syncStatusColor(status: string, t: ReturnType<typeof useFoundryTokens>): string {
|
||||||
if (typeof window === "undefined") {
|
switch (status) {
|
||||||
return;
|
case "synced":
|
||||||
}
|
return t.statusSuccess;
|
||||||
try {
|
case "syncing":
|
||||||
window.localStorage.setItem(DEV_PANEL_STORAGE_KEY, String(value));
|
case "pending":
|
||||||
} catch {
|
return t.statusWarning;
|
||||||
// ignore
|
case "error":
|
||||||
|
return t.statusError;
|
||||||
|
default:
|
||||||
|
return t.textMuted;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function sectionStyle(borderColor: string, background: string) {
|
function installStatusColor(status: string, t: ReturnType<typeof useFoundryTokens>): string {
|
||||||
return {
|
switch (status) {
|
||||||
display: "grid",
|
case "connected":
|
||||||
gap: "10px",
|
return t.statusSuccess;
|
||||||
padding: "12px",
|
case "install_required":
|
||||||
borderRadius: "12px",
|
return t.statusWarning;
|
||||||
border: `1px solid ${borderColor}`,
|
case "reconnect_required":
|
||||||
background,
|
return t.statusError;
|
||||||
} as const;
|
default:
|
||||||
}
|
return t.textMuted;
|
||||||
|
|
||||||
function labelStyle(color: string) {
|
|
||||||
return {
|
|
||||||
fontSize: "11px",
|
|
||||||
fontWeight: 600,
|
|
||||||
letterSpacing: "0.04em",
|
|
||||||
textTransform: "uppercase" as const,
|
|
||||||
color,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergedRouteParams(matches: Array<{ params: Record<string, unknown> }>): Record<string, string> {
|
|
||||||
return matches.reduce<Record<string, string>>((acc, match) => {
|
|
||||||
for (const [key, value] of Object.entries(match.params)) {
|
|
||||||
if (typeof value === "string" && value.length > 0) {
|
|
||||||
acc[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DevPanel() {
|
|
||||||
if (!import.meta.env.DEV) {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const client = useMockAppClient();
|
export const DevPanel = memo(function DevPanel({ workspaceId, snapshot, organization }: DevPanelProps) {
|
||||||
const snapshot = useMockAppSnapshot();
|
const [css] = useStyletron();
|
||||||
const organization = activeMockOrganization(snapshot);
|
|
||||||
const user = activeMockUser(snapshot);
|
|
||||||
const organizations = eligibleOrganizations(snapshot);
|
|
||||||
const t = useFoundryTokens();
|
const t = useFoundryTokens();
|
||||||
const routeContext = useRouterState({
|
const [now, setNow] = useState(Date.now());
|
||||||
select: (state) => ({
|
|
||||||
location: state.location,
|
|
||||||
params: mergedRouteParams(state.matches as Array<{ params: Record<string, unknown> }>),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const [visible, setVisible] = useState<boolean>(() => readStoredVisibility());
|
|
||||||
|
|
||||||
|
// Tick every 2s to keep relative timestamps fresh
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
writeStoredVisibility(visible);
|
const id = setInterval(() => setNow(Date.now()), 2000);
|
||||||
}, [visible]);
|
return () => clearInterval(id);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
|
||||||
if (event.shiftKey && event.key.toLowerCase() === "d") {
|
|
||||||
event.preventDefault();
|
|
||||||
setVisible((current) => !current);
|
|
||||||
}
|
|
||||||
if (event.key === "Escape") {
|
|
||||||
setVisible(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
window.addEventListener("keydown", onKeyDown);
|
|
||||||
return () => window.removeEventListener("keydown", onKeyDown);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const modeLabel = isMockFrontendClient ? "Mock" : "Live";
|
const topics = useMemo((): TopicInfo[] => {
|
||||||
const selectedWorkspaceId = routeContext.params.workspaceId ?? null;
|
const items: TopicInfo[] = [];
|
||||||
const selectedTaskId = routeContext.params.taskId ?? null;
|
|
||||||
const selectedRepoId = routeContext.params.repoId ?? null;
|
|
||||||
const selectedSessionId =
|
|
||||||
routeContext.location.search && typeof routeContext.location.search === "object" && "sessionId" in routeContext.location.search
|
|
||||||
? (((routeContext.location.search as Record<string, unknown>).sessionId as string | undefined) ?? null)
|
|
||||||
: null;
|
|
||||||
const contextOrganization =
|
|
||||||
(routeContext.params.organizationId ? (snapshot.organizations.find((candidate) => candidate.id === routeContext.params.organizationId) ?? null) : null) ??
|
|
||||||
(selectedWorkspaceId ? (snapshot.organizations.find((candidate) => candidate.workspaceId === selectedWorkspaceId) ?? null) : null) ??
|
|
||||||
organization;
|
|
||||||
const github = contextOrganization?.github ?? null;
|
|
||||||
|
|
||||||
const pillButtonStyle = useCallback(
|
// Workbench subscription topic
|
||||||
(active = false) =>
|
items.push({
|
||||||
({
|
label: "Workbench",
|
||||||
border: `1px solid ${active ? t.accent : t.borderDefault}`,
|
key: `ws:${workspaceId}`,
|
||||||
background: active ? t.surfacePrimary : t.surfaceSecondary,
|
listenerCount: 1,
|
||||||
color: t.textPrimary,
|
hasConnection: true,
|
||||||
borderRadius: "999px",
|
lastRefresh: now,
|
||||||
padding: "6px 10px",
|
});
|
||||||
fontSize: "11px",
|
|
||||||
fontWeight: 600,
|
|
||||||
cursor: "pointer",
|
|
||||||
}) as const,
|
|
||||||
[t],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!visible) {
|
// Per-task tab subscriptions
|
||||||
return (
|
for (const task of snapshot.tasks ?? []) {
|
||||||
<button
|
if (task.status === "archived") continue;
|
||||||
type="button"
|
for (const tab of task.tabs ?? []) {
|
||||||
onClick={() => setVisible(true)}
|
items.push({
|
||||||
style={{
|
label: `Tab/${task.title?.slice(0, 16) || task.id.slice(0, 8)}/${tab.sessionName.slice(0, 10)}`,
|
||||||
position: "fixed",
|
key: `${workspaceId}:${task.id}:${tab.id}`,
|
||||||
right: "16px",
|
listenerCount: 1,
|
||||||
bottom: "16px",
|
hasConnection: tab.status === "running",
|
||||||
zIndex: 1000,
|
lastRefresh: tab.status === "running" ? now : null,
|
||||||
display: "inline-flex",
|
});
|
||||||
alignItems: "center",
|
}
|
||||||
gap: "8px",
|
}
|
||||||
border: `1px solid ${t.borderDefault}`,
|
|
||||||
background: "rgba(9, 9, 11, 0.78)",
|
return items;
|
||||||
color: t.textPrimary,
|
}, [workspaceId, snapshot, now]);
|
||||||
borderRadius: "999px",
|
|
||||||
padding: "9px 12px",
|
const tasks = snapshot.tasks ?? [];
|
||||||
boxShadow: "0 18px 40px rgba(0, 0, 0, 0.22)",
|
const repos = snapshot.repos ?? [];
|
||||||
cursor: "pointer",
|
const projects = snapshot.projects ?? [];
|
||||||
}}
|
|
||||||
>
|
const mono = css({
|
||||||
<Bug size={14} />
|
fontFamily: "ui-monospace, SFMono-Regular, 'SF Mono', Consolas, monospace",
|
||||||
<span style={{ display: "inline-flex", alignItems: "center", gap: "8px", fontSize: "12px", lineHeight: 1 }}>
|
fontSize: "10px",
|
||||||
<span style={{ color: t.textSecondary }}>Show Dev Panel</span>
|
});
|
||||||
<span
|
|
||||||
style={{
|
|
||||||
padding: "4px 7px",
|
|
||||||
borderRadius: "999px",
|
|
||||||
border: `1px solid ${t.borderDefault}`,
|
|
||||||
background: "rgba(255, 255, 255, 0.04)",
|
|
||||||
fontSize: "11px",
|
|
||||||
fontWeight: 700,
|
|
||||||
letterSpacing: "0.03em",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Shift+D
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
className={css({
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
right: "16px",
|
bottom: "8px",
|
||||||
bottom: "16px",
|
right: "8px",
|
||||||
width: "360px",
|
width: "320px",
|
||||||
maxHeight: "calc(100vh - 32px)",
|
maxHeight: "50vh",
|
||||||
overflowY: "auto",
|
zIndex: 99999,
|
||||||
zIndex: 1000,
|
backgroundColor: t.surfaceElevated,
|
||||||
borderRadius: "18px",
|
border: `1px solid ${t.borderMedium}`,
|
||||||
border: `1px solid ${t.borderDefault}`,
|
borderRadius: "6px",
|
||||||
background: t.surfacePrimary,
|
boxShadow: t.shadow,
|
||||||
color: t.textPrimary,
|
overflow: "hidden",
|
||||||
boxShadow: "0 24px 60px rgba(0, 0, 0, 0.35)",
|
display: "flex",
|
||||||
}}
|
flexDirection: "column",
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
|
{/* Header */}
|
||||||
<div
|
<div
|
||||||
style={{
|
className={css({
|
||||||
position: "sticky",
|
|
||||||
top: 0,
|
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "space-between",
|
justifyContent: "space-between",
|
||||||
padding: "14px 16px",
|
padding: "4px 8px",
|
||||||
borderBottom: `1px solid ${t.borderDefault}`,
|
borderBottom: `1px solid ${t.borderSubtle}`,
|
||||||
background: t.surfacePrimary,
|
backgroundColor: t.surfaceTertiary,
|
||||||
}}
|
flexShrink: 0,
|
||||||
|
})}
|
||||||
>
|
>
|
||||||
<div style={{ display: "grid", gap: "2px" }}>
|
<span
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
className={css({
|
||||||
<Bug size={14} />
|
fontSize: "10px",
|
||||||
<strong style={{ fontSize: "13px" }}>Dev Panel</strong>
|
fontWeight: 600,
|
||||||
<span
|
color: t.textSecondary,
|
||||||
style={{
|
letterSpacing: "0.5px",
|
||||||
fontSize: "10px",
|
textTransform: "uppercase",
|
||||||
fontWeight: 700,
|
display: "flex",
|
||||||
letterSpacing: "0.06em",
|
alignItems: "center",
|
||||||
textTransform: "uppercase",
|
gap: "4px",
|
||||||
color: t.textMuted,
|
})}
|
||||||
}}
|
>
|
||||||
>
|
Dev
|
||||||
{modeLabel}
|
{isMockFrontendClient && <span className={css({ fontSize: "8px", fontWeight: 600, color: t.statusWarning, letterSpacing: "0.3px" })}>MOCK</span>}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
<span className={css({ fontSize: "9px", color: t.textMuted })}>Shift+D</span>
|
||||||
<div style={{ fontSize: "11px", color: t.textMuted }}>{routeContext.location.pathname}</div>
|
|
||||||
</div>
|
|
||||||
<button type="button" onClick={() => setVisible(false)} style={pillButtonStyle()}>
|
|
||||||
Hide
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: "grid", gap: "12px", padding: "14px" }}>
|
{/* Body */}
|
||||||
<div style={sectionStyle(t.borderSubtle, t.surfaceSecondary)}>
|
<div className={css({ overflowY: "auto", padding: "6px" })}>
|
||||||
<div style={labelStyle(t.textMuted)}>Context</div>
|
{/* Interest Topics */}
|
||||||
<div style={{ display: "grid", gap: "4px", fontSize: "12px" }}>
|
<Section label="Interest Topics" t={t} css={css}>
|
||||||
<div>Organization: {contextOrganization?.settings.displayName ?? "None selected"}</div>
|
{topics.map((topic) => (
|
||||||
<div>Workspace: {selectedWorkspaceId ?? "None selected"}</div>
|
<div
|
||||||
<div>Task: {selectedTaskId ?? "None selected"}</div>
|
key={topic.key}
|
||||||
<div>Repo: {selectedRepoId ?? "None selected"}</div>
|
className={css({
|
||||||
<div>Session: {selectedSessionId ?? "None selected"}</div>
|
display: "flex",
|
||||||
</div>
|
alignItems: "center",
|
||||||
</div>
|
gap: "6px",
|
||||||
|
padding: "2px 0",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={css({
|
||||||
|
width: "5px",
|
||||||
|
height: "5px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
backgroundColor: topic.hasConnection ? t.statusSuccess : t.textMuted,
|
||||||
|
flexShrink: 0,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<span className={css({ fontSize: "10px", color: t.textPrimary, flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" })}>
|
||||||
|
{topic.label}
|
||||||
|
</span>
|
||||||
|
<span className={`${mono} ${css({ color: t.textMuted })}`}>{topic.key.length > 24 ? `...${topic.key.slice(-20)}` : topic.key}</span>
|
||||||
|
<span className={`${mono} ${css({ color: t.textTertiary })}`}>{timeAgo(topic.lastRefresh)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{topics.length === 0 && <span className={css({ fontSize: "10px", color: t.textMuted })}>No active subscriptions</span>}
|
||||||
|
</Section>
|
||||||
|
|
||||||
<div style={sectionStyle(t.borderSubtle, t.surfaceSecondary)}>
|
{/* Snapshot Summary */}
|
||||||
<div style={labelStyle(t.textMuted)}>Session</div>
|
<Section label="Snapshot" t={t} css={css}>
|
||||||
<div style={{ display: "grid", gap: "4px", fontSize: "12px" }}>
|
<div className={css({ display: "flex", gap: "10px", fontSize: "10px" })}>
|
||||||
<div>Auth: {snapshot.auth.status}</div>
|
<Stat label="repos" value={repos.length} t={t} css={css} />
|
||||||
<div>User: {user ? `${user.name} (@${user.githubLogin})` : "None"}</div>
|
<Stat label="projects" value={projects.length} t={t} css={css} />
|
||||||
<div>Active org: {organization?.settings.displayName ?? "None selected"}</div>
|
<Stat label="tasks" value={tasks.length} t={t} css={css} />
|
||||||
</div>
|
</div>
|
||||||
{isMockFrontendClient ? (
|
</Section>
|
||||||
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
|
|
||||||
{snapshot.auth.status === "signed_in" ? (
|
{/* Tasks */}
|
||||||
<button type="button" onClick={() => void client.signOut()} style={pillButtonStyle()}>
|
{tasks.length > 0 && (
|
||||||
Sign out
|
<Section label="Tasks" t={t} css={css}>
|
||||||
</button>
|
{tasks.slice(0, 10).map((task) => {
|
||||||
) : (
|
const status = taskStatusLabel(task);
|
||||||
snapshot.users.map((candidate) => (
|
return (
|
||||||
<button key={candidate.id} type="button" onClick={() => void client.signInWithGithub(candidate.id)} style={pillButtonStyle()}>
|
<div
|
||||||
Sign in as {candidate.githubLogin}
|
key={task.id}
|
||||||
</button>
|
className={css({
|
||||||
))
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "6px",
|
||||||
|
padding: "1px 0",
|
||||||
|
fontSize: "10px",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={css({
|
||||||
|
width: "5px",
|
||||||
|
height: "5px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
backgroundColor: statusColor(status, t),
|
||||||
|
flexShrink: 0,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<span className={css({ color: t.textPrimary, flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" })}>
|
||||||
|
{task.title || task.id.slice(0, 12)}
|
||||||
|
</span>
|
||||||
|
<span className={`${mono} ${css({ color: statusColor(status, t) })}`}>{status}</span>
|
||||||
|
<span className={`${mono} ${css({ color: t.textMuted })}`}>{task.tabs?.length ?? 0} tabs</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* GitHub */}
|
||||||
|
{organization && (
|
||||||
|
<Section label="GitHub" t={t} css={css}>
|
||||||
|
<div className={css({ display: "flex", flexDirection: "column", gap: "3px", fontSize: "10px" })}>
|
||||||
|
<div className={css({ display: "flex", alignItems: "center", gap: "6px" })}>
|
||||||
|
<span
|
||||||
|
className={css({
|
||||||
|
width: "5px",
|
||||||
|
height: "5px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
backgroundColor: installStatusColor(organization.github.installationStatus, t),
|
||||||
|
flexShrink: 0,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<span className={css({ color: t.textPrimary, flex: 1 })}>App</span>
|
||||||
|
<span className={`${mono} ${css({ color: installStatusColor(organization.github.installationStatus, t) })}`}>
|
||||||
|
{organization.github.installationStatus.replace(/_/g, " ")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className={css({ display: "flex", alignItems: "center", gap: "6px" })}>
|
||||||
|
<span
|
||||||
|
className={css({
|
||||||
|
width: "5px",
|
||||||
|
height: "5px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
backgroundColor: syncStatusColor(organization.github.syncStatus, t),
|
||||||
|
flexShrink: 0,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<span className={css({ color: t.textPrimary, flex: 1 })}>Sync</span>
|
||||||
|
<span className={`${mono} ${css({ color: syncStatusColor(organization.github.syncStatus, t) })}`}>{organization.github.syncStatus}</span>
|
||||||
|
</div>
|
||||||
|
<div className={css({ display: "flex", gap: "10px", marginTop: "2px" })}>
|
||||||
|
<Stat label="repos imported" value={organization.github.importedRepoCount} t={t} css={css} />
|
||||||
|
</div>
|
||||||
|
{organization.github.connectedAccount && (
|
||||||
|
<div className={`${mono} ${css({ color: t.textMuted, marginTop: "1px" })}`}>@{organization.github.connectedAccount}</div>
|
||||||
|
)}
|
||||||
|
{organization.github.lastSyncLabel && (
|
||||||
|
<div className={`${mono} ${css({ color: t.textMuted })}`}>last sync: {organization.github.lastSyncLabel}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
</Section>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
<div style={sectionStyle(t.borderSubtle, t.surfaceSecondary)}>
|
{/* Workspace */}
|
||||||
<div style={labelStyle(t.textMuted)}>GitHub</div>
|
<Section label="Workspace" t={t} css={css}>
|
||||||
<div style={{ display: "grid", gap: "4px", fontSize: "12px" }}>
|
<div className={`${mono} ${css({ color: t.textTertiary })}`}>{workspaceId}</div>
|
||||||
<div>Installation: {github?.installationStatus ?? "n/a"}</div>
|
{organization && (
|
||||||
<div>Sync: {github?.syncStatus ?? "n/a"}</div>
|
<div className={`${mono} ${css({ color: t.textMuted, marginTop: "2px" })}`}>
|
||||||
<div>Repos: {github?.importedRepoCount ?? 0}</div>
|
org: {organization.settings.displayName} ({organization.kind})
|
||||||
<div>Last sync: {github?.lastSyncLabel ?? "n/a"}</div>
|
|
||||||
</div>
|
|
||||||
{contextOrganization ? (
|
|
||||||
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
|
|
||||||
<button type="button" onClick={() => void client.triggerGithubSync(contextOrganization.id)} style={pillButtonStyle()}>
|
|
||||||
<RefreshCw size={12} style={{ marginRight: "6px", verticalAlign: "text-bottom" }} />
|
|
||||||
Sync
|
|
||||||
</button>
|
|
||||||
<button type="button" onClick={() => void client.reconnectGithub(contextOrganization.id)} style={pillButtonStyle()}>
|
|
||||||
<Wifi size={12} style={{ marginRight: "6px", verticalAlign: "text-bottom" }} />
|
|
||||||
Reconnect
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
)}
|
||||||
</div>
|
</Section>
|
||||||
|
|
||||||
{isMockFrontendClient && organizations.length > 0 ? (
|
|
||||||
<div style={sectionStyle(t.borderSubtle, t.surfaceSecondary)}>
|
|
||||||
<div style={labelStyle(t.textMuted)}>Mock Organization</div>
|
|
||||||
<div style={{ display: "flex", gap: "6px", flexWrap: "wrap" }}>
|
|
||||||
{organizations.map((candidate) => (
|
|
||||||
<button
|
|
||||||
key={candidate.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => void client.selectOrganization(candidate.id)}
|
|
||||||
style={pillButtonStyle(contextOrganization?.id === candidate.id)}
|
|
||||||
>
|
|
||||||
{candidate.settings.displayName}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function Section({
|
||||||
|
label,
|
||||||
|
t,
|
||||||
|
css: cssFn,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
t: ReturnType<typeof useFoundryTokens>;
|
||||||
|
css: ReturnType<typeof useStyletron>[0];
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={cssFn({ marginBottom: "6px" })}>
|
||||||
|
<div
|
||||||
|
className={cssFn({
|
||||||
|
fontSize: "9px",
|
||||||
|
fontWeight: 600,
|
||||||
|
color: t.textMuted,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: "0.5px",
|
||||||
|
marginBottom: "2px",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Stat({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
t,
|
||||||
|
css: cssFn,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
t: ReturnType<typeof useFoundryTokens>;
|
||||||
|
css: ReturnType<typeof useStyletron>[0];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
<span className={cssFn({ fontWeight: 600, color: t.textPrimary })}>{value}</span>
|
||||||
|
<span className={cssFn({ color: t.textTertiary, marginLeft: "2px" })}>{label}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDevPanel() {
|
||||||
|
const [visible, setVisible] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.shiftKey && e.key === "D" && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||||
|
const tag = (e.target as HTMLElement)?.tagName;
|
||||||
|
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
|
||||||
|
e.preventDefault();
|
||||||
|
setVisible((prev) => !prev);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return visible;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,13 @@
|
||||||
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
|
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from "react";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import { useStyletron } from "baseui";
|
import { useStyletron } from "baseui";
|
||||||
import { createErrorContext, type WorkbenchSessionSummary, type WorkbenchTaskDetail, type WorkbenchTaskSummary } from "@sandbox-agent/foundry-shared";
|
import {
|
||||||
|
createErrorContext,
|
||||||
|
type TaskWorkbenchSnapshot,
|
||||||
|
type WorkbenchSessionSummary,
|
||||||
|
type WorkbenchTaskDetail,
|
||||||
|
type WorkbenchTaskSummary,
|
||||||
|
} from "@sandbox-agent/foundry-shared";
|
||||||
import { useInterest } from "@sandbox-agent/foundry-client";
|
import { useInterest } from "@sandbox-agent/foundry-client";
|
||||||
|
|
||||||
import { PanelLeft, PanelRight } from "lucide-react";
|
import { PanelLeft, PanelRight } from "lucide-react";
|
||||||
|
|
@ -1085,6 +1091,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
||||||
const [leftSidebarOpen, setLeftSidebarOpen] = useState(true);
|
const [leftSidebarOpen, setLeftSidebarOpen] = useState(true);
|
||||||
const [rightSidebarOpen, setRightSidebarOpen] = useState(true);
|
const [rightSidebarOpen, setRightSidebarOpen] = useState(true);
|
||||||
const [leftSidebarPeeking, setLeftSidebarPeeking] = useState(false);
|
const [leftSidebarPeeking, setLeftSidebarPeeking] = useState(false);
|
||||||
|
const showDevPanel = useDevPanel();
|
||||||
const peekTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const peekTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
const startPeek = useCallback(() => {
|
const startPeek = useCallback(() => {
|
||||||
|
|
@ -1269,35 +1276,38 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
||||||
},
|
},
|
||||||
"failed_to_auto_create_workbench_session",
|
"failed_to_auto_create_workbench_session",
|
||||||
);
|
);
|
||||||
} finally {
|
// Keep the guard in the set on error to prevent retry storms.
|
||||||
autoCreatingSessionForTaskRef.current.delete(activeTask.id);
|
// The guard is cleared when tabs appear (line above) or the task changes.
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}, [activeTask, selectedSessionId, syncRouteSession, taskWorkbenchClient]);
|
}, [activeTask, selectedSessionId, syncRouteSession, taskWorkbenchClient]);
|
||||||
|
|
||||||
const createTask = useCallback(() => {
|
const createTask = useCallback(
|
||||||
void (async () => {
|
(overrideRepoId?: string) => {
|
||||||
const repoId = selectedNewTaskRepoId;
|
void (async () => {
|
||||||
if (!repoId) {
|
const repoId = overrideRepoId || selectedNewTaskRepoId;
|
||||||
throw new Error("Cannot create a task without an available repo");
|
if (!repoId) {
|
||||||
}
|
throw new Error("Cannot create a task without an available repo");
|
||||||
|
}
|
||||||
|
|
||||||
const { taskId, tabId } = await taskWorkbenchClient.createTask({
|
const { taskId, tabId } = await taskWorkbenchClient.createTask({
|
||||||
repoId,
|
repoId,
|
||||||
task: "New task",
|
task: "New task",
|
||||||
model: "gpt-4o",
|
model: "gpt-4o",
|
||||||
title: "New task",
|
title: "New task",
|
||||||
});
|
});
|
||||||
await navigate({
|
await navigate({
|
||||||
to: "/workspaces/$workspaceId/tasks/$taskId",
|
to: "/workspaces/$workspaceId/tasks/$taskId",
|
||||||
params: {
|
params: {
|
||||||
workspaceId,
|
workspaceId,
|
||||||
taskId,
|
taskId,
|
||||||
},
|
},
|
||||||
search: { sessionId: tabId ?? undefined },
|
search: { sessionId: tabId ?? undefined },
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
}, [navigate, selectedNewTaskRepoId, workspaceId]);
|
},
|
||||||
|
[navigate, selectedNewTaskRepoId, workspaceId],
|
||||||
|
);
|
||||||
|
|
||||||
const openDiffTab = useCallback(
|
const openDiffTab = useCallback(
|
||||||
(path: string) => {
|
(path: string) => {
|
||||||
|
|
@ -1509,7 +1519,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
||||||
transition: sidebarTransition,
|
transition: sidebarTransition,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ minWidth: `${leftWidth}px`, flex: 1, display: "flex", flexDirection: "column" }}>
|
<div style={{ minWidth: `${leftWidth}px`, flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
|
||||||
<Sidebar
|
<Sidebar
|
||||||
projects={projects}
|
projects={projects}
|
||||||
newTaskRepos={workspaceRepos}
|
newTaskRepos={workspaceRepos}
|
||||||
|
|
@ -1565,29 +1575,63 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
||||||
gap: "12px",
|
gap: "12px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<h2 style={{ margin: 0, fontSize: "20px", fontWeight: 600 }}>Create your first task</h2>
|
{activeOrg?.github.syncStatus === "syncing" || activeOrg?.github.syncStatus === "pending" ? (
|
||||||
<p style={{ margin: 0, opacity: 0.75 }}>
|
<>
|
||||||
{workspaceRepos.length > 0
|
<div
|
||||||
? "Start from the sidebar to create a task on the first available repo."
|
className={css({
|
||||||
: "No repos are available in this workspace yet."}
|
width: "24px",
|
||||||
</p>
|
height: "24px",
|
||||||
<button
|
border: `2px solid ${t.borderSubtle}`,
|
||||||
type="button"
|
borderTopColor: t.textSecondary,
|
||||||
onClick={createTask}
|
borderRadius: "50%",
|
||||||
disabled={workspaceRepos.length === 0}
|
animationName: {
|
||||||
style={{
|
from: { transform: "rotate(0deg)" },
|
||||||
alignSelf: "center",
|
to: { transform: "rotate(360deg)" },
|
||||||
border: 0,
|
} as unknown as string,
|
||||||
borderRadius: "999px",
|
animationDuration: "0.8s",
|
||||||
padding: "10px 18px",
|
animationIterationCount: "infinite",
|
||||||
background: workspaceRepos.length > 0 ? t.borderMedium : t.textTertiary,
|
animationTimingFunction: "linear",
|
||||||
color: t.textPrimary,
|
alignSelf: "center",
|
||||||
cursor: workspaceRepos.length > 0 ? "pointer" : "not-allowed",
|
})}
|
||||||
fontWeight: 600,
|
/>
|
||||||
}}
|
<h2 style={{ margin: 0, fontSize: "20px", fontWeight: 600 }}>Syncing with GitHub</h2>
|
||||||
>
|
<p style={{ margin: 0, opacity: 0.75 }}>
|
||||||
New task
|
Importing repos from @{activeOrg.github.connectedAccount || "GitHub"}...
|
||||||
</button>
|
{activeOrg.github.importedRepoCount > 0 && <> {activeOrg.github.importedRepoCount} repos imported so far.</>}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : activeOrg?.github.syncStatus === "error" ? (
|
||||||
|
<>
|
||||||
|
<h2 style={{ margin: 0, fontSize: "20px", fontWeight: 600, color: t.statusError }}>GitHub sync failed</h2>
|
||||||
|
<p style={{ margin: 0, opacity: 0.75 }}>There was a problem syncing repos from GitHub. Check the dev panel for details.</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<h2 style={{ margin: 0, fontSize: "20px", fontWeight: 600 }}>Create your first task</h2>
|
||||||
|
<p style={{ margin: 0, opacity: 0.75 }}>
|
||||||
|
{workspaceRepos.length > 0
|
||||||
|
? "Start from the sidebar to create a task on the first available repo."
|
||||||
|
: "No repos are available in this workspace yet."}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => createTask()}
|
||||||
|
disabled={workspaceRepos.length === 0}
|
||||||
|
style={{
|
||||||
|
alignSelf: "center",
|
||||||
|
border: 0,
|
||||||
|
borderRadius: "999px",
|
||||||
|
padding: "10px 18px",
|
||||||
|
background: workspaceRepos.length > 0 ? t.borderMedium : t.textTertiary,
|
||||||
|
color: t.textPrimary,
|
||||||
|
cursor: workspaceRepos.length > 0 ? "pointer" : "not-allowed",
|
||||||
|
fontWeight: 600,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
New task
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ScrollBody>
|
</ScrollBody>
|
||||||
|
|
@ -1610,6 +1654,47 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Shell>
|
</Shell>
|
||||||
|
{activeOrg && (activeOrg.github.installationStatus === "install_required" || activeOrg.github.installationStatus === "reconnect_required") && (
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
position: "fixed",
|
||||||
|
bottom: "8px",
|
||||||
|
left: "8px",
|
||||||
|
zIndex: 99998,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "6px",
|
||||||
|
padding: "6px 12px",
|
||||||
|
backgroundColor: t.surfaceElevated,
|
||||||
|
border: `1px solid ${t.statusError}`,
|
||||||
|
borderRadius: "6px",
|
||||||
|
boxShadow: t.shadow,
|
||||||
|
fontSize: "11px",
|
||||||
|
color: t.textPrimary,
|
||||||
|
maxWidth: "360px",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={css({
|
||||||
|
width: "6px",
|
||||||
|
height: "6px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
backgroundColor: t.statusError,
|
||||||
|
flexShrink: 0,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
GitHub App {activeOrg.github.installationStatus === "install_required" ? "not installed" : "needs reconnection"} — repo sync is unavailable
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{showDevPanel && (
|
||||||
|
<DevPanel
|
||||||
|
workspaceId={workspaceId}
|
||||||
|
snapshot={{ workspaceId, repos: workspaceRepos, projects: rawProjects, tasks } as TaskWorkbenchSnapshot}
|
||||||
|
organization={activeOrg}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1629,7 +1714,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
||||||
transition: sidebarTransition,
|
transition: sidebarTransition,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ minWidth: `${leftWidth}px`, flex: 1, display: "flex", flexDirection: "column" }}>
|
<div style={{ minWidth: `${leftWidth}px`, flex: 1, minHeight: 0, display: "flex", flexDirection: "column" }}>
|
||||||
<Sidebar
|
<Sidebar
|
||||||
projects={projects}
|
projects={projects}
|
||||||
newTaskRepos={workspaceRepos}
|
newTaskRepos={workspaceRepos}
|
||||||
|
|
@ -1760,7 +1845,47 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{showDevPanel && <DevPanel workspaceId={workspaceId} snapshot={viewModel} />}
|
{activeOrg && (activeOrg.github.installationStatus === "install_required" || activeOrg.github.installationStatus === "reconnect_required") && (
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
position: "fixed",
|
||||||
|
bottom: "8px",
|
||||||
|
left: "8px",
|
||||||
|
zIndex: 99998,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "6px",
|
||||||
|
padding: "6px 12px",
|
||||||
|
backgroundColor: t.surfaceElevated,
|
||||||
|
border: `1px solid ${t.statusError}`,
|
||||||
|
borderRadius: "6px",
|
||||||
|
boxShadow: t.shadow,
|
||||||
|
fontSize: "11px",
|
||||||
|
color: t.textPrimary,
|
||||||
|
maxWidth: "360px",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={css({
|
||||||
|
width: "6px",
|
||||||
|
height: "6px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
backgroundColor: t.statusError,
|
||||||
|
flexShrink: 0,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
GitHub App {activeOrg.github.installationStatus === "install_required" ? "not installed" : "needs reconnection"} — repo sync is unavailable
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{showDevPanel && (
|
||||||
|
<DevPanel
|
||||||
|
workspaceId={workspaceId}
|
||||||
|
snapshot={{ workspaceId, repos: workspaceRepos, projects: rawProjects, tasks } as TaskWorkbenchSnapshot}
|
||||||
|
organization={activeOrg}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Shell>
|
</Shell>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { memo, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import { useStyletron } from "baseui";
|
import { useStyletron } from "baseui";
|
||||||
import { LabelSmall, LabelXSmall } from "baseui/typography";
|
import { LabelSmall, LabelXSmall } from "baseui/typography";
|
||||||
|
import { Select, type Value } from "baseui/select";
|
||||||
import {
|
import {
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
|
@ -26,6 +27,17 @@ import type { FoundryTokens } from "../../styles/tokens";
|
||||||
|
|
||||||
const PROJECT_COLORS = ["#6366f1", "#f59e0b", "#10b981", "#ef4444", "#8b5cf6", "#ec4899", "#06b6d4", "#f97316"];
|
const PROJECT_COLORS = ["#6366f1", "#f59e0b", "#10b981", "#ef4444", "#8b5cf6", "#ec4899", "#06b6d4", "#f97316"];
|
||||||
|
|
||||||
|
/** Strip the org prefix (e.g. "rivet-dev/") when all repos share the same org. */
|
||||||
|
function stripCommonOrgPrefix(label: string, repos: Array<{ label: string }>): string {
|
||||||
|
const slashIdx = label.indexOf("/");
|
||||||
|
if (slashIdx < 0) return label;
|
||||||
|
const prefix = label.slice(0, slashIdx + 1);
|
||||||
|
if (repos.every((r) => r.label.startsWith(prefix))) {
|
||||||
|
return label.slice(slashIdx + 1);
|
||||||
|
}
|
||||||
|
return label;
|
||||||
|
}
|
||||||
|
|
||||||
function projectInitial(label: string): string {
|
function projectInitial(label: string): string {
|
||||||
const parts = label.split("/");
|
const parts = label.split("/");
|
||||||
const name = parts[parts.length - 1] ?? label;
|
const name = parts[parts.length - 1] ?? label;
|
||||||
|
|
@ -61,7 +73,7 @@ export const Sidebar = memo(function Sidebar({
|
||||||
selectedNewTaskRepoId: string;
|
selectedNewTaskRepoId: string;
|
||||||
activeId: string;
|
activeId: string;
|
||||||
onSelect: (id: string) => void;
|
onSelect: (id: string) => void;
|
||||||
onCreate: () => void;
|
onCreate: (repoId?: string) => void;
|
||||||
onSelectNewTaskRepo: (repoId: string) => void;
|
onSelectNewTaskRepo: (repoId: string) => void;
|
||||||
onMarkUnread: (id: string) => void;
|
onMarkUnread: (id: string) => void;
|
||||||
onRenameTask: (id: string) => void;
|
onRenameTask: (id: string) => void;
|
||||||
|
|
@ -137,19 +149,8 @@ export const Sidebar = memo(function Sidebar({
|
||||||
};
|
};
|
||||||
}, [drag, onReorderProjects, onReorderTasks]);
|
}, [drag, onReorderProjects, onReorderTasks]);
|
||||||
|
|
||||||
const [createMenuOpen, setCreateMenuOpen] = useState(false);
|
const [createSelectOpen, setCreateSelectOpen] = useState(false);
|
||||||
const createMenuRef = useRef<HTMLDivElement>(null);
|
const selectOptions = useMemo(() => newTaskRepos.map((repo) => ({ id: repo.id, label: stripCommonOrgPrefix(repo.label, newTaskRepos) })), [newTaskRepos]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!createMenuOpen) return;
|
|
||||||
function handleClick(event: MouseEvent) {
|
|
||||||
if (createMenuRef.current && !createMenuRef.current.contains(event.target as Node)) {
|
|
||||||
setCreateMenuOpen(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
document.addEventListener("mousedown", handleClick);
|
|
||||||
return () => document.removeEventListener("mousedown", handleClick);
|
|
||||||
}, [createMenuOpen]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SPanel>
|
<SPanel>
|
||||||
|
|
@ -232,7 +233,99 @@ export const Sidebar = memo(function Sidebar({
|
||||||
<PanelLeft size={14} />
|
<PanelLeft size={14} />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div ref={createMenuRef} className={css({ position: "relative", flexShrink: 0 })}>
|
{createSelectOpen ? (
|
||||||
|
<div className={css({ flex: 1, minWidth: 0 })}>
|
||||||
|
<Select
|
||||||
|
options={selectOptions}
|
||||||
|
value={[]}
|
||||||
|
placeholder="Search repos..."
|
||||||
|
type="search"
|
||||||
|
openOnClick
|
||||||
|
autoFocus
|
||||||
|
onChange={({ value }: { value: Value }) => {
|
||||||
|
const selected = value[0];
|
||||||
|
if (selected) {
|
||||||
|
onSelectNewTaskRepo(selected.id as string);
|
||||||
|
setCreateSelectOpen(false);
|
||||||
|
onCreate(selected.id as string);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClose={() => setCreateSelectOpen(false)}
|
||||||
|
overrides={{
|
||||||
|
Root: {
|
||||||
|
style: {
|
||||||
|
width: "100%",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ControlContainer: {
|
||||||
|
style: {
|
||||||
|
backgroundColor: t.surfaceTertiary,
|
||||||
|
borderTopColor: t.borderSubtle,
|
||||||
|
borderBottomColor: t.borderSubtle,
|
||||||
|
borderLeftColor: t.borderSubtle,
|
||||||
|
borderRightColor: t.borderSubtle,
|
||||||
|
borderTopWidth: "1px",
|
||||||
|
borderBottomWidth: "1px",
|
||||||
|
borderLeftWidth: "1px",
|
||||||
|
borderRightWidth: "1px",
|
||||||
|
borderTopLeftRadius: "6px",
|
||||||
|
borderTopRightRadius: "6px",
|
||||||
|
borderBottomLeftRadius: "6px",
|
||||||
|
borderBottomRightRadius: "6px",
|
||||||
|
minHeight: "28px",
|
||||||
|
paddingLeft: "8px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ValueContainer: {
|
||||||
|
style: {
|
||||||
|
paddingTop: "0px",
|
||||||
|
paddingBottom: "0px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Input: {
|
||||||
|
style: {
|
||||||
|
fontSize: "12px",
|
||||||
|
color: t.textPrimary,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Placeholder: {
|
||||||
|
style: {
|
||||||
|
fontSize: "12px",
|
||||||
|
color: t.textMuted,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Dropdown: {
|
||||||
|
style: {
|
||||||
|
backgroundColor: t.surfaceElevated,
|
||||||
|
borderTopColor: t.borderDefault,
|
||||||
|
borderBottomColor: t.borderDefault,
|
||||||
|
borderLeftColor: t.borderDefault,
|
||||||
|
borderRightColor: t.borderDefault,
|
||||||
|
maxHeight: "min(320px, 50vh)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
DropdownListItem: {
|
||||||
|
style: {
|
||||||
|
fontSize: "12px",
|
||||||
|
paddingTop: "6px",
|
||||||
|
paddingBottom: "6px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
IconsContainer: {
|
||||||
|
style: {
|
||||||
|
paddingRight: "4px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
SearchIconContainer: {
|
||||||
|
style: {
|
||||||
|
paddingLeft: "0px",
|
||||||
|
paddingRight: "4px",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
|
|
@ -241,9 +334,9 @@ export const Sidebar = memo(function Sidebar({
|
||||||
if (newTaskRepos.length === 0) return;
|
if (newTaskRepos.length === 0) return;
|
||||||
if (newTaskRepos.length === 1) {
|
if (newTaskRepos.length === 1) {
|
||||||
onSelectNewTaskRepo(newTaskRepos[0]!.id);
|
onSelectNewTaskRepo(newTaskRepos[0]!.id);
|
||||||
onCreate();
|
onCreate(newTaskRepos[0]!.id);
|
||||||
} else {
|
} else {
|
||||||
setCreateMenuOpen((prev) => !prev);
|
setCreateSelectOpen(true);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
|
|
@ -251,9 +344,9 @@ export const Sidebar = memo(function Sidebar({
|
||||||
if (event.key === "Enter" || event.key === " ") {
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
if (newTaskRepos.length === 1) {
|
if (newTaskRepos.length === 1) {
|
||||||
onSelectNewTaskRepo(newTaskRepos[0]!.id);
|
onSelectNewTaskRepo(newTaskRepos[0]!.id);
|
||||||
onCreate();
|
onCreate(newTaskRepos[0]!.id);
|
||||||
} else {
|
} else {
|
||||||
setCreateMenuOpen((prev) => !prev);
|
setCreateSelectOpen(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|
@ -275,80 +368,7 @@ export const Sidebar = memo(function Sidebar({
|
||||||
>
|
>
|
||||||
<Plus size={14} style={{ display: "block" }} />
|
<Plus size={14} style={{ display: "block" }} />
|
||||||
</div>
|
</div>
|
||||||
{createMenuOpen && newTaskRepos.length > 1 ? (
|
)}
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
position: "absolute",
|
|
||||||
top: "100%",
|
|
||||||
right: 0,
|
|
||||||
marginTop: "4px",
|
|
||||||
zIndex: 9999,
|
|
||||||
minWidth: "200px",
|
|
||||||
borderRadius: "10px",
|
|
||||||
border: `1px solid ${t.borderDefault}`,
|
|
||||||
backgroundColor: t.surfaceElevated,
|
|
||||||
boxShadow: `${t.shadow}, 0 0 0 1px ${t.interactiveSubtle}`,
|
|
||||||
padding: "4px",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
gap: "2px",
|
|
||||||
maxHeight: "240px",
|
|
||||||
overflowY: "auto",
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{newTaskRepos.map((repo) => (
|
|
||||||
<button
|
|
||||||
key={repo.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
onSelectNewTaskRepo(repo.id);
|
|
||||||
setCreateMenuOpen(false);
|
|
||||||
onCreate();
|
|
||||||
}}
|
|
||||||
className={css({
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
gap: "10px",
|
|
||||||
width: "100%",
|
|
||||||
padding: "8px 12px",
|
|
||||||
borderRadius: "6px",
|
|
||||||
border: "none",
|
|
||||||
background: "transparent",
|
|
||||||
color: t.textSecondary,
|
|
||||||
cursor: "pointer",
|
|
||||||
fontSize: "13px",
|
|
||||||
fontWeight: 400,
|
|
||||||
textAlign: "left",
|
|
||||||
transition: "background 200ms ease, color 200ms ease",
|
|
||||||
":hover": {
|
|
||||||
backgroundColor: t.interactiveHover,
|
|
||||||
color: t.textPrimary,
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={css({
|
|
||||||
width: "18px",
|
|
||||||
height: "18px",
|
|
||||||
borderRadius: "4px",
|
|
||||||
background: `linear-gradient(135deg, ${projectIconColor(repo.label)}, ${projectIconColor(repo.label + "x")})`,
|
|
||||||
display: "flex",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
fontSize: "9px",
|
|
||||||
fontWeight: 700,
|
|
||||||
color: t.textOnAccent,
|
|
||||||
flexShrink: 0,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{projectInitial(repo.label)}
|
|
||||||
</span>
|
|
||||||
<span className={css({ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" })}>{repo.label}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</PanelHeaderBar>
|
</PanelHeaderBar>
|
||||||
<ScrollBody>
|
<ScrollBody>
|
||||||
<div className={css({ padding: "8px", display: "flex", flexDirection: "column", gap: "4px" })}>
|
<div className={css({ padding: "8px", display: "flex", flexDirection: "column", gap: "4px" })}>
|
||||||
|
|
@ -458,7 +478,7 @@ export const Sidebar = memo(function Sidebar({
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{project.label}
|
{stripCommonOrgPrefix(project.label, projects)}
|
||||||
</LabelSmall>
|
</LabelSmall>
|
||||||
</div>
|
</div>
|
||||||
<div className={css({ display: "flex", alignItems: "center", gap: "4px", flexShrink: 0 })}>
|
<div className={css({ display: "flex", alignItems: "center", gap: "4px", flexShrink: 0 })}>
|
||||||
|
|
@ -468,7 +488,7 @@ export const Sidebar = memo(function Sidebar({
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setHoveredProjectId(null);
|
setHoveredProjectId(null);
|
||||||
onSelectNewTaskRepo(project.id);
|
onSelectNewTaskRepo(project.id);
|
||||||
onCreate();
|
onCreate(project.id);
|
||||||
}}
|
}}
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
onMouseDown={(e) => e.stopPropagation()}
|
||||||
className={css({
|
className={css({
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,14 @@ export interface FoundryLoggerOptions {
|
||||||
service: string;
|
service: string;
|
||||||
bindings?: Record<string, unknown>;
|
bindings?: Record<string, unknown>;
|
||||||
level?: string;
|
level?: string;
|
||||||
|
format?: "json" | "logfmt";
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProcessLike = {
|
type ProcessLike = {
|
||||||
env?: Record<string, string | undefined>;
|
env?: Record<string, string | undefined>;
|
||||||
|
stdout?: {
|
||||||
|
write?: (chunk: string) => unknown;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
function resolveEnvVar(name: string): string | undefined {
|
function resolveEnvVar(name: string): string | undefined {
|
||||||
|
|
@ -28,6 +32,116 @@ function isBrowserRuntime(): boolean {
|
||||||
return typeof window !== "undefined" && typeof document !== "undefined";
|
return typeof window !== "undefined" && typeof document !== "undefined";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function serializeLogValue(value: unknown): string | number | boolean | null {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "bigint") {
|
||||||
|
return value.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value instanceof Error) {
|
||||||
|
return JSON.stringify({
|
||||||
|
name: value.name,
|
||||||
|
message: value.message,
|
||||||
|
stack: value.stack,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
} catch {
|
||||||
|
return "[unserializable]";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLogfmtValue(value: string | number | boolean | null): string {
|
||||||
|
if (typeof value === "number" || typeof value === "boolean") {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = value ?? "null";
|
||||||
|
if (raw.length > 0 && !/[\s="\\]/.test(raw)) {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `"${raw.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n")}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLogfmtLine(record: Record<string, unknown>): string {
|
||||||
|
return Object.entries(record)
|
||||||
|
.filter(([, value]) => value !== undefined)
|
||||||
|
.map(([key, value]) => `${key}=${formatLogfmtValue(serializeLogValue(value))}`)
|
||||||
|
.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringifyMessagePart(value: unknown): string {
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serialized = serializeLogValue(value);
|
||||||
|
return typeof serialized === "string" ? serialized : String(serialized);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLogRecord(level: string, bindings: Record<string, unknown>, args: Parameters<Logger["info"]>): Record<string, unknown> {
|
||||||
|
const record: Record<string, unknown> = {
|
||||||
|
time: new Date().toISOString(),
|
||||||
|
level,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(bindings)) {
|
||||||
|
if (key !== "time" && key !== "level" && key !== "msg" && value !== undefined) {
|
||||||
|
record[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.length === 0) {
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [first, ...rest] = args;
|
||||||
|
if (first && typeof first === "object") {
|
||||||
|
if (first instanceof Error) {
|
||||||
|
record.err = {
|
||||||
|
name: first.name,
|
||||||
|
message: first.message,
|
||||||
|
stack: first.stack,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
for (const [key, value] of Object.entries(first)) {
|
||||||
|
if (key !== "time" && key !== "level" && key !== "msg" && value !== undefined) {
|
||||||
|
record[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rest.length > 0) {
|
||||||
|
record.msg = rest.map(stringifyMessagePart).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
record.msg = [first, ...rest].map(stringifyMessagePart).join(" ");
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeLogfmtLine(line: string): void {
|
||||||
|
const processLike = (globalThis as { process?: ProcessLike }).process;
|
||||||
|
if (processLike?.stdout?.write) {
|
||||||
|
processLike.stdout.write(`${line}\n`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(line);
|
||||||
|
}
|
||||||
|
|
||||||
export function createFoundryLogger(options: FoundryLoggerOptions): Logger {
|
export function createFoundryLogger(options: FoundryLoggerOptions): Logger {
|
||||||
const browser = isBrowserRuntime();
|
const browser = isBrowserRuntime();
|
||||||
const loggerOptions: LoggerOptions = {
|
const loggerOptions: LoggerOptions = {
|
||||||
|
|
@ -44,6 +158,15 @@ export function createFoundryLogger(options: FoundryLoggerOptions): Logger {
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
loggerOptions.timestamp = pino.stdTimeFunctions.isoTime;
|
loggerOptions.timestamp = pino.stdTimeFunctions.isoTime;
|
||||||
|
if (options.format === "logfmt") {
|
||||||
|
loggerOptions.hooks = {
|
||||||
|
logMethod(this: Logger, args, _method, level) {
|
||||||
|
const levelLabel = this.levels.labels[level] ?? "info";
|
||||||
|
const record = buildLogRecord(levelLabel, this.bindings(), args);
|
||||||
|
writeLogfmtLine(formatLogfmtLine(record));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return pino(loggerOptions);
|
return pino(loggerOptions);
|
||||||
|
|
|
||||||
29
foundry/packages/shared/test/logging.test.ts
Normal file
29
foundry/packages/shared/test/logging.test.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createFoundryLogger } from "../src/logging.js";
|
||||||
|
|
||||||
|
describe("createFoundryLogger", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits logfmt output when requested", () => {
|
||||||
|
const writes: string[] = [];
|
||||||
|
const write = vi.fn((chunk: string | Uint8Array) => {
|
||||||
|
writes.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"));
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
vi.spyOn(process.stdout, "write").mockImplementation(write as typeof process.stdout.write);
|
||||||
|
|
||||||
|
const logger = createFoundryLogger({
|
||||||
|
service: "foundry-backend",
|
||||||
|
format: "logfmt",
|
||||||
|
}).child({
|
||||||
|
requestId: "req-123",
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info({ count: 2, nested: { ok: true } }, "backend started");
|
||||||
|
|
||||||
|
expect(write).toHaveBeenCalledTimes(1);
|
||||||
|
expect(writes[0]).toMatch(/^time=\S+ level=info service=foundry-backend requestId=req-123 count=2 nested="\{\\"ok\\":true\}" msg="backend started"\n$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
BIN
foundry/screenshots/dev-panel-github.png
Normal file
BIN
foundry/screenshots/dev-panel-github.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 672 KiB |
1332
foundry/scripts/data/rivet-dev.json
Normal file
1332
foundry/scripts/data/rivet-dev.json
Normal file
File diff suppressed because it is too large
Load diff
290
foundry/scripts/pull-org-data.ts
Normal file
290
foundry/scripts/pull-org-data.ts
Normal file
|
|
@ -0,0 +1,290 @@
|
||||||
|
#!/usr/bin/env bun
|
||||||
|
/**
|
||||||
|
* Pull public GitHub organization data into a JSON fixture file.
|
||||||
|
*
|
||||||
|
* This script mirrors the sync logic in the backend workspace actor
|
||||||
|
* (see: packages/backend/src/actors/workspace/app-shell.ts — syncGithubOrganizations
|
||||||
|
* and syncGithubOrganizationRepos). Keep the two in sync: when the backend
|
||||||
|
* sync workflow changes what data it fetches or how it structures organizations,
|
||||||
|
* update this script to match.
|
||||||
|
*
|
||||||
|
* Key difference from the backend sync: this script only fetches **public** data
|
||||||
|
* from the GitHub API (no auth token required, no private repos). It is used to
|
||||||
|
* populate realistic mock/test data for the Foundry frontend without needing
|
||||||
|
* GitHub OAuth credentials or a GitHub App installation.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* bun foundry/scripts/pull-org-data.ts <org-login> [--out <path>]
|
||||||
|
*
|
||||||
|
* Examples:
|
||||||
|
* bun foundry/scripts/pull-org-data.ts rivet-gg
|
||||||
|
* bun foundry/scripts/pull-org-data.ts rivet-gg --out foundry/scripts/data/rivet-gg.json
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { parseArgs } from "node:util";
|
||||||
|
import { writeFileSync, mkdirSync } from "node:fs";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
|
||||||
|
// ── Types matching the backend sync output ──
|
||||||
|
// See: packages/shared/src/app-shell.ts
|
||||||
|
|
||||||
|
interface OrgFixtureRepo {
|
||||||
|
fullName: string;
|
||||||
|
cloneUrl: string;
|
||||||
|
description: string | null;
|
||||||
|
language: string | null;
|
||||||
|
stars: number;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OrgFixtureMember {
|
||||||
|
id: string;
|
||||||
|
login: string;
|
||||||
|
avatarUrl: string;
|
||||||
|
role: "admin" | "member";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OrgFixturePullRequest {
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
state: "open";
|
||||||
|
draft: boolean;
|
||||||
|
headRefName: string;
|
||||||
|
author: string;
|
||||||
|
repoFullName: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OrgFixture {
|
||||||
|
/** ISO timestamp of when this data was pulled */
|
||||||
|
pulledAt: string;
|
||||||
|
/** GitHub organization login (e.g. "rivet-gg") */
|
||||||
|
login: string;
|
||||||
|
/** GitHub numeric ID */
|
||||||
|
id: number;
|
||||||
|
/** Display name */
|
||||||
|
name: string | null;
|
||||||
|
/** Organization description */
|
||||||
|
description: string | null;
|
||||||
|
/** Public email */
|
||||||
|
email: string | null;
|
||||||
|
/** Blog/website URL */
|
||||||
|
blog: string | null;
|
||||||
|
/** Avatar URL */
|
||||||
|
avatarUrl: string;
|
||||||
|
/** Public repositories (excludes forks by default) */
|
||||||
|
repos: OrgFixtureRepo[];
|
||||||
|
/** Public members (only those with public membership) */
|
||||||
|
members: OrgFixtureMember[];
|
||||||
|
/** Open pull requests across all public repos */
|
||||||
|
openPullRequests: OrgFixturePullRequest[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GitHub API helpers ──
|
||||||
|
// Mirrors the pagination approach in packages/backend/src/services/app-github.ts
|
||||||
|
|
||||||
|
const API_BASE = "https://api.github.com";
|
||||||
|
const GITHUB_TOKEN = process.env.GITHUB_TOKEN ?? process.env.GH_TOKEN ?? null;
|
||||||
|
|
||||||
|
function authHeaders(): Record<string, string> {
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
Accept: "application/vnd.github+json",
|
||||||
|
"X-GitHub-Api-Version": "2022-11-28",
|
||||||
|
"User-Agent": "foundry-pull-org-data/1.0",
|
||||||
|
};
|
||||||
|
if (GITHUB_TOKEN) {
|
||||||
|
headers["Authorization"] = `Bearer ${GITHUB_TOKEN}`;
|
||||||
|
}
|
||||||
|
return headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function githubGet<T>(url: string): Promise<T> {
|
||||||
|
const response = await fetch(url, { headers: authHeaders() });
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.text().catch(() => "");
|
||||||
|
throw new Error(`GitHub API ${response.status}: ${url}\n${body.slice(0, 500)}`);
|
||||||
|
}
|
||||||
|
return (await response.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNextLink(linkHeader: string | null): string | null {
|
||||||
|
if (!linkHeader) return null;
|
||||||
|
for (const part of linkHeader.split(",")) {
|
||||||
|
const [urlPart, relPart] = part.split(";").map((v) => v.trim());
|
||||||
|
if (urlPart && relPart?.includes('rel="next"')) {
|
||||||
|
return urlPart.replace(/^<|>$/g, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function githubPaginate<T>(path: string): Promise<T[]> {
|
||||||
|
let url: string | null = `${API_BASE}${path.startsWith("/") ? path : `/${path}`}`;
|
||||||
|
const items: T[] = [];
|
||||||
|
|
||||||
|
while (url) {
|
||||||
|
const response = await fetch(url, { headers: authHeaders() });
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.text().catch(() => "");
|
||||||
|
throw new Error(`GitHub API ${response.status}: ${url}\n${body.slice(0, 500)}`);
|
||||||
|
}
|
||||||
|
const page = (await response.json()) as T[];
|
||||||
|
items.push(...page);
|
||||||
|
url = parseNextLink(response.headers.get("link"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Main ──
|
||||||
|
|
||||||
|
async function pullOrgData(orgLogin: string): Promise<OrgFixture> {
|
||||||
|
console.log(`Fetching organization: ${orgLogin}`);
|
||||||
|
|
||||||
|
// 1. Fetch org profile
|
||||||
|
// Backend equivalent: getViewer() + listOrganizations() derive org identity
|
||||||
|
const org = await githubGet<{
|
||||||
|
id: number;
|
||||||
|
login: string;
|
||||||
|
name: string | null;
|
||||||
|
description: string | null;
|
||||||
|
email: string | null;
|
||||||
|
blog: string | null;
|
||||||
|
avatar_url: string;
|
||||||
|
public_repos: number;
|
||||||
|
public_members_url: string;
|
||||||
|
}>(`${API_BASE}/orgs/${orgLogin}`);
|
||||||
|
|
||||||
|
console.log(` ${org.name ?? org.login} — ${org.public_repos} public repos`);
|
||||||
|
|
||||||
|
// 2. Fetch public repos (non-fork, non-archived)
|
||||||
|
// Backend equivalent: listInstallationRepositories() or listUserRepositories()
|
||||||
|
// Key difference: we only fetch public repos here (type=public)
|
||||||
|
const rawRepos = await githubPaginate<{
|
||||||
|
full_name: string;
|
||||||
|
clone_url: string;
|
||||||
|
description: string | null;
|
||||||
|
language: string | null;
|
||||||
|
stargazers_count: number;
|
||||||
|
updated_at: string;
|
||||||
|
fork: boolean;
|
||||||
|
archived: boolean;
|
||||||
|
private: boolean;
|
||||||
|
}>(`/orgs/${orgLogin}/repos?per_page=100&type=public&sort=updated`);
|
||||||
|
|
||||||
|
const repos: OrgFixtureRepo[] = rawRepos
|
||||||
|
.filter((r) => !r.fork && !r.archived && !r.private)
|
||||||
|
.map((r) => ({
|
||||||
|
fullName: r.full_name,
|
||||||
|
cloneUrl: r.clone_url,
|
||||||
|
description: r.description,
|
||||||
|
language: r.language,
|
||||||
|
stars: r.stargazers_count,
|
||||||
|
updatedAt: r.updated_at,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => b.stars - a.stars);
|
||||||
|
|
||||||
|
console.log(` ${repos.length} public repos (excluding forks/archived)`);
|
||||||
|
|
||||||
|
// 3. Fetch public members
|
||||||
|
// Backend equivalent: members are derived from the OAuth user + org membership
|
||||||
|
// Here we can only see members with public membership visibility
|
||||||
|
const rawMembers = await githubPaginate<{
|
||||||
|
id: number;
|
||||||
|
login: string;
|
||||||
|
avatar_url: string;
|
||||||
|
}>(`/orgs/${orgLogin}/members?per_page=100`);
|
||||||
|
|
||||||
|
const members: OrgFixtureMember[] = rawMembers.map((m) => ({
|
||||||
|
id: String(m.id),
|
||||||
|
login: m.login,
|
||||||
|
avatarUrl: m.avatar_url,
|
||||||
|
role: "member" as const,
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log(` ${members.length} public members`);
|
||||||
|
|
||||||
|
// 4. Fetch open PRs across all public repos
|
||||||
|
// Backend equivalent: ProjectPrSyncActor polls GitHub for open PRs per repo
|
||||||
|
// and stores them in the pr_cache table on the project actor
|
||||||
|
const openPullRequests: OrgFixturePullRequest[] = [];
|
||||||
|
for (const repo of repos) {
|
||||||
|
const rawPrs = await githubPaginate<{
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
state: string;
|
||||||
|
draft: boolean;
|
||||||
|
head: { ref: string };
|
||||||
|
user: { login: string } | null;
|
||||||
|
updated_at: string;
|
||||||
|
}>(`/repos/${repo.fullName}/pulls?state=open&per_page=100`);
|
||||||
|
|
||||||
|
for (const pr of rawPrs) {
|
||||||
|
openPullRequests.push({
|
||||||
|
number: pr.number,
|
||||||
|
title: pr.title,
|
||||||
|
state: "open",
|
||||||
|
draft: pr.draft,
|
||||||
|
headRefName: pr.head.ref,
|
||||||
|
author: pr.user?.login ?? "unknown",
|
||||||
|
repoFullName: repo.fullName,
|
||||||
|
updatedAt: pr.updated_at,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rawPrs.length > 0) {
|
||||||
|
console.log(` ${repo.fullName}: ${rawPrs.length} open PRs`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` ${openPullRequests.length} total open PRs`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
pulledAt: new Date().toISOString(),
|
||||||
|
login: org.login,
|
||||||
|
id: org.id,
|
||||||
|
name: org.name,
|
||||||
|
description: org.description,
|
||||||
|
email: org.email,
|
||||||
|
blog: org.blog,
|
||||||
|
avatarUrl: org.avatar_url,
|
||||||
|
repos,
|
||||||
|
members,
|
||||||
|
openPullRequests,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CLI ──
|
||||||
|
|
||||||
|
const { values, positionals } = parseArgs({
|
||||||
|
args: process.argv.slice(2),
|
||||||
|
options: {
|
||||||
|
out: { type: "string", short: "o" },
|
||||||
|
help: { type: "boolean", short: "h" },
|
||||||
|
},
|
||||||
|
allowPositionals: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (values.help || positionals.length === 0) {
|
||||||
|
console.log("Usage: bun foundry/scripts/pull-org-data.ts <org-login> [--out <path>]");
|
||||||
|
console.log("");
|
||||||
|
console.log("Pulls public GitHub organization data into a JSON fixture file.");
|
||||||
|
console.log("Set GITHUB_TOKEN or GH_TOKEN to avoid rate limits.");
|
||||||
|
process.exit(positionals.length === 0 && !values.help ? 1 : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const orgLogin = positionals[0]!;
|
||||||
|
const defaultOutDir = resolve(import.meta.dirname ?? ".", "data");
|
||||||
|
const outPath = values.out ?? resolve(defaultOutDir, `${orgLogin}.json`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await pullOrgData(orgLogin);
|
||||||
|
|
||||||
|
mkdirSync(dirname(outPath), { recursive: true });
|
||||||
|
writeFileSync(outPath, JSON.stringify(data, null, 2) + "\n");
|
||||||
|
|
||||||
|
console.log(`\nWrote ${outPath}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error instanceof Error ? error.message : String(error));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue