mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 07:04:48 +00:00
wip (#253)
This commit is contained in:
parent
70d31f819c
commit
5ea9ec5e2f
47 changed files with 2605 additions and 669 deletions
|
|
@ -23,6 +23,9 @@ GITHUB_APP_PRIVATE_KEY=
|
||||||
# Webhook secret for verifying GitHub webhook payloads.
|
# Webhook secret for verifying GitHub webhook payloads.
|
||||||
# Use smee.io for local development: https://smee.io/new
|
# Use smee.io for local development: https://smee.io/new
|
||||||
GITHUB_WEBHOOK_SECRET=
|
GITHUB_WEBHOOK_SECRET=
|
||||||
|
# Required for local GitHub webhook forwarding in compose.dev.
|
||||||
|
SMEE_URL=
|
||||||
|
SMEE_TARGET=http://backend:7741/v1/webhooks/github
|
||||||
|
|
||||||
# Fill these in when enabling live Stripe billing.
|
# Fill these in when enabling live Stripe billing.
|
||||||
STRIPE_SECRET_KEY=
|
STRIPE_SECRET_KEY=
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,8 @@ Recommended GitHub App permissions:
|
||||||
|
|
||||||
Set the webhook URL to `https://<your-backend-host>/v1/webhooks/github` and generate a webhook secret. Store the secret as `GITHUB_WEBHOOK_SECRET`.
|
Set the webhook URL to `https://<your-backend-host>/v1/webhooks/github` and generate a webhook secret. Store the secret as `GITHUB_WEBHOOK_SECRET`.
|
||||||
|
|
||||||
|
This is required, not optional. Foundry depends on GitHub App webhook delivery for installation lifecycle changes, repo access changes, and ongoing repo / pull request sync. If the GitHub App is not installed for the workspace, or webhook delivery is misconfigured, Foundry will remain in an install / reconnect state and core GitHub-backed functionality will not work correctly.
|
||||||
|
|
||||||
Recommended webhook subscriptions:
|
Recommended webhook subscriptions:
|
||||||
|
|
||||||
- `installation`
|
- `installation`
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,7 @@ The client subscribes to `app` always, `workspace` when entering a workspace, `t
|
||||||
- If a requested UI cannot be implemented cleanly with an existing `BaseUI` component, stop and ask the user whether they are sure they want to diverge from the system.
|
- If a requested UI cannot be implemented cleanly with an existing `BaseUI` component, stop and ask the user whether they are sure they want to diverge from the system.
|
||||||
- In that case, recommend the closest existing `BaseUI` components or compositions that could satisfy the need before proposing custom UI work.
|
- In that case, recommend the closest existing `BaseUI` components or compositions that could satisfy the need before proposing custom UI work.
|
||||||
- Only introduce custom UI primitives when `BaseUI` and existing Foundry patterns are not sufficient, or when the user explicitly confirms they want the divergence.
|
- Only introduce custom UI primitives when `BaseUI` and existing Foundry patterns are not sufficient, or when the user explicitly confirms they want the divergence.
|
||||||
|
- **Styletron atomic CSS rule:** Never mix CSS shorthand properties with their longhand equivalents in the same style object (including nested pseudo-selectors like `:hover`), or in a base styled component whose consumers override with longhand via `$style`. This includes `padding`/`paddingLeft`, `margin`/`marginTop`, `background`/`backgroundColor`, `border`/`borderLeft`, etc. Styletron generates independent atomic classes for shorthand and longhand, so they conflict unpredictably. Use `backgroundColor: "transparent"` instead of `background: "none"` for button resets. Always use longhand properties when any side may be overridden individually.
|
||||||
|
|
||||||
## Runtime Policy
|
## Runtime Policy
|
||||||
|
|
||||||
|
|
@ -201,15 +202,37 @@ For all Rivet/RivetKit implementation:
|
||||||
- Do not build blocking flows that wait on external systems to become ready or complete. Prefer push-based progression driven by actor messages, events, webhooks, or queue/workflow state changes.
|
- Do not build blocking flows that wait on external systems to become ready or complete. Prefer push-based progression driven by actor messages, events, webhooks, or queue/workflow state changes.
|
||||||
- Use workflows/background commands for any repo sync, sandbox provisioning, agent install, branch restack/rebase, or other multi-step external work. Do not keep user-facing actions/requests open while that work runs.
|
- Use workflows/background commands for any repo sync, sandbox provisioning, agent install, branch restack/rebase, or other multi-step external work. Do not keep user-facing actions/requests open while that work runs.
|
||||||
- `send` policy: always `await` the `send(...)` call itself so enqueue failures surface immediately, but default to `wait: false`.
|
- `send` policy: always `await` the `send(...)` call itself so enqueue failures surface immediately, but default to `wait: false`.
|
||||||
- Only use `send(..., { wait: true })` for short, bounded local mutations (e.g. a DB write that returns a result the caller needs). Never use `wait: true` for operations that depend on external readiness, polling actors, provider setup, repo/network I/O, sandbox sessions, GitHub API calls, or long-running queue drains.
|
|
||||||
- Never self-send with `wait: true` from inside a workflow handler — the workflow processes one message at a time, so the handler would deadlock waiting for the new message to be dequeued.
|
- Never self-send with `wait: true` from inside a workflow handler — the workflow processes one message at a time, so the handler would deadlock waiting for the new message to be dequeued.
|
||||||
- When an action is void-returning and triggers external work, use `wait: false` and let the UI react to state changes pushed by the workflow.
|
|
||||||
- Request/action contract: wait only until the minimum resource needed for the client's next step exists. Example: task creation may wait for task actor creation/identity, but not for sandbox provisioning or session bootstrap.
|
|
||||||
- Read paths must not force refresh/sync work inline. Serve the latest cached projection, mark staleness explicitly, and trigger background refresh separately when needed.
|
- Read paths must not force refresh/sync work inline. Serve the latest cached projection, mark staleness explicitly, and trigger background refresh separately when needed.
|
||||||
- If a workflow needs to resume after some external work completes, model that as workflow state plus follow-up messages/events instead of holding the original request open.
|
- If a workflow needs to resume after some external work completes, model that as workflow state plus follow-up messages/events instead of holding the original request open.
|
||||||
- No retries: never add retry loops (`withRetries`, `setTimeout` retry, exponential backoff) anywhere in the codebase. If an operation fails, surface the error immediately. If a dependency is not ready yet, model that explicitly with workflow state and resume from a push/event instead of polling or retry loops.
|
- No retries: never add retry loops (`withRetries`, `setTimeout` retry, exponential backoff) anywhere in the codebase. If an operation fails, surface the error immediately. If a dependency is not ready yet, model that explicitly with workflow state and resume from a push/event instead of polling or retry loops.
|
||||||
- Never throw errors that expect the caller to retry (e.g. `throw new Error("... retry shortly")`). If a dependency is not ready, write the current state to the DB with an appropriate pending status, enqueue the async work, and return successfully. Let the client observe the pending → ready transition via push events.
|
- Never throw errors that expect the caller to retry (e.g. `throw new Error("... retry shortly")`). If a dependency is not ready, write the current state to the DB with an appropriate pending status, enqueue the async work, and return successfully. Let the client observe the pending → ready transition via push events.
|
||||||
- Action return contract: every action that creates a resource must write the resource record to the DB before returning, so the client can immediately query/render it. The record may have a pending status, but it must exist. Never return an ID that doesn't yet have a corresponding DB row.
|
- Action return contract: every action that creates a resource must write the resource record to the DB before returning, so the client can immediately query/render it. The record may have a pending status, but it must exist. Never return an ID that doesn't yet have a corresponding DB row.
|
||||||
|
|
||||||
|
### Action handler responsiveness
|
||||||
|
|
||||||
|
Action handlers must return fast. The pattern:
|
||||||
|
|
||||||
|
1. **Creating an entity** — `wait: true` is fine. Do the DB write, return the ID/record. The caller needs the ID to proceed. The record may have a pending status; that's expected.
|
||||||
|
2. **Enqueuing work** (sending a message, triggering a sandbox operation, starting a sync) — `wait: false`. Write any precondition state to the DB synchronously, enqueue the work, and return. The client observes progress via push events on the relevant topic (session status, task status, etc.).
|
||||||
|
3. **Validating preconditions** — check state synchronously in the action handler *before* enqueuing. If a precondition isn't met (e.g. session not ready, task not initialized), throw an error immediately. Do not implicitly provision missing dependencies or poll for readiness inside the action handler. It is the client's responsibility to ensure preconditions are met before calling the action.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- `createTask` → `wait: true` (returns `{ taskId }`), then enqueue provisioning with `wait: false`. Client sees task appear immediately with pending status, observes `ready` via workspace events.
|
||||||
|
- `sendWorkbenchMessage` → validate session is `ready` (throw if not), enqueue with `wait: false`. Client observes session transition to `running` → `idle` via session events.
|
||||||
|
- `createWorkbenchSession` → `wait: true` (returns `{ tabId }`), enqueue sandbox provisioning with `wait: false`. Client observes `pending_provision` → `ready` via task events.
|
||||||
|
|
||||||
|
Never use `wait: true` for operations that depend on external readiness, sandbox I/O, agent responses, git network operations, polling loops, or long-running queue drains. Never hold an action open while waiting for an external system to become ready — that is a polling/retry loop in disguise.
|
||||||
|
|
||||||
|
### Task creation: resolve metadata before creating the actor
|
||||||
|
|
||||||
|
When creating a task, all deterministic metadata (title, branch name) must be resolved synchronously in the parent actor (project) *before* the task actor is created. The task actor must never be created with null `branchName` or `title`.
|
||||||
|
|
||||||
|
- Title is derived from the task description via `deriveFallbackTitle()` — pure string manipulation, no external I/O.
|
||||||
|
- Branch name is derived from the title via `sanitizeBranchName()` + conflict checking against remote branches and the project's task index.
|
||||||
|
- The project actor already has the repo clone and task index. Do the git fetch + name resolution there.
|
||||||
|
- Do not defer naming to a background provision workflow. Do not poll for names to become available.
|
||||||
|
- The `onBranch` path (attaching to an existing branch) and the new-task path should both produce a fully-named task record on return.
|
||||||
- Actor handle policy:
|
- Actor handle policy:
|
||||||
- Prefer explicit `get` or explicit `create` based on workflow intent; do not default to `getOrCreate`.
|
- Prefer explicit `get` or explicit `create` based on workflow intent; do not default to `getOrCreate`.
|
||||||
- Use `get`/`getForId` when the actor is expected to already exist; if missing, surface an explicit `Actor not found` error with recovery context.
|
- Use `get`/`getForId` when the actor is expected to already exist; if missing, surface an explicit `Actor not found` error with recovery context.
|
||||||
|
|
@ -235,6 +258,8 @@ For all Rivet/RivetKit implementation:
|
||||||
- For Foundry live verification, use `rivet-dev/sandbox-agent-testing` as the default testing repo unless the task explicitly says otherwise.
|
- For Foundry live verification, use `rivet-dev/sandbox-agent-testing` as the default testing repo unless the task explicitly says otherwise.
|
||||||
- Secrets (e.g. `OPENAI_API_KEY`, `GITHUB_TOKEN`/`GH_TOKEN`) must be provided via environment variables, never hardcoded in the repo.
|
- Secrets (e.g. `OPENAI_API_KEY`, `GITHUB_TOKEN`/`GH_TOKEN`) must be provided via environment variables, never hardcoded in the repo.
|
||||||
- `~/misc/env.txt` and `~/misc/the-foundry.env` contain the expected local OpenAI + GitHub OAuth/App config for dev.
|
- `~/misc/env.txt` and `~/misc/the-foundry.env` contain the expected local OpenAI + GitHub OAuth/App config for dev.
|
||||||
|
- For local GitHub webhook development, use the configured Smee proxy (`SMEE_URL`) to forward deliveries into `POST /v1/webhooks/github`. Check `.env` / `foundry/.env` if you need the current channel URL.
|
||||||
|
- If GitHub repos, PRs, or install state are not showing up, verify that the GitHub App is installed for the workspace and that webhook delivery is enabled and healthy. Foundry depends on webhook events for GitHub-backed state; missing webhooks means the product will appear broken.
|
||||||
- Do not assume `gh auth token` is sufficient for Foundry task provisioning against private repos. Sandbox/bootstrap git clone, push, and PR flows require a repo-capable `GITHUB_TOKEN`/`GH_TOKEN` in the backend container.
|
- Do not assume `gh auth token` is sufficient for Foundry task provisioning against private repos. Sandbox/bootstrap git clone, push, and PR flows require a repo-capable `GITHUB_TOKEN`/`GH_TOKEN` in the backend container.
|
||||||
- Preferred product behavior for org workspaces is to mint a GitHub App installation token from the workspace installation and inject it into backend/sandbox git operations. Do not rely on an operator's ambient CLI auth as the long-term solution.
|
- Preferred product behavior for org workspaces is to mint a GitHub App installation token from the workspace installation and inject it into backend/sandbox git operations. Do not rely on an operator's ambient CLI auth as the long-term solution.
|
||||||
- Treat client E2E tests in `packages/client/test` as the primary end-to-end source of truth for product behavior.
|
- Treat client E2E tests in `packages/client/test` as the primary end-to-end source of truth for product behavior.
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,27 @@ services:
|
||||||
- "foundry_shared_node_modules:/app/foundry/packages/shared/node_modules"
|
- "foundry_shared_node_modules:/app/foundry/packages/shared/node_modules"
|
||||||
- "foundry_pnpm_store:/tmp/.local/share/pnpm/store"
|
- "foundry_pnpm_store:/tmp/.local/share/pnpm/store"
|
||||||
|
|
||||||
|
smee:
|
||||||
|
image: node:20-alpine
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
env_file:
|
||||||
|
- path: .env
|
||||||
|
required: false
|
||||||
|
environment:
|
||||||
|
SMEE_URL: "${SMEE_URL:-}"
|
||||||
|
SMEE_TARGET: "${SMEE_TARGET:-http://backend:7741/v1/webhooks/github}"
|
||||||
|
command:
|
||||||
|
- /bin/sh
|
||||||
|
- -lc
|
||||||
|
- |
|
||||||
|
if [ -z "$SMEE_URL" ]; then
|
||||||
|
echo "SMEE_URL is required for local GitHub webhook forwarding" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
exec npx --yes smee-client --url "$SMEE_URL" --target "$SMEE_TARGET"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
foundry_backend_root_node_modules: {}
|
foundry_backend_root_node_modules: {}
|
||||||
foundry_backend_backend_node_modules: {}
|
foundry_backend_backend_node_modules: {}
|
||||||
|
|
|
||||||
5
foundry/packages/backend/src/actors/github-data/db/db.ts
Normal file
5
foundry/packages/backend/src/actors/github-data/db/db.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { db } from "rivetkit/db/drizzle";
|
||||||
|
import * as schema from "./schema.js";
|
||||||
|
import migrations from "./migrations.js";
|
||||||
|
|
||||||
|
export const githubDataDb = db({ schema, migrations });
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
const journal = {
|
||||||
|
entries: [
|
||||||
|
{
|
||||||
|
idx: 0,
|
||||||
|
when: 1773446400000,
|
||||||
|
tag: "0000_github_data",
|
||||||
|
breakpoints: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export default {
|
||||||
|
journal,
|
||||||
|
migrations: {
|
||||||
|
m0000: `CREATE TABLE \`github_meta\` (
|
||||||
|
\`id\` integer PRIMARY KEY NOT NULL,
|
||||||
|
\`connected_account\` text NOT NULL,
|
||||||
|
\`installation_status\` text NOT NULL,
|
||||||
|
\`sync_status\` text NOT NULL,
|
||||||
|
\`installation_id\` integer,
|
||||||
|
\`last_sync_label\` text NOT NULL,
|
||||||
|
\`last_sync_at\` integer,
|
||||||
|
\`updated_at\` integer NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE \`github_repositories\` (
|
||||||
|
\`repo_id\` text PRIMARY KEY NOT NULL,
|
||||||
|
\`full_name\` text NOT NULL,
|
||||||
|
\`clone_url\` text NOT NULL,
|
||||||
|
\`private\` integer NOT NULL,
|
||||||
|
\`updated_at\` integer NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE \`github_members\` (
|
||||||
|
\`member_id\` text PRIMARY KEY NOT NULL,
|
||||||
|
\`login\` text NOT NULL,
|
||||||
|
\`display_name\` text NOT NULL,
|
||||||
|
\`email\` text,
|
||||||
|
\`role\` text,
|
||||||
|
\`state\` text NOT NULL,
|
||||||
|
\`updated_at\` integer NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE \`github_pull_requests\` (
|
||||||
|
\`pr_id\` text PRIMARY KEY NOT NULL,
|
||||||
|
\`repo_id\` text NOT NULL,
|
||||||
|
\`repo_full_name\` text NOT NULL,
|
||||||
|
\`number\` integer NOT NULL,
|
||||||
|
\`title\` text NOT NULL,
|
||||||
|
\`body\` text,
|
||||||
|
\`state\` text NOT NULL,
|
||||||
|
\`url\` text NOT NULL,
|
||||||
|
\`head_ref_name\` text NOT NULL,
|
||||||
|
\`base_ref_name\` text NOT NULL,
|
||||||
|
\`author_login\` text,
|
||||||
|
\`is_draft\` integer NOT NULL,
|
||||||
|
\`updated_at\` integer NOT NULL
|
||||||
|
);
|
||||||
|
`,
|
||||||
|
} as const,
|
||||||
|
};
|
||||||
46
foundry/packages/backend/src/actors/github-data/db/schema.ts
Normal file
46
foundry/packages/backend/src/actors/github-data/db/schema.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { integer, sqliteTable, text } from "rivetkit/db/drizzle";
|
||||||
|
|
||||||
|
export const githubMeta = sqliteTable("github_meta", {
|
||||||
|
id: integer("id").primaryKey(),
|
||||||
|
connectedAccount: text("connected_account").notNull(),
|
||||||
|
installationStatus: text("installation_status").notNull(),
|
||||||
|
syncStatus: text("sync_status").notNull(),
|
||||||
|
installationId: integer("installation_id"),
|
||||||
|
lastSyncLabel: text("last_sync_label").notNull(),
|
||||||
|
lastSyncAt: integer("last_sync_at"),
|
||||||
|
updatedAt: integer("updated_at").notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const githubRepositories = sqliteTable("github_repositories", {
|
||||||
|
repoId: text("repo_id").notNull().primaryKey(),
|
||||||
|
fullName: text("full_name").notNull(),
|
||||||
|
cloneUrl: text("clone_url").notNull(),
|
||||||
|
private: integer("private").notNull(),
|
||||||
|
updatedAt: integer("updated_at").notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const githubMembers = sqliteTable("github_members", {
|
||||||
|
memberId: text("member_id").notNull().primaryKey(),
|
||||||
|
login: text("login").notNull(),
|
||||||
|
displayName: text("display_name").notNull(),
|
||||||
|
email: text("email"),
|
||||||
|
role: text("role"),
|
||||||
|
state: text("state").notNull(),
|
||||||
|
updatedAt: integer("updated_at").notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const githubPullRequests = sqliteTable("github_pull_requests", {
|
||||||
|
prId: text("pr_id").notNull().primaryKey(),
|
||||||
|
repoId: text("repo_id").notNull(),
|
||||||
|
repoFullName: text("repo_full_name").notNull(),
|
||||||
|
number: integer("number").notNull(),
|
||||||
|
title: text("title").notNull(),
|
||||||
|
body: text("body"),
|
||||||
|
state: text("state").notNull(),
|
||||||
|
url: text("url").notNull(),
|
||||||
|
headRefName: text("head_ref_name").notNull(),
|
||||||
|
baseRefName: text("base_ref_name").notNull(),
|
||||||
|
authorLogin: text("author_login"),
|
||||||
|
isDraft: integer("is_draft").notNull(),
|
||||||
|
updatedAt: integer("updated_at").notNull(),
|
||||||
|
});
|
||||||
775
foundry/packages/backend/src/actors/github-data/index.ts
Normal file
775
foundry/packages/backend/src/actors/github-data/index.ts
Normal file
|
|
@ -0,0 +1,775 @@
|
||||||
|
// @ts-nocheck
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { actor } from "rivetkit";
|
||||||
|
import type { FoundryOrganization } from "@sandbox-agent/foundry-shared";
|
||||||
|
import { getActorRuntimeContext } from "../context.js";
|
||||||
|
import { getOrCreateWorkspace, getTask } from "../handles.js";
|
||||||
|
import { repoIdFromRemote } from "../../services/repo.js";
|
||||||
|
import { resolveWorkspaceGithubAuth } from "../../services/github-auth.js";
|
||||||
|
import { githubDataDb } from "./db/db.js";
|
||||||
|
import { githubMembers, githubMeta, githubPullRequests, githubRepositories } from "./db/schema.js";
|
||||||
|
|
||||||
|
const META_ROW_ID = 1;
|
||||||
|
|
||||||
|
interface GithubDataInput {
|
||||||
|
workspaceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GithubMemberRecord {
|
||||||
|
id: string;
|
||||||
|
login: string;
|
||||||
|
name: string;
|
||||||
|
email?: string | null;
|
||||||
|
role?: string | null;
|
||||||
|
state?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GithubRepositoryRecord {
|
||||||
|
fullName: string;
|
||||||
|
cloneUrl: string;
|
||||||
|
private: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GithubPullRequestRecord {
|
||||||
|
repoId: string;
|
||||||
|
repoFullName: string;
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
body: string | null;
|
||||||
|
state: string;
|
||||||
|
url: string;
|
||||||
|
headRefName: string;
|
||||||
|
baseRefName: string;
|
||||||
|
authorLogin: string | null;
|
||||||
|
isDraft: boolean;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FullSyncInput {
|
||||||
|
connectedAccount?: string | null;
|
||||||
|
installationStatus?: FoundryOrganization["github"]["installationStatus"];
|
||||||
|
installationId?: number | null;
|
||||||
|
githubLogin?: string | null;
|
||||||
|
kind?: FoundryOrganization["kind"] | null;
|
||||||
|
accessToken?: string | null;
|
||||||
|
label?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClearStateInput {
|
||||||
|
connectedAccount: string;
|
||||||
|
installationStatus: FoundryOrganization["github"]["installationStatus"];
|
||||||
|
installationId: number | null;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PullRequestWebhookInput {
|
||||||
|
connectedAccount: string;
|
||||||
|
installationStatus: FoundryOrganization["github"]["installationStatus"];
|
||||||
|
installationId: number | null;
|
||||||
|
repository: {
|
||||||
|
fullName: string;
|
||||||
|
cloneUrl: string;
|
||||||
|
private: boolean;
|
||||||
|
};
|
||||||
|
pullRequest: {
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
body: string | null;
|
||||||
|
state: string;
|
||||||
|
url: string;
|
||||||
|
headRefName: string;
|
||||||
|
baseRefName: string;
|
||||||
|
authorLogin: string | null;
|
||||||
|
isDraft: boolean;
|
||||||
|
merged?: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePrStatus(input: { state: string; isDraft?: boolean; merged?: boolean }): "OPEN" | "DRAFT" | "CLOSED" | "MERGED" {
|
||||||
|
const state = input.state.trim().toUpperCase();
|
||||||
|
if (input.merged || state === "MERGED") return "MERGED";
|
||||||
|
if (state === "CLOSED") return "CLOSED";
|
||||||
|
return input.isDraft ? "DRAFT" : "OPEN";
|
||||||
|
}
|
||||||
|
|
||||||
|
function pullRequestSummaryFromRow(row: any) {
|
||||||
|
return {
|
||||||
|
prId: row.prId,
|
||||||
|
repoId: row.repoId,
|
||||||
|
repoFullName: row.repoFullName,
|
||||||
|
number: row.number,
|
||||||
|
title: row.title,
|
||||||
|
state: row.state,
|
||||||
|
url: row.url,
|
||||||
|
headRefName: row.headRefName,
|
||||||
|
baseRefName: row.baseRefName,
|
||||||
|
authorLogin: row.authorLogin ?? null,
|
||||||
|
isDraft: Boolean(row.isDraft),
|
||||||
|
updatedAtMs: row.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readMeta(c: any) {
|
||||||
|
const row = await c.db.select().from(githubMeta).where(eq(githubMeta.id, META_ROW_ID)).get();
|
||||||
|
return {
|
||||||
|
connectedAccount: row?.connectedAccount ?? "",
|
||||||
|
installationStatus: (row?.installationStatus ?? "install_required") as FoundryOrganization["github"]["installationStatus"],
|
||||||
|
syncStatus: (row?.syncStatus ?? "pending") as FoundryOrganization["github"]["syncStatus"],
|
||||||
|
installationId: row?.installationId ?? null,
|
||||||
|
lastSyncLabel: row?.lastSyncLabel ?? "Waiting for first import",
|
||||||
|
lastSyncAt: row?.lastSyncAt ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeMeta(c: any, patch: Partial<Awaited<ReturnType<typeof readMeta>>>) {
|
||||||
|
const current = await readMeta(c);
|
||||||
|
const next = {
|
||||||
|
...current,
|
||||||
|
...patch,
|
||||||
|
};
|
||||||
|
await c.db
|
||||||
|
.insert(githubMeta)
|
||||||
|
.values({
|
||||||
|
id: META_ROW_ID,
|
||||||
|
connectedAccount: next.connectedAccount,
|
||||||
|
installationStatus: next.installationStatus,
|
||||||
|
syncStatus: next.syncStatus,
|
||||||
|
installationId: next.installationId,
|
||||||
|
lastSyncLabel: next.lastSyncLabel,
|
||||||
|
lastSyncAt: next.lastSyncAt,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: githubMeta.id,
|
||||||
|
set: {
|
||||||
|
connectedAccount: next.connectedAccount,
|
||||||
|
installationStatus: next.installationStatus,
|
||||||
|
syncStatus: next.syncStatus,
|
||||||
|
installationId: next.installationId,
|
||||||
|
lastSyncLabel: next.lastSyncLabel,
|
||||||
|
lastSyncAt: next.lastSyncAt,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getOrganizationContext(c: any, overrides?: FullSyncInput) {
|
||||||
|
const workspace = await getOrCreateWorkspace(c, c.state.workspaceId);
|
||||||
|
const organization = await workspace.getOrganizationShellStateIfInitialized({});
|
||||||
|
if (!organization) {
|
||||||
|
throw new Error(`Workspace ${c.state.workspaceId} is not initialized`);
|
||||||
|
}
|
||||||
|
const auth = await resolveWorkspaceGithubAuth(c, c.state.workspaceId);
|
||||||
|
return {
|
||||||
|
kind: overrides?.kind ?? organization.snapshot.kind,
|
||||||
|
githubLogin: overrides?.githubLogin ?? organization.githubLogin,
|
||||||
|
connectedAccount: overrides?.connectedAccount ?? organization.snapshot.github.connectedAccount ?? organization.githubLogin,
|
||||||
|
installationId: overrides?.installationId ?? organization.githubInstallationId ?? null,
|
||||||
|
installationStatus:
|
||||||
|
overrides?.installationStatus ??
|
||||||
|
organization.snapshot.github.installationStatus ??
|
||||||
|
(organization.snapshot.kind === "personal" ? "connected" : "reconnect_required"),
|
||||||
|
accessToken: overrides?.accessToken ?? auth?.githubToken ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function replaceRepositories(c: any, repositories: GithubRepositoryRecord[], updatedAt: number) {
|
||||||
|
await c.db.delete(githubRepositories).run();
|
||||||
|
for (const repository of repositories) {
|
||||||
|
await c.db
|
||||||
|
.insert(githubRepositories)
|
||||||
|
.values({
|
||||||
|
repoId: repoIdFromRemote(repository.cloneUrl),
|
||||||
|
fullName: repository.fullName,
|
||||||
|
cloneUrl: repository.cloneUrl,
|
||||||
|
private: repository.private ? 1 : 0,
|
||||||
|
updatedAt,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function replaceMembers(c: any, members: GithubMemberRecord[], updatedAt: number) {
|
||||||
|
await c.db.delete(githubMembers).run();
|
||||||
|
for (const member of members) {
|
||||||
|
await c.db
|
||||||
|
.insert(githubMembers)
|
||||||
|
.values({
|
||||||
|
memberId: member.id,
|
||||||
|
login: member.login,
|
||||||
|
displayName: member.name || member.login,
|
||||||
|
email: member.email ?? null,
|
||||||
|
role: member.role ?? null,
|
||||||
|
state: member.state ?? "active",
|
||||||
|
updatedAt,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function replacePullRequests(c: any, pullRequests: GithubPullRequestRecord[]) {
|
||||||
|
await c.db.delete(githubPullRequests).run();
|
||||||
|
for (const pullRequest of pullRequests) {
|
||||||
|
await c.db
|
||||||
|
.insert(githubPullRequests)
|
||||||
|
.values({
|
||||||
|
prId: `${pullRequest.repoId}#${pullRequest.number}`,
|
||||||
|
repoId: pullRequest.repoId,
|
||||||
|
repoFullName: pullRequest.repoFullName,
|
||||||
|
number: pullRequest.number,
|
||||||
|
title: pullRequest.title,
|
||||||
|
body: pullRequest.body ?? null,
|
||||||
|
state: pullRequest.state,
|
||||||
|
url: pullRequest.url,
|
||||||
|
headRefName: pullRequest.headRefName,
|
||||||
|
baseRefName: pullRequest.baseRefName,
|
||||||
|
authorLogin: pullRequest.authorLogin ?? null,
|
||||||
|
isDraft: pullRequest.isDraft ? 1 : 0,
|
||||||
|
updatedAt: pullRequest.updatedAt,
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshTaskSummaryForBranch(c: any, repoId: string, branchName: string) {
|
||||||
|
const workspace = await getOrCreateWorkspace(c, c.state.workspaceId);
|
||||||
|
await workspace.refreshTaskSummaryForGithubBranch({ repoId, branchName });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function emitPullRequestChangeEvents(c: any, beforeRows: any[], afterRows: any[]) {
|
||||||
|
const workspace = await getOrCreateWorkspace(c, c.state.workspaceId);
|
||||||
|
const beforeById = new Map(beforeRows.map((row) => [row.prId, row]));
|
||||||
|
const afterById = new Map(afterRows.map((row) => [row.prId, row]));
|
||||||
|
|
||||||
|
for (const [prId, row] of afterById) {
|
||||||
|
const previous = beforeById.get(prId);
|
||||||
|
const changed =
|
||||||
|
!previous ||
|
||||||
|
previous.title !== row.title ||
|
||||||
|
previous.state !== row.state ||
|
||||||
|
previous.url !== row.url ||
|
||||||
|
previous.headRefName !== row.headRefName ||
|
||||||
|
previous.baseRefName !== row.baseRefName ||
|
||||||
|
previous.authorLogin !== row.authorLogin ||
|
||||||
|
previous.isDraft !== row.isDraft ||
|
||||||
|
previous.updatedAt !== row.updatedAt;
|
||||||
|
if (!changed) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await workspace.applyOpenPullRequestUpdate({
|
||||||
|
pullRequest: pullRequestSummaryFromRow(row),
|
||||||
|
});
|
||||||
|
await refreshTaskSummaryForBranch(c, row.repoId, row.headRefName);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [prId, row] of beforeById) {
|
||||||
|
if (afterById.has(prId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await workspace.removeOpenPullRequest({ prId });
|
||||||
|
await refreshTaskSummaryForBranch(c, row.repoId, row.headRefName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function autoArchiveTaskForClosedPullRequest(c: any, row: any) {
|
||||||
|
const workspace = await getOrCreateWorkspace(c, c.state.workspaceId);
|
||||||
|
const match = await workspace.findTaskForGithubBranch({
|
||||||
|
repoId: row.repoId,
|
||||||
|
branchName: row.headRefName,
|
||||||
|
});
|
||||||
|
if (!match?.taskId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const task = getTask(c, c.state.workspaceId, row.repoId, match.taskId);
|
||||||
|
await task.archive({ reason: `PR ${String(row.state).toLowerCase()}` });
|
||||||
|
} catch {
|
||||||
|
// Best-effort only. Task summary refresh will still clear the PR state.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveRepositories(c: any, context: Awaited<ReturnType<typeof getOrganizationContext>>): Promise<GithubRepositoryRecord[]> {
|
||||||
|
const { appShell } = getActorRuntimeContext();
|
||||||
|
if (context.kind === "personal") {
|
||||||
|
if (!context.accessToken) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return await appShell.github.listUserRepositories(context.accessToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.installationId != null) {
|
||||||
|
try {
|
||||||
|
return await appShell.github.listInstallationRepositories(context.installationId);
|
||||||
|
} catch (error) {
|
||||||
|
if (!context.accessToken) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!context.accessToken) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await appShell.github.listUserRepositories(context.accessToken)).filter((repository) => repository.fullName.startsWith(`${context.githubLogin}/`));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveMembers(c: any, context: Awaited<ReturnType<typeof getOrganizationContext>>): Promise<GithubMemberRecord[]> {
|
||||||
|
const { appShell } = getActorRuntimeContext();
|
||||||
|
if (context.kind === "personal") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (context.installationId != null) {
|
||||||
|
try {
|
||||||
|
return await appShell.github.listInstallationMembers(context.installationId, context.githubLogin);
|
||||||
|
} catch (error) {
|
||||||
|
if (!context.accessToken) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!context.accessToken) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return await appShell.github.listOrganizationMembers(context.accessToken, context.githubLogin);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolvePullRequests(
|
||||||
|
c: any,
|
||||||
|
context: Awaited<ReturnType<typeof getOrganizationContext>>,
|
||||||
|
repositories: GithubRepositoryRecord[],
|
||||||
|
): Promise<GithubPullRequestRecord[]> {
|
||||||
|
const { appShell } = getActorRuntimeContext();
|
||||||
|
if (repositories.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
let pullRequests: Array<{
|
||||||
|
repoFullName: string;
|
||||||
|
cloneUrl: string;
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
body?: string | null;
|
||||||
|
state: string;
|
||||||
|
url: string;
|
||||||
|
headRefName: string;
|
||||||
|
baseRefName: string;
|
||||||
|
authorLogin?: string | null;
|
||||||
|
isDraft?: boolean;
|
||||||
|
merged?: boolean;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
if (context.installationId != null) {
|
||||||
|
try {
|
||||||
|
pullRequests = await appShell.github.listInstallationPullRequestsForRepositories(context.installationId, repositories);
|
||||||
|
} catch (error) {
|
||||||
|
if (!context.accessToken) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pullRequests.length === 0 && context.accessToken) {
|
||||||
|
pullRequests = await appShell.github.listPullRequestsForUserRepositories(context.accessToken, repositories);
|
||||||
|
}
|
||||||
|
|
||||||
|
return pullRequests.map((pullRequest) => ({
|
||||||
|
repoId: repoIdFromRemote(pullRequest.cloneUrl),
|
||||||
|
repoFullName: pullRequest.repoFullName,
|
||||||
|
number: pullRequest.number,
|
||||||
|
title: pullRequest.title,
|
||||||
|
body: pullRequest.body ?? null,
|
||||||
|
state: normalizePrStatus(pullRequest),
|
||||||
|
url: pullRequest.url,
|
||||||
|
headRefName: pullRequest.headRefName,
|
||||||
|
baseRefName: pullRequest.baseRefName,
|
||||||
|
authorLogin: pullRequest.authorLogin ?? null,
|
||||||
|
isDraft: Boolean(pullRequest.isDraft),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readAllPullRequestRows(c: any) {
|
||||||
|
return await c.db.select().from(githubPullRequests).all();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runFullSync(c: any, input: FullSyncInput = {}) {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const beforeRows = await readAllPullRequestRows(c);
|
||||||
|
const context = await getOrganizationContext(c, input);
|
||||||
|
|
||||||
|
await writeMeta(c, {
|
||||||
|
connectedAccount: context.connectedAccount,
|
||||||
|
installationStatus: context.installationStatus,
|
||||||
|
installationId: context.installationId,
|
||||||
|
syncStatus: "syncing",
|
||||||
|
lastSyncLabel: input.label?.trim() || "Syncing GitHub data...",
|
||||||
|
});
|
||||||
|
|
||||||
|
const repositories = await resolveRepositories(c, context);
|
||||||
|
const members = await resolveMembers(c, context);
|
||||||
|
const pullRequests = await resolvePullRequests(c, context, repositories);
|
||||||
|
|
||||||
|
await replaceRepositories(c, repositories, startedAt);
|
||||||
|
await replaceMembers(c, members, startedAt);
|
||||||
|
await replacePullRequests(c, pullRequests);
|
||||||
|
|
||||||
|
const workspace = await getOrCreateWorkspace(c, c.state.workspaceId);
|
||||||
|
await workspace.applyGithubDataProjection({
|
||||||
|
connectedAccount: context.connectedAccount,
|
||||||
|
installationStatus: context.installationStatus,
|
||||||
|
installationId: context.installationId,
|
||||||
|
syncStatus: "synced",
|
||||||
|
lastSyncLabel: repositories.length > 0 ? `Synced ${repositories.length} repositories` : "No repositories available",
|
||||||
|
lastSyncAt: startedAt,
|
||||||
|
repositories,
|
||||||
|
});
|
||||||
|
|
||||||
|
const meta = await writeMeta(c, {
|
||||||
|
connectedAccount: context.connectedAccount,
|
||||||
|
installationStatus: context.installationStatus,
|
||||||
|
installationId: context.installationId,
|
||||||
|
syncStatus: "synced",
|
||||||
|
lastSyncLabel: repositories.length > 0 ? `Synced ${repositories.length} repositories` : "No repositories available",
|
||||||
|
lastSyncAt: startedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
const afterRows = await readAllPullRequestRows(c);
|
||||||
|
await emitPullRequestChangeEvents(c, beforeRows, afterRows);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...meta,
|
||||||
|
repositoryCount: repositories.length,
|
||||||
|
memberCount: members.length,
|
||||||
|
pullRequestCount: afterRows.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const githubData = actor({
|
||||||
|
db: githubDataDb,
|
||||||
|
options: {
|
||||||
|
name: "GitHub Data",
|
||||||
|
icon: "github",
|
||||||
|
actionTimeout: 5 * 60_000,
|
||||||
|
},
|
||||||
|
createState: (_c, input: GithubDataInput) => ({
|
||||||
|
workspaceId: input.workspaceId,
|
||||||
|
}),
|
||||||
|
actions: {
|
||||||
|
async getSummary(c) {
|
||||||
|
const repositories = await c.db.select().from(githubRepositories).all();
|
||||||
|
const members = await c.db.select().from(githubMembers).all();
|
||||||
|
const pullRequests = await c.db.select().from(githubPullRequests).all();
|
||||||
|
return {
|
||||||
|
...(await readMeta(c)),
|
||||||
|
repositoryCount: repositories.length,
|
||||||
|
memberCount: members.length,
|
||||||
|
pullRequestCount: pullRequests.length,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async listRepositories(c) {
|
||||||
|
const rows = await c.db.select().from(githubRepositories).all();
|
||||||
|
return rows.map((row) => ({
|
||||||
|
repoId: row.repoId,
|
||||||
|
fullName: row.fullName,
|
||||||
|
cloneUrl: row.cloneUrl,
|
||||||
|
private: Boolean(row.private),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
async listPullRequestsForRepository(c, input: { repoId: string }) {
|
||||||
|
const rows = await c.db.select().from(githubPullRequests).where(eq(githubPullRequests.repoId, input.repoId)).all();
|
||||||
|
return rows.map(pullRequestSummaryFromRow);
|
||||||
|
},
|
||||||
|
|
||||||
|
async listOpenPullRequests(c) {
|
||||||
|
const rows = await c.db.select().from(githubPullRequests).all();
|
||||||
|
return rows.map(pullRequestSummaryFromRow).sort((left, right) => right.updatedAtMs - left.updatedAtMs);
|
||||||
|
},
|
||||||
|
|
||||||
|
async getPullRequestForBranch(c, input: { repoId: string; branchName: string }) {
|
||||||
|
const rows = await c.db.select().from(githubPullRequests).where(eq(githubPullRequests.repoId, input.repoId)).all();
|
||||||
|
const match = rows.find((candidate) => candidate.headRefName === input.branchName) ?? null;
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
number: match.number,
|
||||||
|
status: match.isDraft ? ("draft" as const) : ("ready" as const),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async fullSync(c, input: FullSyncInput = {}) {
|
||||||
|
return await runFullSync(c, input);
|
||||||
|
},
|
||||||
|
|
||||||
|
async reloadOrganization(c) {
|
||||||
|
return await runFullSync(c, { label: "Reloading GitHub organization..." });
|
||||||
|
},
|
||||||
|
|
||||||
|
async reloadAllPullRequests(c) {
|
||||||
|
return await runFullSync(c, { label: "Reloading GitHub pull requests..." });
|
||||||
|
},
|
||||||
|
|
||||||
|
async reloadRepository(c, input: { repoId: string }) {
|
||||||
|
const context = await getOrganizationContext(c);
|
||||||
|
const current = await c.db.select().from(githubRepositories).where(eq(githubRepositories.repoId, input.repoId)).get();
|
||||||
|
if (!current) {
|
||||||
|
throw new Error(`Unknown GitHub repository: ${input.repoId}`);
|
||||||
|
}
|
||||||
|
const { appShell } = getActorRuntimeContext();
|
||||||
|
const repository =
|
||||||
|
context.installationId != null
|
||||||
|
? await appShell.github.getInstallationRepository(context.installationId, current.fullName)
|
||||||
|
: context.accessToken
|
||||||
|
? await appShell.github.getUserRepository(context.accessToken, current.fullName)
|
||||||
|
: null;
|
||||||
|
if (!repository) {
|
||||||
|
throw new Error(`Unable to reload repository: ${current.fullName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedAt = Date.now();
|
||||||
|
await c.db
|
||||||
|
.insert(githubRepositories)
|
||||||
|
.values({
|
||||||
|
repoId: input.repoId,
|
||||||
|
fullName: repository.fullName,
|
||||||
|
cloneUrl: repository.cloneUrl,
|
||||||
|
private: repository.private ? 1 : 0,
|
||||||
|
updatedAt,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: githubRepositories.repoId,
|
||||||
|
set: {
|
||||||
|
fullName: repository.fullName,
|
||||||
|
cloneUrl: repository.cloneUrl,
|
||||||
|
private: repository.private ? 1 : 0,
|
||||||
|
updatedAt,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
const workspace = await getOrCreateWorkspace(c, c.state.workspaceId);
|
||||||
|
await workspace.applyGithubRepositoryProjection({
|
||||||
|
repoId: input.repoId,
|
||||||
|
remoteUrl: repository.cloneUrl,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
repoId: input.repoId,
|
||||||
|
fullName: repository.fullName,
|
||||||
|
cloneUrl: repository.cloneUrl,
|
||||||
|
private: repository.private,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async reloadPullRequest(c, input: { repoId: string; prNumber: number }) {
|
||||||
|
const repository = await c.db.select().from(githubRepositories).where(eq(githubRepositories.repoId, input.repoId)).get();
|
||||||
|
if (!repository) {
|
||||||
|
throw new Error(`Unknown GitHub repository: ${input.repoId}`);
|
||||||
|
}
|
||||||
|
const context = await getOrganizationContext(c);
|
||||||
|
const { appShell } = getActorRuntimeContext();
|
||||||
|
const pullRequest =
|
||||||
|
context.installationId != null
|
||||||
|
? await appShell.github.getInstallationPullRequest(context.installationId, repository.fullName, input.prNumber)
|
||||||
|
: context.accessToken
|
||||||
|
? await appShell.github.getUserPullRequest(context.accessToken, repository.fullName, input.prNumber)
|
||||||
|
: null;
|
||||||
|
if (!pullRequest) {
|
||||||
|
throw new Error(`Unable to reload pull request #${input.prNumber} for ${repository.fullName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const beforeRows = await readAllPullRequestRows(c);
|
||||||
|
const updatedAt = Date.now();
|
||||||
|
const nextState = normalizePrStatus(pullRequest);
|
||||||
|
const prId = `${input.repoId}#${input.prNumber}`;
|
||||||
|
if (nextState === "CLOSED" || nextState === "MERGED") {
|
||||||
|
await c.db.delete(githubPullRequests).where(eq(githubPullRequests.prId, prId)).run();
|
||||||
|
} else {
|
||||||
|
await c.db
|
||||||
|
.insert(githubPullRequests)
|
||||||
|
.values({
|
||||||
|
prId,
|
||||||
|
repoId: input.repoId,
|
||||||
|
repoFullName: repository.fullName,
|
||||||
|
number: pullRequest.number,
|
||||||
|
title: pullRequest.title,
|
||||||
|
body: pullRequest.body ?? null,
|
||||||
|
state: nextState,
|
||||||
|
url: pullRequest.url,
|
||||||
|
headRefName: pullRequest.headRefName,
|
||||||
|
baseRefName: pullRequest.baseRefName,
|
||||||
|
authorLogin: pullRequest.authorLogin ?? null,
|
||||||
|
isDraft: pullRequest.isDraft ? 1 : 0,
|
||||||
|
updatedAt,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: githubPullRequests.prId,
|
||||||
|
set: {
|
||||||
|
title: pullRequest.title,
|
||||||
|
body: pullRequest.body ?? null,
|
||||||
|
state: nextState,
|
||||||
|
url: pullRequest.url,
|
||||||
|
headRefName: pullRequest.headRefName,
|
||||||
|
baseRefName: pullRequest.baseRefName,
|
||||||
|
authorLogin: pullRequest.authorLogin ?? null,
|
||||||
|
isDraft: pullRequest.isDraft ? 1 : 0,
|
||||||
|
updatedAt,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
const afterRows = await readAllPullRequestRows(c);
|
||||||
|
await emitPullRequestChangeEvents(c, beforeRows, afterRows);
|
||||||
|
const closed = afterRows.find((row) => row.prId === prId);
|
||||||
|
if (!closed && (nextState === "CLOSED" || nextState === "MERGED")) {
|
||||||
|
const previous = beforeRows.find((row) => row.prId === prId);
|
||||||
|
if (previous) {
|
||||||
|
await autoArchiveTaskForClosedPullRequest(c, {
|
||||||
|
...previous,
|
||||||
|
state: nextState,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pullRequestSummaryFromRow(
|
||||||
|
afterRows.find((row) => row.prId === prId) ?? {
|
||||||
|
prId,
|
||||||
|
repoId: input.repoId,
|
||||||
|
repoFullName: repository.fullName,
|
||||||
|
number: input.prNumber,
|
||||||
|
title: pullRequest.title,
|
||||||
|
state: nextState,
|
||||||
|
url: pullRequest.url,
|
||||||
|
headRefName: pullRequest.headRefName,
|
||||||
|
baseRefName: pullRequest.baseRefName,
|
||||||
|
authorLogin: pullRequest.authorLogin ?? null,
|
||||||
|
isDraft: pullRequest.isDraft ? 1 : 0,
|
||||||
|
updatedAt,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
async clearState(c, input: ClearStateInput) {
|
||||||
|
const beforeRows = await readAllPullRequestRows(c);
|
||||||
|
await c.db.delete(githubPullRequests).run();
|
||||||
|
await c.db.delete(githubRepositories).run();
|
||||||
|
await c.db.delete(githubMembers).run();
|
||||||
|
await writeMeta(c, {
|
||||||
|
connectedAccount: input.connectedAccount,
|
||||||
|
installationStatus: input.installationStatus,
|
||||||
|
installationId: input.installationId,
|
||||||
|
syncStatus: "pending",
|
||||||
|
lastSyncLabel: input.label,
|
||||||
|
lastSyncAt: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const workspace = await getOrCreateWorkspace(c, c.state.workspaceId);
|
||||||
|
await workspace.applyGithubDataProjection({
|
||||||
|
connectedAccount: input.connectedAccount,
|
||||||
|
installationStatus: input.installationStatus,
|
||||||
|
installationId: input.installationId,
|
||||||
|
syncStatus: "pending",
|
||||||
|
lastSyncLabel: input.label,
|
||||||
|
lastSyncAt: null,
|
||||||
|
repositories: [],
|
||||||
|
});
|
||||||
|
await emitPullRequestChangeEvents(c, beforeRows, []);
|
||||||
|
},
|
||||||
|
|
||||||
|
async handlePullRequestWebhook(c, input: PullRequestWebhookInput) {
|
||||||
|
const beforeRows = await readAllPullRequestRows(c);
|
||||||
|
const repoId = repoIdFromRemote(input.repository.cloneUrl);
|
||||||
|
const updatedAt = Date.now();
|
||||||
|
const state = normalizePrStatus(input.pullRequest);
|
||||||
|
const prId = `${repoId}#${input.pullRequest.number}`;
|
||||||
|
|
||||||
|
await c.db
|
||||||
|
.insert(githubRepositories)
|
||||||
|
.values({
|
||||||
|
repoId,
|
||||||
|
fullName: input.repository.fullName,
|
||||||
|
cloneUrl: input.repository.cloneUrl,
|
||||||
|
private: input.repository.private ? 1 : 0,
|
||||||
|
updatedAt,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: githubRepositories.repoId,
|
||||||
|
set: {
|
||||||
|
fullName: input.repository.fullName,
|
||||||
|
cloneUrl: input.repository.cloneUrl,
|
||||||
|
private: input.repository.private ? 1 : 0,
|
||||||
|
updatedAt,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
|
||||||
|
if (state === "CLOSED" || state === "MERGED") {
|
||||||
|
await c.db.delete(githubPullRequests).where(eq(githubPullRequests.prId, prId)).run();
|
||||||
|
} else {
|
||||||
|
await c.db
|
||||||
|
.insert(githubPullRequests)
|
||||||
|
.values({
|
||||||
|
prId,
|
||||||
|
repoId,
|
||||||
|
repoFullName: input.repository.fullName,
|
||||||
|
number: input.pullRequest.number,
|
||||||
|
title: input.pullRequest.title,
|
||||||
|
body: input.pullRequest.body ?? null,
|
||||||
|
state,
|
||||||
|
url: input.pullRequest.url,
|
||||||
|
headRefName: input.pullRequest.headRefName,
|
||||||
|
baseRefName: input.pullRequest.baseRefName,
|
||||||
|
authorLogin: input.pullRequest.authorLogin ?? null,
|
||||||
|
isDraft: input.pullRequest.isDraft ? 1 : 0,
|
||||||
|
updatedAt,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: githubPullRequests.prId,
|
||||||
|
set: {
|
||||||
|
title: input.pullRequest.title,
|
||||||
|
body: input.pullRequest.body ?? null,
|
||||||
|
state,
|
||||||
|
url: input.pullRequest.url,
|
||||||
|
headRefName: input.pullRequest.headRefName,
|
||||||
|
baseRefName: input.pullRequest.baseRefName,
|
||||||
|
authorLogin: input.pullRequest.authorLogin ?? null,
|
||||||
|
isDraft: input.pullRequest.isDraft ? 1 : 0,
|
||||||
|
updatedAt,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeMeta(c, {
|
||||||
|
connectedAccount: input.connectedAccount,
|
||||||
|
installationStatus: input.installationStatus,
|
||||||
|
installationId: input.installationId,
|
||||||
|
syncStatus: "synced",
|
||||||
|
lastSyncLabel: "GitHub webhook received",
|
||||||
|
lastSyncAt: updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
const workspace = await getOrCreateWorkspace(c, c.state.workspaceId);
|
||||||
|
await workspace.applyGithubRepositoryProjection({
|
||||||
|
repoId,
|
||||||
|
remoteUrl: input.repository.cloneUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
const afterRows = await readAllPullRequestRows(c);
|
||||||
|
await emitPullRequestChangeEvents(c, beforeRows, afterRows);
|
||||||
|
if (state === "CLOSED" || state === "MERGED") {
|
||||||
|
const previous = beforeRows.find((row) => row.prId === prId);
|
||||||
|
if (previous) {
|
||||||
|
await autoArchiveTaskForClosedPullRequest(c, {
|
||||||
|
...previous,
|
||||||
|
state,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { authUserKey, taskKey, historyKey, projectBranchSyncKey, projectKey, projectPrSyncKey, taskSandboxKey, workspaceKey } from "./keys.js";
|
import { authUserKey, githubDataKey, taskKey, historyKey, projectBranchSyncKey, projectKey, taskSandboxKey, workspaceKey } from "./keys.js";
|
||||||
|
|
||||||
export function actorClient(c: any) {
|
export function actorClient(c: any) {
|
||||||
return c.client();
|
return c.client();
|
||||||
|
|
@ -53,17 +53,18 @@ export async function getOrCreateHistory(c: any, workspaceId: string, repoId: st
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getOrCreateProjectPrSync(c: any, workspaceId: string, repoId: string, repoPath: string, intervalMs: number) {
|
export async function getOrCreateGithubData(c: any, workspaceId: string) {
|
||||||
return await actorClient(c).projectPrSync.getOrCreate(projectPrSyncKey(workspaceId, repoId), {
|
return await actorClient(c).githubData.getOrCreate(githubDataKey(workspaceId), {
|
||||||
createWithInput: {
|
createWithInput: {
|
||||||
workspaceId,
|
workspaceId,
|
||||||
repoId,
|
|
||||||
repoPath,
|
|
||||||
intervalMs,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getGithubData(c: any, workspaceId: string) {
|
||||||
|
return actorClient(c).githubData.get(githubDataKey(workspaceId));
|
||||||
|
}
|
||||||
|
|
||||||
export async function getOrCreateProjectBranchSync(c: any, workspaceId: string, repoId: string, repoPath: string, intervalMs: number) {
|
export async function getOrCreateProjectBranchSync(c: any, workspaceId: string, repoId: string, repoPath: string, intervalMs: number) {
|
||||||
return await actorClient(c).projectBranchSync.getOrCreate(projectBranchSyncKey(workspaceId, repoId), {
|
return await actorClient(c).projectBranchSync.getOrCreate(projectBranchSyncKey(workspaceId, repoId), {
|
||||||
createWithInput: {
|
createWithInput: {
|
||||||
|
|
@ -85,10 +86,6 @@ export async function getOrCreateTaskSandbox(c: any, workspaceId: string, sandbo
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function selfProjectPrSync(c: any) {
|
|
||||||
return actorClient(c).projectPrSync.getForId(c.actorId);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function selfProjectBranchSync(c: any) {
|
export function selfProjectBranchSync(c: any) {
|
||||||
return actorClient(c).projectBranchSync.getForId(c.actorId);
|
return actorClient(c).projectBranchSync.getForId(c.actorId);
|
||||||
}
|
}
|
||||||
|
|
@ -112,3 +109,7 @@ export function selfProject(c: any) {
|
||||||
export function selfAuthUser(c: any) {
|
export function selfAuthUser(c: any) {
|
||||||
return actorClient(c).authUser.getForId(c.actorId);
|
return actorClient(c).authUser.getForId(c.actorId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function selfGithubData(c: any) {
|
||||||
|
return actorClient(c).githubData.getForId(c.actorId);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
import { authUser } from "./auth-user/index.js";
|
import { authUser } from "./auth-user/index.js";
|
||||||
import { setup } from "rivetkit";
|
import { setup } from "rivetkit";
|
||||||
|
import { githubData } from "./github-data/index.js";
|
||||||
import { task } from "./task/index.js";
|
import { task } from "./task/index.js";
|
||||||
import { history } from "./history/index.js";
|
import { history } from "./history/index.js";
|
||||||
import { projectBranchSync } from "./project-branch-sync/index.js";
|
import { projectBranchSync } from "./project-branch-sync/index.js";
|
||||||
import { projectPrSync } from "./project-pr-sync/index.js";
|
|
||||||
import { project } from "./project/index.js";
|
import { project } from "./project/index.js";
|
||||||
import { taskSandbox } from "./sandbox/index.js";
|
import { taskSandbox } from "./sandbox/index.js";
|
||||||
import { workspace } from "./workspace/index.js";
|
import { workspace } from "./workspace/index.js";
|
||||||
|
|
@ -28,7 +28,7 @@ export const registry = setup({
|
||||||
task,
|
task,
|
||||||
taskSandbox,
|
taskSandbox,
|
||||||
history,
|
history,
|
||||||
projectPrSync,
|
githubData,
|
||||||
projectBranchSync,
|
projectBranchSync,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -36,11 +36,11 @@ export const registry = setup({
|
||||||
export * from "./context.js";
|
export * from "./context.js";
|
||||||
export * from "./events.js";
|
export * from "./events.js";
|
||||||
export * from "./auth-user/index.js";
|
export * from "./auth-user/index.js";
|
||||||
|
export * from "./github-data/index.js";
|
||||||
export * from "./task/index.js";
|
export * from "./task/index.js";
|
||||||
export * from "./history/index.js";
|
export * from "./history/index.js";
|
||||||
export * from "./keys.js";
|
export * from "./keys.js";
|
||||||
export * from "./project-branch-sync/index.js";
|
export * from "./project-branch-sync/index.js";
|
||||||
export * from "./project-pr-sync/index.js";
|
|
||||||
export * from "./project/index.js";
|
export * from "./project/index.js";
|
||||||
export * from "./sandbox/index.js";
|
export * from "./sandbox/index.js";
|
||||||
export * from "./workspace/index.js";
|
export * from "./workspace/index.js";
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,8 @@ export function historyKey(workspaceId: string, repoId: string): ActorKey {
|
||||||
return ["ws", workspaceId, "project", repoId, "history"];
|
return ["ws", workspaceId, "project", repoId, "history"];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function projectPrSyncKey(workspaceId: string, repoId: string): ActorKey {
|
export function githubDataKey(workspaceId: string): ActorKey {
|
||||||
return ["ws", workspaceId, "project", repoId, "pr-sync"];
|
return ["ws", workspaceId, "github-data"];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function projectBranchSyncKey(workspaceId: string, repoId: string): ActorKey {
|
export function projectBranchSyncKey(workspaceId: string, repoId: string): ActorKey {
|
||||||
|
|
|
||||||
|
|
@ -1,98 +0,0 @@
|
||||||
import { actor, queue } from "rivetkit";
|
|
||||||
import { workflow } from "rivetkit/workflow";
|
|
||||||
import { getActorRuntimeContext } from "../context.js";
|
|
||||||
import { getProject, selfProjectPrSync } from "../handles.js";
|
|
||||||
import { logActorWarning, resolveErrorMessage, resolveErrorStack } from "../logging.js";
|
|
||||||
import { type PollingControlState, runWorkflowPollingLoop } from "../polling.js";
|
|
||||||
import { resolveWorkspaceGithubAuth } from "../../services/github-auth.js";
|
|
||||||
|
|
||||||
export interface ProjectPrSyncInput {
|
|
||||||
workspaceId: string;
|
|
||||||
repoId: string;
|
|
||||||
repoPath: string;
|
|
||||||
intervalMs: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SetIntervalCommand {
|
|
||||||
intervalMs: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProjectPrSyncState extends PollingControlState {
|
|
||||||
workspaceId: string;
|
|
||||||
repoId: string;
|
|
||||||
repoPath: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CONTROL = {
|
|
||||||
start: "project.pr_sync.control.start",
|
|
||||||
stop: "project.pr_sync.control.stop",
|
|
||||||
setInterval: "project.pr_sync.control.set_interval",
|
|
||||||
force: "project.pr_sync.control.force",
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
async function pollPrs(c: { state: ProjectPrSyncState }): Promise<void> {
|
|
||||||
const { driver } = getActorRuntimeContext();
|
|
||||||
const auth = await resolveWorkspaceGithubAuth(c, c.state.workspaceId);
|
|
||||||
const items = await driver.github.listPullRequests(c.state.repoPath, { githubToken: auth?.githubToken ?? null });
|
|
||||||
const parent = getProject(c, c.state.workspaceId, c.state.repoId);
|
|
||||||
await parent.applyPrSyncResult({ items, at: Date.now() });
|
|
||||||
}
|
|
||||||
|
|
||||||
export const projectPrSync = actor({
|
|
||||||
queues: {
|
|
||||||
[CONTROL.start]: queue(),
|
|
||||||
[CONTROL.stop]: queue(),
|
|
||||||
[CONTROL.setInterval]: queue(),
|
|
||||||
[CONTROL.force]: queue(),
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
name: "Project PR Sync",
|
|
||||||
icon: "code-merge",
|
|
||||||
// Polling actors rely on timer-based wakeups; sleeping would pause the timer and stop polling.
|
|
||||||
noSleep: true,
|
|
||||||
},
|
|
||||||
createState: (_c, input: ProjectPrSyncInput): ProjectPrSyncState => ({
|
|
||||||
workspaceId: input.workspaceId,
|
|
||||||
repoId: input.repoId,
|
|
||||||
repoPath: input.repoPath,
|
|
||||||
intervalMs: input.intervalMs,
|
|
||||||
running: true,
|
|
||||||
}),
|
|
||||||
actions: {
|
|
||||||
async start(c): Promise<void> {
|
|
||||||
const self = selfProjectPrSync(c);
|
|
||||||
await self.send(CONTROL.start, {}, { wait: true, timeout: 15_000 });
|
|
||||||
},
|
|
||||||
|
|
||||||
async stop(c): Promise<void> {
|
|
||||||
const self = selfProjectPrSync(c);
|
|
||||||
await self.send(CONTROL.stop, {}, { wait: true, timeout: 15_000 });
|
|
||||||
},
|
|
||||||
|
|
||||||
async setIntervalMs(c, payload: SetIntervalCommand): Promise<void> {
|
|
||||||
const self = selfProjectPrSync(c);
|
|
||||||
await self.send(CONTROL.setInterval, payload, { wait: true, timeout: 15_000 });
|
|
||||||
},
|
|
||||||
|
|
||||||
async force(c): Promise<void> {
|
|
||||||
const self = selfProjectPrSync(c);
|
|
||||||
await self.send(CONTROL.force, {}, { wait: true, timeout: 5 * 60_000 });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
run: workflow(async (ctx) => {
|
|
||||||
await runWorkflowPollingLoop<ProjectPrSyncState>(ctx, {
|
|
||||||
loopName: "project-pr-sync-loop",
|
|
||||||
control: CONTROL,
|
|
||||||
onPoll: async (loopCtx) => {
|
|
||||||
try {
|
|
||||||
await pollPrs(loopCtx);
|
|
||||||
} catch (error) {
|
|
||||||
logActorWarning("project-pr-sync", "poll failed", {
|
|
||||||
error: resolveErrorMessage(error),
|
|
||||||
stack: resolveErrorStack(error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
@ -4,13 +4,13 @@ import { and, desc, eq, isNotNull, ne } from "drizzle-orm";
|
||||||
import { Loop } from "rivetkit/workflow";
|
import { Loop } from "rivetkit/workflow";
|
||||||
import type { AgentType, TaskRecord, TaskSummary, ProviderId, RepoOverview, RepoStackAction, RepoStackActionResult } from "@sandbox-agent/foundry-shared";
|
import type { AgentType, TaskRecord, TaskSummary, ProviderId, RepoOverview, RepoStackAction, RepoStackActionResult } from "@sandbox-agent/foundry-shared";
|
||||||
import { getActorRuntimeContext } from "../context.js";
|
import { getActorRuntimeContext } from "../context.js";
|
||||||
import { getTask, getOrCreateTask, getOrCreateHistory, getOrCreateProjectBranchSync, getOrCreateProjectPrSync, selfProject } from "../handles.js";
|
import { getGithubData, getTask, getOrCreateTask, getOrCreateHistory, getOrCreateProjectBranchSync, selfProject } from "../handles.js";
|
||||||
import { isActorNotFoundError, logActorWarning, resolveErrorMessage } from "../logging.js";
|
import { isActorNotFoundError, logActorWarning, resolveErrorMessage } from "../logging.js";
|
||||||
import { foundryRepoClonePath } from "../../services/foundry-paths.js";
|
import { foundryRepoClonePath } from "../../services/foundry-paths.js";
|
||||||
import { resolveWorkspaceGithubAuth } from "../../services/github-auth.js";
|
import { resolveWorkspaceGithubAuth } from "../../services/github-auth.js";
|
||||||
import { expectQueueResponse } from "../../services/queue.js";
|
import { expectQueueResponse } from "../../services/queue.js";
|
||||||
import { withRepoGitLock } from "../../services/repo-git-lock.js";
|
import { withRepoGitLock } from "../../services/repo-git-lock.js";
|
||||||
import { branches, taskIndex, prCache, repoActionJobs, repoMeta } from "./db/schema.js";
|
import { branches, taskIndex, repoActionJobs, repoMeta } from "./db/schema.js";
|
||||||
import { deriveFallbackTitle } from "../../services/create-flow.js";
|
import { deriveFallbackTitle } from "../../services/create-flow.js";
|
||||||
import { normalizeBaseBranchName } from "../../integrations/git-spice/index.js";
|
import { normalizeBaseBranchName } from "../../integrations/git-spice/index.js";
|
||||||
import { sortBranchesForOverview } from "./stack-model.js";
|
import { sortBranchesForOverview } from "./stack-model.js";
|
||||||
|
|
@ -55,22 +55,6 @@ interface GetPullRequestForBranchCommand {
|
||||||
branchName: string;
|
branchName: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PrSyncResult {
|
|
||||||
items: Array<{
|
|
||||||
number: number;
|
|
||||||
headRefName: string;
|
|
||||||
state: string;
|
|
||||||
title: string;
|
|
||||||
url?: string;
|
|
||||||
author?: string;
|
|
||||||
isDraft?: boolean;
|
|
||||||
ciStatus?: string | null;
|
|
||||||
reviewStatus?: string | null;
|
|
||||||
reviewer?: string | null;
|
|
||||||
}>;
|
|
||||||
at: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BranchSyncResult {
|
interface BranchSyncResult {
|
||||||
items: Array<{
|
items: Array<{
|
||||||
branchName: string;
|
branchName: string;
|
||||||
|
|
@ -99,7 +83,6 @@ const PROJECT_QUEUE_NAMES = [
|
||||||
"project.command.createTask",
|
"project.command.createTask",
|
||||||
"project.command.registerTaskBranch",
|
"project.command.registerTaskBranch",
|
||||||
"project.command.runRepoStackAction",
|
"project.command.runRepoStackAction",
|
||||||
"project.command.applyPrSyncResult",
|
|
||||||
"project.command.applyBranchSyncResult",
|
"project.command.applyBranchSyncResult",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|
@ -125,18 +108,9 @@ async function ensureProjectSyncActors(c: any, localPath: string): Promise<void>
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const prSync = await getOrCreateProjectPrSync(c, c.state.workspaceId, c.state.repoId, localPath, 30_000);
|
|
||||||
const branchSync = await getOrCreateProjectBranchSync(c, c.state.workspaceId, c.state.repoId, localPath, 5_000);
|
const branchSync = await getOrCreateProjectBranchSync(c, c.state.workspaceId, c.state.repoId, localPath, 5_000);
|
||||||
c.state.syncActorsStarted = true;
|
c.state.syncActorsStarted = true;
|
||||||
|
|
||||||
void prSync.start().catch((error: unknown) => {
|
|
||||||
logActorWarning("project.sync", "starting pr sync actor failed", {
|
|
||||||
workspaceId: c.state.workspaceId,
|
|
||||||
repoId: c.state.repoId,
|
|
||||||
error: resolveErrorMessage(error),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
void branchSync.start().catch((error: unknown) => {
|
void branchSync.start().catch((error: unknown) => {
|
||||||
logActorWarning("project.sync", "starting branch sync actor failed", {
|
logActorWarning("project.sync", "starting branch sync actor failed", {
|
||||||
workspaceId: c.state.workspaceId,
|
workspaceId: c.state.workspaceId,
|
||||||
|
|
@ -352,9 +326,6 @@ async function ensureTaskIndexHydratedForRead(c: any): Promise<void> {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function forceProjectSync(c: any, localPath: string): Promise<void> {
|
async function forceProjectSync(c: any, localPath: string): Promise<void> {
|
||||||
const prSync = await getOrCreateProjectPrSync(c, c.state.workspaceId, c.state.repoId, localPath, 30_000);
|
|
||||||
await prSync.force();
|
|
||||||
|
|
||||||
const branchSync = await getOrCreateProjectBranchSync(c, c.state.workspaceId, c.state.repoId, localPath, 5_000);
|
const branchSync = await getOrCreateProjectBranchSync(c, c.state.workspaceId, c.state.repoId, localPath, 5_000);
|
||||||
await branchSync.force();
|
await branchSync.force();
|
||||||
}
|
}
|
||||||
|
|
@ -377,17 +348,10 @@ async function enrichTaskRecord(c: any, record: TaskRecord): Promise<TaskRecord>
|
||||||
|
|
||||||
const pr =
|
const pr =
|
||||||
branchName != null
|
branchName != null
|
||||||
? await c.db
|
? await getGithubData(c, c.state.workspaceId)
|
||||||
.select({
|
.listPullRequestsForRepository({ repoId: c.state.repoId })
|
||||||
prUrl: prCache.prUrl,
|
.then((rows: any[]) => rows.find((row) => row.headRefName === branchName) ?? null)
|
||||||
prAuthor: prCache.prAuthor,
|
.catch(() => null)
|
||||||
ciStatus: prCache.ciStatus,
|
|
||||||
reviewStatus: prCache.reviewStatus,
|
|
||||||
reviewer: prCache.reviewer,
|
|
||||||
})
|
|
||||||
.from(prCache)
|
|
||||||
.where(eq(prCache.branchName, branchName))
|
|
||||||
.get()
|
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -396,11 +360,11 @@ async function enrichTaskRecord(c: any, record: TaskRecord): Promise<TaskRecord>
|
||||||
hasUnpushed: br?.hasUnpushed != null ? String(br.hasUnpushed) : null,
|
hasUnpushed: br?.hasUnpushed != null ? String(br.hasUnpushed) : null,
|
||||||
conflictsWithMain: br?.conflictsWithMain != null ? String(br.conflictsWithMain) : null,
|
conflictsWithMain: br?.conflictsWithMain != null ? String(br.conflictsWithMain) : null,
|
||||||
parentBranch: br?.parentBranch ?? null,
|
parentBranch: br?.parentBranch ?? null,
|
||||||
prUrl: pr?.prUrl ?? null,
|
prUrl: pr?.url ?? null,
|
||||||
prAuthor: pr?.prAuthor ?? null,
|
prAuthor: pr?.authorLogin ?? null,
|
||||||
ciStatus: pr?.ciStatus ?? null,
|
ciStatus: null,
|
||||||
reviewStatus: pr?.reviewStatus ?? null,
|
reviewStatus: null,
|
||||||
reviewer: pr?.reviewer ?? null,
|
reviewer: pr?.authorLogin ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -458,11 +422,6 @@ async function createTaskMutation(c: any, cmd: CreateTaskCommand): Promise<TaskR
|
||||||
const taskId = randomUUID();
|
const taskId = randomUUID();
|
||||||
|
|
||||||
if (onBranch) {
|
if (onBranch) {
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await registerTaskBranchMutation(c, {
|
await registerTaskBranchMutation(c, {
|
||||||
taskId,
|
taskId,
|
||||||
branchName: onBranch,
|
branchName: onBranch,
|
||||||
|
|
@ -810,82 +769,6 @@ async function runRepoStackActionMutation(c: any, cmd: RunRepoStackActionCommand
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applyPrSyncResultMutation(c: any, body: PrSyncResult): Promise<void> {
|
|
||||||
await c.db.delete(prCache).run();
|
|
||||||
|
|
||||||
for (const item of body.items) {
|
|
||||||
await c.db
|
|
||||||
.insert(prCache)
|
|
||||||
.values({
|
|
||||||
branchName: item.headRefName,
|
|
||||||
prNumber: item.number,
|
|
||||||
state: item.state,
|
|
||||||
title: item.title,
|
|
||||||
prUrl: item.url ?? null,
|
|
||||||
prAuthor: item.author ?? null,
|
|
||||||
isDraft: item.isDraft ? 1 : 0,
|
|
||||||
ciStatus: item.ciStatus ?? null,
|
|
||||||
reviewStatus: item.reviewStatus ?? null,
|
|
||||||
reviewer: item.reviewer ?? null,
|
|
||||||
fetchedAt: body.at,
|
|
||||||
updatedAt: body.at,
|
|
||||||
})
|
|
||||||
.onConflictDoUpdate({
|
|
||||||
target: prCache.branchName,
|
|
||||||
set: {
|
|
||||||
prNumber: item.number,
|
|
||||||
state: item.state,
|
|
||||||
title: item.title,
|
|
||||||
prUrl: item.url ?? null,
|
|
||||||
prAuthor: item.author ?? null,
|
|
||||||
isDraft: item.isDraft ? 1 : 0,
|
|
||||||
ciStatus: item.ciStatus ?? null,
|
|
||||||
reviewStatus: item.reviewStatus ?? null,
|
|
||||||
reviewer: item.reviewer ?? null,
|
|
||||||
fetchedAt: body.at,
|
|
||||||
updatedAt: body.at,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.run();
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const item of body.items) {
|
|
||||||
if (item.state !== "MERGED" && item.state !== "CLOSED") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const row = await c.db.select({ taskId: taskIndex.taskId }).from(taskIndex).where(eq(taskIndex.branchName, item.headRefName)).get();
|
|
||||||
if (!row) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const h = getTask(c, c.state.workspaceId, c.state.repoId, row.taskId);
|
|
||||||
await h.archive({ reason: `PR ${item.state.toLowerCase()}` });
|
|
||||||
} catch (error) {
|
|
||||||
if (isStaleTaskReferenceError(error)) {
|
|
||||||
await deleteStaleTaskIndexRow(c, row.taskId);
|
|
||||||
logActorWarning("project", "pruned stale task index row during PR close archive", {
|
|
||||||
workspaceId: c.state.workspaceId,
|
|
||||||
repoId: c.state.repoId,
|
|
||||||
taskId: row.taskId,
|
|
||||||
branchName: item.headRefName,
|
|
||||||
prState: item.state,
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
logActorWarning("project", "failed to auto-archive task after PR close", {
|
|
||||||
workspaceId: c.state.workspaceId,
|
|
||||||
repoId: c.state.repoId,
|
|
||||||
taskId: row.taskId,
|
|
||||||
branchName: item.headRefName,
|
|
||||||
prState: item.state,
|
|
||||||
error: resolveErrorMessage(error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function applyBranchSyncResultMutation(c: any, body: BranchSyncResult): Promise<void> {
|
async function applyBranchSyncResultMutation(c: any, body: BranchSyncResult): Promise<void> {
|
||||||
const incoming = new Set(body.items.map((item) => item.branchName));
|
const incoming = new Set(body.items.map((item) => item.branchName));
|
||||||
const reservedRows = await c.db.select({ branchName: taskIndex.branchName }).from(taskIndex).where(isNotNull(taskIndex.branchName)).all();
|
const reservedRows = await c.db.select({ branchName: taskIndex.branchName }).from(taskIndex).where(isNotNull(taskIndex.branchName)).all();
|
||||||
|
|
@ -953,69 +836,77 @@ export async function runProjectWorkflow(ctx: any): Promise<void> {
|
||||||
return Loop.continue(undefined);
|
return Loop.continue(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.name === "project.command.ensure") {
|
try {
|
||||||
const result = await loopCtx.step({
|
if (msg.name === "project.command.ensure") {
|
||||||
name: "project-ensure",
|
const result = await loopCtx.step({
|
||||||
timeout: 5 * 60_000,
|
name: "project-ensure",
|
||||||
run: async () => ensureProjectMutation(loopCtx, msg.body as EnsureProjectCommand),
|
timeout: 5 * 60_000,
|
||||||
});
|
run: async () => ensureProjectMutation(loopCtx, msg.body as EnsureProjectCommand),
|
||||||
await msg.complete(result);
|
});
|
||||||
return Loop.continue(undefined);
|
await msg.complete(result);
|
||||||
}
|
return Loop.continue(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
if (msg.name === "project.command.hydrateTaskIndex") {
|
if (msg.name === "project.command.hydrateTaskIndex") {
|
||||||
await loopCtx.step("project-hydrate-task-index", async () => hydrateTaskIndexMutation(loopCtx, msg.body as HydrateTaskIndexCommand));
|
await loopCtx.step("project-hydrate-task-index", async () => hydrateTaskIndexMutation(loopCtx, msg.body as HydrateTaskIndexCommand));
|
||||||
await msg.complete({ ok: true });
|
await msg.complete({ ok: true });
|
||||||
return Loop.continue(undefined);
|
return Loop.continue(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.name === "project.command.createTask") {
|
if (msg.name === "project.command.createTask") {
|
||||||
const result = await loopCtx.step({
|
const result = await loopCtx.step({
|
||||||
name: "project-create-task",
|
name: "project-create-task",
|
||||||
timeout: 5 * 60_000,
|
timeout: 5 * 60_000,
|
||||||
run: async () => createTaskMutation(loopCtx, msg.body as CreateTaskCommand),
|
run: async () => createTaskMutation(loopCtx, msg.body as CreateTaskCommand),
|
||||||
});
|
});
|
||||||
await msg.complete(result);
|
await msg.complete(result);
|
||||||
return Loop.continue(undefined);
|
return Loop.continue(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.name === "project.command.registerTaskBranch") {
|
if (msg.name === "project.command.registerTaskBranch") {
|
||||||
const result = await loopCtx.step({
|
const result = await loopCtx.step({
|
||||||
name: "project-register-task-branch",
|
name: "project-register-task-branch",
|
||||||
timeout: 5 * 60_000,
|
timeout: 5 * 60_000,
|
||||||
run: async () => registerTaskBranchMutation(loopCtx, msg.body as RegisterTaskBranchCommand),
|
run: async () => registerTaskBranchMutation(loopCtx, msg.body as RegisterTaskBranchCommand),
|
||||||
});
|
});
|
||||||
await msg.complete(result);
|
await msg.complete(result);
|
||||||
return Loop.continue(undefined);
|
return Loop.continue(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.name === "project.command.runRepoStackAction") {
|
if (msg.name === "project.command.runRepoStackAction") {
|
||||||
const result = await loopCtx.step({
|
const result = await loopCtx.step({
|
||||||
name: "project-run-repo-stack-action",
|
name: "project-run-repo-stack-action",
|
||||||
timeout: 12 * 60_000,
|
timeout: 12 * 60_000,
|
||||||
run: async () => runRepoStackActionMutation(loopCtx, msg.body as RunRepoStackActionCommand),
|
run: async () => runRepoStackActionMutation(loopCtx, msg.body as RunRepoStackActionCommand),
|
||||||
});
|
});
|
||||||
await msg.complete(result);
|
await msg.complete(result);
|
||||||
return Loop.continue(undefined);
|
return Loop.continue(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.name === "project.command.applyPrSyncResult") {
|
if (msg.name === "project.command.applyBranchSyncResult") {
|
||||||
await loopCtx.step({
|
await loopCtx.step({
|
||||||
name: "project-apply-pr-sync-result",
|
name: "project-apply-branch-sync-result",
|
||||||
timeout: 60_000,
|
timeout: 60_000,
|
||||||
run: async () => applyPrSyncResultMutation(loopCtx, msg.body as PrSyncResult),
|
run: async () => applyBranchSyncResultMutation(loopCtx, msg.body as BranchSyncResult),
|
||||||
|
});
|
||||||
|
await msg.complete({ ok: true });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = resolveErrorMessage(error);
|
||||||
|
logActorWarning("project", "project workflow command failed", {
|
||||||
|
workspaceId: loopCtx.state.workspaceId,
|
||||||
|
repoId: loopCtx.state.repoId,
|
||||||
|
queueName: msg.name,
|
||||||
|
error: message,
|
||||||
});
|
});
|
||||||
await msg.complete({ ok: true });
|
await msg.complete({ error: message }).catch((completeError: unknown) => {
|
||||||
return Loop.continue(undefined);
|
logActorWarning("project", "project workflow failed completing error response", {
|
||||||
}
|
workspaceId: loopCtx.state.workspaceId,
|
||||||
|
repoId: loopCtx.state.repoId,
|
||||||
if (msg.name === "project.command.applyBranchSyncResult") {
|
queueName: msg.name,
|
||||||
await loopCtx.step({
|
error: resolveErrorMessage(completeError),
|
||||||
name: "project-apply-branch-sync-result",
|
});
|
||||||
timeout: 60_000,
|
|
||||||
run: async () => applyBranchSyncResultMutation(loopCtx, msg.body as BranchSyncResult),
|
|
||||||
});
|
});
|
||||||
await msg.complete({ ok: true });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Loop.continue(undefined);
|
return Loop.continue(undefined);
|
||||||
|
|
@ -1219,19 +1110,9 @@ export const projectActions = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const prRows = await c.db
|
const githubData = getGithubData(c, c.state.workspaceId);
|
||||||
.select({
|
const prRows = await githubData.listPullRequestsForRepository({ repoId: c.state.repoId }).catch(() => []);
|
||||||
branchName: prCache.branchName,
|
const prByBranch = new Map(prRows.map((row) => [row.headRefName, row]));
|
||||||
prNumber: prCache.prNumber,
|
|
||||||
prState: prCache.state,
|
|
||||||
prUrl: prCache.prUrl,
|
|
||||||
ciStatus: prCache.ciStatus,
|
|
||||||
reviewStatus: prCache.reviewStatus,
|
|
||||||
reviewer: prCache.reviewer,
|
|
||||||
})
|
|
||||||
.from(prCache)
|
|
||||||
.all();
|
|
||||||
const prByBranch = new Map(prRows.map((row) => [row.branchName, row]));
|
|
||||||
|
|
||||||
const combinedRows = sortBranchesForOverview(
|
const combinedRows = sortBranchesForOverview(
|
||||||
branchRowsRaw.map((row) => ({
|
branchRowsRaw.map((row) => ({
|
||||||
|
|
@ -1258,12 +1139,12 @@ export const projectActions = {
|
||||||
taskId: taskMeta?.taskId ?? null,
|
taskId: taskMeta?.taskId ?? null,
|
||||||
taskTitle: taskMeta?.title ?? null,
|
taskTitle: taskMeta?.title ?? null,
|
||||||
taskStatus: taskMeta?.status ?? null,
|
taskStatus: taskMeta?.status ?? null,
|
||||||
prNumber: pr?.prNumber ?? null,
|
prNumber: pr?.number ?? null,
|
||||||
prState: pr?.prState ?? null,
|
prState: pr?.state ?? null,
|
||||||
prUrl: pr?.prUrl ?? null,
|
prUrl: pr?.url ?? null,
|
||||||
ciStatus: pr?.ciStatus ?? null,
|
ciStatus: null,
|
||||||
reviewStatus: pr?.reviewStatus ?? null,
|
reviewStatus: null,
|
||||||
reviewer: pr?.reviewer ?? null,
|
reviewer: pr?.authorLogin ?? null,
|
||||||
firstSeenAt: row.firstSeenAt ?? null,
|
firstSeenAt: row.firstSeenAt ?? null,
|
||||||
lastSeenAt: row.lastSeenAt ?? null,
|
lastSeenAt: row.lastSeenAt ?? null,
|
||||||
updatedAt: Math.max(row.updatedAt, taskMeta?.updatedAt ?? 0),
|
updatedAt: Math.max(row.updatedAt, taskMeta?.updatedAt ?? 0),
|
||||||
|
|
@ -1271,7 +1152,7 @@ export const projectActions = {
|
||||||
});
|
});
|
||||||
|
|
||||||
const latestBranchSync = await c.db.select({ updatedAt: branches.updatedAt }).from(branches).orderBy(desc(branches.updatedAt)).limit(1).get();
|
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();
|
const githubSummary = await githubData.getSummary().catch(() => null);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
workspaceId: c.state.workspaceId,
|
workspaceId: c.state.workspaceId,
|
||||||
|
|
@ -1281,9 +1162,9 @@ export const projectActions = {
|
||||||
stackAvailable,
|
stackAvailable,
|
||||||
fetchedAt: now,
|
fetchedAt: now,
|
||||||
branchSyncAt: latestBranchSync?.updatedAt ?? null,
|
branchSyncAt: latestBranchSync?.updatedAt ?? null,
|
||||||
prSyncAt: latestPrSync?.updatedAt ?? null,
|
prSyncAt: githubSummary?.lastSyncAt ?? null,
|
||||||
branchSyncStatus: latestBranchSync ? "synced" : "pending",
|
branchSyncStatus: latestBranchSync ? "synced" : "pending",
|
||||||
prSyncStatus: latestPrSync ? "synced" : "pending",
|
prSyncStatus: githubSummary?.syncStatus ?? "pending",
|
||||||
repoActionJobs: await listRepoActionJobRows(c),
|
repoActionJobs: await listRepoActionJobRows(c),
|
||||||
branches: branchRows,
|
branches: branchRows,
|
||||||
};
|
};
|
||||||
|
|
@ -1294,24 +1175,11 @@ export const projectActions = {
|
||||||
if (!branchName) {
|
if (!branchName) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const githubData = getGithubData(c, c.state.workspaceId);
|
||||||
const pr = await c.db
|
return await githubData.getPullRequestForBranch({
|
||||||
.select({
|
repoId: c.state.repoId,
|
||||||
prNumber: prCache.prNumber,
|
branchName,
|
||||||
prState: prCache.state,
|
});
|
||||||
})
|
|
||||||
.from(prCache)
|
|
||||||
.where(eq(prCache.branchName, branchName))
|
|
||||||
.get();
|
|
||||||
|
|
||||||
if (!pr?.prNumber) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
number: pr.prNumber,
|
|
||||||
status: pr.prState === "draft" ? "draft" : "ready",
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
|
|
||||||
async runRepoStackAction(c: any, cmd: RunRepoStackActionCommand): Promise<RepoStackActionResult> {
|
async runRepoStackAction(c: any, cmd: RunRepoStackActionCommand): Promise<RepoStackActionResult> {
|
||||||
|
|
@ -1353,14 +1221,6 @@ export const projectActions = {
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
async applyPrSyncResult(c: any, body: PrSyncResult): Promise<void> {
|
|
||||||
const self = selfProject(c);
|
|
||||||
await self.send(projectWorkflowQueueName("project.command.applyPrSyncResult"), body, {
|
|
||||||
wait: true,
|
|
||||||
timeout: 5 * 60_000,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
async applyBranchSyncResult(c: any, body: BranchSyncResult): Promise<void> {
|
async applyBranchSyncResult(c: any, body: BranchSyncResult): Promise<void> {
|
||||||
const self = selfProject(c);
|
const self = selfProject(c);
|
||||||
await self.send(projectWorkflowQueueName("project.command.applyBranchSyncResult"), body, {
|
await self.send(projectWorkflowQueueName("project.command.applyBranchSyncResult"), body, {
|
||||||
|
|
|
||||||
|
|
@ -29,21 +29,6 @@ export default {
|
||||||
\`updated_at\` integer NOT NULL
|
\`updated_at\` integer NOT NULL
|
||||||
);
|
);
|
||||||
--> statement-breakpoint
|
--> statement-breakpoint
|
||||||
CREATE TABLE \`pr_cache\` (
|
|
||||||
\`branch_name\` text PRIMARY KEY NOT NULL,
|
|
||||||
\`pr_number\` integer NOT NULL,
|
|
||||||
\`state\` text NOT NULL,
|
|
||||||
\`title\` text NOT NULL,
|
|
||||||
\`pr_url\` text,
|
|
||||||
\`pr_author\` text,
|
|
||||||
\`is_draft\` integer DEFAULT 0 NOT NULL,
|
|
||||||
\`ci_status\` text,
|
|
||||||
\`review_status\` text,
|
|
||||||
\`reviewer\` text,
|
|
||||||
\`fetched_at\` integer,
|
|
||||||
\`updated_at\` integer NOT NULL
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE TABLE \`repo_meta\` (
|
CREATE TABLE \`repo_meta\` (
|
||||||
\`id\` integer PRIMARY KEY NOT NULL,
|
\`id\` integer PRIMARY KEY NOT NULL,
|
||||||
\`remote_url\` text NOT NULL,
|
\`remote_url\` text NOT NULL,
|
||||||
|
|
|
||||||
|
|
@ -21,21 +21,6 @@ export const repoMeta = sqliteTable("repo_meta", {
|
||||||
updatedAt: integer("updated_at").notNull(),
|
updatedAt: integer("updated_at").notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const prCache = sqliteTable("pr_cache", {
|
|
||||||
branchName: text("branch_name").notNull().primaryKey(),
|
|
||||||
prNumber: integer("pr_number").notNull(),
|
|
||||||
state: text("state").notNull(),
|
|
||||||
title: text("title").notNull(),
|
|
||||||
prUrl: text("pr_url"),
|
|
||||||
prAuthor: text("pr_author"),
|
|
||||||
isDraft: integer("is_draft").notNull().default(0),
|
|
||||||
ciStatus: text("ci_status"),
|
|
||||||
reviewStatus: text("review_status"),
|
|
||||||
reviewer: text("reviewer"),
|
|
||||||
fetchedAt: integer("fetched_at"),
|
|
||||||
updatedAt: integer("updated_at").notNull(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const taskIndex = sqliteTable("task_index", {
|
export const taskIndex = sqliteTable("task_index", {
|
||||||
taskId: text("task_id").notNull().primaryKey(),
|
taskId: text("task_id").notNull().primaryKey(),
|
||||||
branchName: text("branch_name"),
|
branchName: text("branch_name"),
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,10 @@ interface TaskWorkbenchSendMessageCommand {
|
||||||
attachments: Array<any>;
|
attachments: Array<any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface TaskWorkbenchSendMessageActionInput extends TaskWorkbenchSendMessageInput {
|
||||||
|
waitForCompletion?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface TaskWorkbenchCreateSessionCommand {
|
interface TaskWorkbenchCreateSessionCommand {
|
||||||
model?: string;
|
model?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -317,9 +321,9 @@ export const task = actor({
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
async sendWorkbenchMessage(c, input: TaskWorkbenchSendMessageInput): Promise<void> {
|
async sendWorkbenchMessage(c, input: TaskWorkbenchSendMessageActionInput): Promise<void> {
|
||||||
const self = selfTask(c);
|
const self = selfTask(c);
|
||||||
await self.send(
|
const result = await self.send(
|
||||||
taskWorkflowQueueName("task.command.workbench.send_message"),
|
taskWorkflowQueueName("task.command.workbench.send_message"),
|
||||||
{
|
{
|
||||||
sessionId: input.tabId,
|
sessionId: input.tabId,
|
||||||
|
|
@ -327,9 +331,13 @@ export const task = actor({
|
||||||
attachments: input.attachments,
|
attachments: input.attachments,
|
||||||
} satisfies TaskWorkbenchSendMessageCommand,
|
} satisfies TaskWorkbenchSendMessageCommand,
|
||||||
{
|
{
|
||||||
wait: false,
|
wait: input.waitForCompletion === true,
|
||||||
|
...(input.waitForCompletion === true ? { timeout: 10 * 60_000 } : {}),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
if (input.waitForCompletion === true) {
|
||||||
|
expectQueueResponse(result);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async stopWorkbenchSession(c, input: TaskTabCommand): Promise<void> {
|
async stopWorkbenchSession(c, input: TaskTabCommand): Promise<void> {
|
||||||
|
|
|
||||||
|
|
@ -149,6 +149,23 @@ export function shouldMarkSessionUnreadForStatus(meta: { thinkingSinceMs?: numbe
|
||||||
return Boolean(meta.thinkingSinceMs);
|
return Boolean(meta.thinkingSinceMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function shouldRecreateSessionForModelChange(meta: {
|
||||||
|
status: "pending_provision" | "pending_session_create" | "ready" | "error";
|
||||||
|
sandboxSessionId?: string | null;
|
||||||
|
created?: boolean;
|
||||||
|
transcript?: Array<any>;
|
||||||
|
}): boolean {
|
||||||
|
if (meta.status !== "ready" || !meta.sandboxSessionId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meta.created) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !Array.isArray(meta.transcript) || meta.transcript.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
async function listSessionMetaRows(c: any, options?: { includeClosed?: boolean }): Promise<Array<any>> {
|
async function listSessionMetaRows(c: any, options?: { includeClosed?: boolean }): Promise<Array<any>> {
|
||||||
await ensureWorkbenchSessionTable(c);
|
await ensureWorkbenchSessionTable(c);
|
||||||
const rows = await c.db.select().from(taskWorkbenchSessions).orderBy(asc(taskWorkbenchSessions.createdAt)).all();
|
const rows = await c.db.select().from(taskWorkbenchSessions).orderBy(asc(taskWorkbenchSessions.createdAt)).all();
|
||||||
|
|
@ -290,6 +307,24 @@ async function requireReadySessionMeta(c: any, tabId: string): Promise<any> {
|
||||||
return meta;
|
return meta;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function ensureReadySessionMeta(c: any, tabId: string): Promise<any> {
|
||||||
|
const meta = await readSessionMeta(c, tabId);
|
||||||
|
if (!meta) {
|
||||||
|
throw new Error(`Unknown workbench tab: ${tabId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meta.status === "ready" && meta.sandboxSessionId) {
|
||||||
|
return meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meta.status === "error") {
|
||||||
|
throw new Error(meta.errorMessage ?? "This workbench tab failed to prepare");
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensureWorkbenchSession(c, tabId);
|
||||||
|
return await requireReadySessionMeta(c, tabId);
|
||||||
|
}
|
||||||
|
|
||||||
function shellFragment(parts: string[]): string {
|
function shellFragment(parts: string[]): string {
|
||||||
return parts.join(" && ");
|
return parts.join(" && ");
|
||||||
}
|
}
|
||||||
|
|
@ -662,6 +697,23 @@ async function enqueueWorkbenchRefresh(
|
||||||
await self.send(command, body, { wait: false });
|
await self.send(command, body, { wait: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function enqueueWorkbenchEnsureSession(c: any, tabId: string): Promise<void> {
|
||||||
|
const self = selfTask(c);
|
||||||
|
await self.send(
|
||||||
|
"task.command.workbench.ensure_session",
|
||||||
|
{
|
||||||
|
tabId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
wait: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pendingWorkbenchSessionStatus(record: any): "pending_provision" | "pending_session_create" {
|
||||||
|
return record.activeSandboxId ? "pending_session_create" : "pending_provision";
|
||||||
|
}
|
||||||
|
|
||||||
async function maybeScheduleWorkbenchRefreshes(c: any, record: any, sessions: Array<any>): Promise<void> {
|
async function maybeScheduleWorkbenchRefreshes(c: any, record: any, sessions: Array<any>): Promise<void> {
|
||||||
const gitState = await readCachedGitState(c);
|
const gitState = await readCachedGitState(c);
|
||||||
if (record.activeSandboxId && !gitState.updatedAt) {
|
if (record.activeSandboxId && !gitState.updatedAt) {
|
||||||
|
|
@ -721,7 +773,7 @@ export async function ensureWorkbenchSeeded(c: any): Promise<any> {
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildSessionSummary(record: any, meta: any): any {
|
function buildSessionSummary(record: any, meta: any): any {
|
||||||
const derivedSandboxSessionId = meta.sandboxSessionId ?? (meta.status === "pending_provision" && record.activeSessionId ? record.activeSessionId : null);
|
const derivedSandboxSessionId = meta.status === "ready" ? (meta.sandboxSessionId ?? null) : null;
|
||||||
const sessionStatus =
|
const sessionStatus =
|
||||||
meta.status === "pending_provision" || meta.status === "pending_session_create"
|
meta.status === "pending_provision" || meta.status === "pending_session_create"
|
||||||
? meta.status
|
? meta.status
|
||||||
|
|
@ -991,12 +1043,12 @@ export async function createWorkbenchSession(c: any, model?: string): Promise<{
|
||||||
await ensureSessionMeta(c, {
|
await ensureSessionMeta(c, {
|
||||||
tabId,
|
tabId,
|
||||||
model: model ?? defaultModelForAgent(record.agentType),
|
model: model ?? defaultModelForAgent(record.agentType),
|
||||||
sandboxSessionId: tabId,
|
sandboxSessionId: null,
|
||||||
status: record.activeSandboxId ? "pending_session_create" : "pending_provision",
|
status: pendingWorkbenchSessionStatus(record),
|
||||||
created: false,
|
created: false,
|
||||||
});
|
});
|
||||||
await ensureWorkbenchSession(c, tabId, model);
|
|
||||||
await broadcastTaskUpdate(c, { sessionId: tabId });
|
await broadcastTaskUpdate(c, { sessionId: tabId });
|
||||||
|
await enqueueWorkbenchEnsureSession(c, tabId);
|
||||||
return { tabId };
|
return { tabId };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1099,14 +1151,60 @@ export async function updateWorkbenchDraft(c: any, sessionId: string, text: stri
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function changeWorkbenchModel(c: any, sessionId: string, model: string): Promise<void> {
|
export async function changeWorkbenchModel(c: any, sessionId: string, model: string): Promise<void> {
|
||||||
await updateSessionMeta(c, sessionId, {
|
const meta = await readSessionMeta(c, sessionId);
|
||||||
|
if (!meta || meta.closed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meta.model === model) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = await ensureWorkbenchSeeded(c);
|
||||||
|
let nextMeta = await updateSessionMeta(c, sessionId, {
|
||||||
model,
|
model,
|
||||||
});
|
});
|
||||||
|
let shouldEnsure = nextMeta.status === "pending_provision" || nextMeta.status === "pending_session_create" || nextMeta.status === "error";
|
||||||
|
|
||||||
|
if (shouldRecreateSessionForModelChange(nextMeta)) {
|
||||||
|
const sandbox = getTaskSandbox(c, c.state.workspaceId, stableSandboxId(c));
|
||||||
|
await sandbox.destroySession(nextMeta.sandboxSessionId);
|
||||||
|
nextMeta = await updateSessionMeta(c, sessionId, {
|
||||||
|
sandboxSessionId: null,
|
||||||
|
status: pendingWorkbenchSessionStatus(record),
|
||||||
|
errorMessage: null,
|
||||||
|
transcriptJson: "[]",
|
||||||
|
transcriptUpdatedAt: null,
|
||||||
|
thinkingSinceMs: null,
|
||||||
|
});
|
||||||
|
shouldEnsure = true;
|
||||||
|
} else if (nextMeta.status === "ready" && nextMeta.sandboxSessionId) {
|
||||||
|
const sandbox = getTaskSandbox(c, c.state.workspaceId, stableSandboxId(c));
|
||||||
|
if (typeof sandbox.rawSendSessionMethod === "function") {
|
||||||
|
try {
|
||||||
|
await sandbox.rawSendSessionMethod(nextMeta.sandboxSessionId, "session/set_config_option", {
|
||||||
|
configId: "model",
|
||||||
|
value: model,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Some agents do not allow live model updates. Preserve the new preference in metadata.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (nextMeta.status !== "ready") {
|
||||||
|
nextMeta = await updateSessionMeta(c, sessionId, {
|
||||||
|
status: pendingWorkbenchSessionStatus(record),
|
||||||
|
errorMessage: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shouldEnsure) {
|
||||||
|
await enqueueWorkbenchEnsureSession(c, sessionId);
|
||||||
|
}
|
||||||
await broadcastTaskUpdate(c, { sessionId });
|
await broadcastTaskUpdate(c, { sessionId });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function sendWorkbenchMessage(c: any, sessionId: string, text: string, attachments: Array<any>): Promise<void> {
|
export async function sendWorkbenchMessage(c: any, sessionId: string, text: string, attachments: Array<any>): Promise<void> {
|
||||||
const meta = await requireReadySessionMeta(c, sessionId);
|
const meta = await ensureReadySessionMeta(c, sessionId);
|
||||||
const record = await ensureWorkbenchSeeded(c);
|
const record = await ensureWorkbenchSeeded(c);
|
||||||
const runtime = await getTaskSandboxRuntime(c, record);
|
const runtime = await getTaskSandboxRuntime(c, record);
|
||||||
await ensureSandboxRepo(c, runtime.sandbox, record);
|
await ensureSandboxRepo(c, runtime.sandbox, record);
|
||||||
|
|
|
||||||
|
|
@ -186,12 +186,16 @@ const commandHandlers: Record<TaskQueueName, WorkflowHandler> = {
|
||||||
},
|
},
|
||||||
|
|
||||||
"task.command.workbench.send_message": async (loopCtx, msg) => {
|
"task.command.workbench.send_message": async (loopCtx, msg) => {
|
||||||
await loopCtx.step({
|
try {
|
||||||
name: "workbench-send-message",
|
await loopCtx.step({
|
||||||
timeout: 10 * 60_000,
|
name: "workbench-send-message",
|
||||||
run: async () => sendWorkbenchMessage(loopCtx, msg.body.sessionId, msg.body.text, msg.body.attachments),
|
timeout: 10 * 60_000,
|
||||||
});
|
run: async () => sendWorkbenchMessage(loopCtx, msg.body.sessionId, msg.body.text, msg.body.attachments),
|
||||||
await msg.complete({ ok: true });
|
});
|
||||||
|
await msg.complete({ ok: true });
|
||||||
|
} catch (error) {
|
||||||
|
await msg.complete({ error: resolveErrorMessage(error) });
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
"task.command.workbench.stop_session": async (loopCtx, msg) => {
|
"task.command.workbench.stop_session": async (loopCtx, msg) => {
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import type {
|
||||||
TaskWorkbenchSendMessageInput,
|
TaskWorkbenchSendMessageInput,
|
||||||
TaskWorkbenchTabInput,
|
TaskWorkbenchTabInput,
|
||||||
TaskWorkbenchUpdateDraftInput,
|
TaskWorkbenchUpdateDraftInput,
|
||||||
|
WorkbenchOpenPrSummary,
|
||||||
WorkbenchRepoSummary,
|
WorkbenchRepoSummary,
|
||||||
WorkbenchSessionSummary,
|
WorkbenchSessionSummary,
|
||||||
WorkbenchTaskSummary,
|
WorkbenchTaskSummary,
|
||||||
|
|
@ -36,12 +37,12 @@ import type {
|
||||||
WorkspaceUseInput,
|
WorkspaceUseInput,
|
||||||
} from "@sandbox-agent/foundry-shared";
|
} from "@sandbox-agent/foundry-shared";
|
||||||
import { getActorRuntimeContext } from "../context.js";
|
import { getActorRuntimeContext } from "../context.js";
|
||||||
import { getTask, getOrCreateHistory, getOrCreateProject, selfWorkspace } from "../handles.js";
|
import { getGithubData, getOrCreateGithubData, getTask, getOrCreateHistory, getOrCreateProject, selfWorkspace } from "../handles.js";
|
||||||
import { logActorWarning, resolveErrorMessage } from "../logging.js";
|
import { logActorWarning, resolveErrorMessage } from "../logging.js";
|
||||||
import { availableSandboxProviderIds, defaultSandboxProviderId } from "../../sandbox-config.js";
|
import { availableSandboxProviderIds, defaultSandboxProviderId } from "../../sandbox-config.js";
|
||||||
import { normalizeRemoteUrl, repoIdFromRemote } from "../../services/repo.js";
|
import { normalizeRemoteUrl, repoIdFromRemote } from "../../services/repo.js";
|
||||||
import { resolveWorkspaceGithubAuth } from "../../services/github-auth.js";
|
import { resolveWorkspaceGithubAuth } from "../../services/github-auth.js";
|
||||||
import { taskLookup, repos, providerProfiles, taskSummaries } from "./db/schema.js";
|
import { organizationProfile, taskLookup, repos, providerProfiles, taskSummaries } from "./db/schema.js";
|
||||||
import { agentTypeForModel } from "../task/workbench.js";
|
import { agentTypeForModel } from "../task/workbench.js";
|
||||||
import { expectQueueResponse } from "../../services/queue.js";
|
import { expectQueueResponse } from "../../services/queue.js";
|
||||||
import { workspaceAppActions } from "./app-shell.js";
|
import { workspaceAppActions } from "./app-shell.js";
|
||||||
|
|
@ -85,6 +86,8 @@ export function workspaceWorkflowQueueName(name: WorkspaceQueueName): WorkspaceQ
|
||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ORGANIZATION_PROFILE_ROW_ID = "profile";
|
||||||
|
|
||||||
function assertWorkspace(c: { state: WorkspaceState }, workspaceId: string): void {
|
function assertWorkspace(c: { state: WorkspaceState }, workspaceId: string): void {
|
||||||
if (workspaceId !== c.state.workspaceId) {
|
if (workspaceId !== c.state.workspaceId) {
|
||||||
throw new Error(`Workspace actor mismatch: actor=${c.state.workspaceId} command=${workspaceId}`);
|
throw new Error(`Workspace actor mismatch: actor=${c.state.workspaceId} command=${workspaceId}`);
|
||||||
|
|
@ -203,6 +206,14 @@ function taskSummaryFromRow(row: any): WorkbenchTaskSummary {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function listOpenPullRequestsSnapshot(c: any, taskRows: WorkbenchTaskSummary[]): Promise<WorkbenchOpenPrSummary[]> {
|
||||||
|
const githubData = getGithubData(c, c.state.workspaceId);
|
||||||
|
const openPullRequests = await githubData.listOpenPullRequests({}).catch(() => []);
|
||||||
|
const claimedBranches = new Set(taskRows.filter((task) => task.branch).map((task) => `${task.repoId}:${task.branch}`));
|
||||||
|
|
||||||
|
return openPullRequests.filter((pullRequest: WorkbenchOpenPrSummary) => !claimedBranches.has(`${pullRequest.repoId}:${pullRequest.headRefName}`));
|
||||||
|
}
|
||||||
|
|
||||||
async function reconcileWorkbenchProjection(c: any): Promise<WorkspaceSummarySnapshot> {
|
async function reconcileWorkbenchProjection(c: any): Promise<WorkspaceSummarySnapshot> {
|
||||||
const repoRows = await c.db
|
const repoRows = await c.db
|
||||||
.select({ repoId: repos.repoId, remoteUrl: repos.remoteUrl, updatedAt: repos.updatedAt })
|
.select({ repoId: repos.repoId, remoteUrl: repos.remoteUrl, updatedAt: repos.updatedAt })
|
||||||
|
|
@ -252,6 +263,7 @@ async function reconcileWorkbenchProjection(c: any): Promise<WorkspaceSummarySna
|
||||||
workspaceId: c.state.workspaceId,
|
workspaceId: c.state.workspaceId,
|
||||||
repos: repoRows.map((row) => buildRepoSummary(row, taskRows)).sort((left, right) => right.latestActivityMs - left.latestActivityMs),
|
repos: repoRows.map((row) => buildRepoSummary(row, taskRows)).sort((left, right) => right.latestActivityMs - left.latestActivityMs),
|
||||||
taskSummaries: taskRows,
|
taskSummaries: taskRows,
|
||||||
|
openPullRequests: await listOpenPullRequestsSnapshot(c, taskRows),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -280,8 +292,8 @@ async function waitForWorkbenchTaskReady(task: any, timeoutMs = 5 * 60_000): Pro
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads the workspace sidebar snapshot from the workspace actor's local SQLite
|
* Reads the workspace sidebar snapshot from the workspace actor's local SQLite
|
||||||
* only. Task actors push summary updates into `task_summaries`, so clients do
|
* plus the org-scoped GitHub actor for open PRs. Task actors still push
|
||||||
* not need this action to fan out to every child actor on the hot read path.
|
* summary updates into `task_summaries`, so the hot read path stays bounded.
|
||||||
*/
|
*/
|
||||||
async function getWorkspaceSummarySnapshot(c: any): Promise<WorkspaceSummarySnapshot> {
|
async function getWorkspaceSummarySnapshot(c: any): Promise<WorkspaceSummarySnapshot> {
|
||||||
const repoRows = await c.db
|
const repoRows = await c.db
|
||||||
|
|
@ -300,6 +312,7 @@ async function getWorkspaceSummarySnapshot(c: any): Promise<WorkspaceSummarySnap
|
||||||
workspaceId: c.state.workspaceId,
|
workspaceId: c.state.workspaceId,
|
||||||
repos: repoRows.map((row) => buildRepoSummary(row, summaries)).sort((left, right) => right.latestActivityMs - left.latestActivityMs),
|
repos: repoRows.map((row) => buildRepoSummary(row, summaries)).sort((left, right) => right.latestActivityMs - left.latestActivityMs),
|
||||||
taskSummaries: summaries,
|
taskSummaries: summaries,
|
||||||
|
openPullRequests: await listOpenPullRequestsSnapshot(c, summaries),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -463,58 +476,74 @@ export async function runWorkspaceWorkflow(ctx: any): Promise<void> {
|
||||||
return Loop.continue(undefined);
|
return Loop.continue(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.name === "workspace.command.addRepo") {
|
try {
|
||||||
const result = await loopCtx.step({
|
if (msg.name === "workspace.command.addRepo") {
|
||||||
name: "workspace-add-repo",
|
const result = await loopCtx.step({
|
||||||
timeout: 60_000,
|
name: "workspace-add-repo",
|
||||||
run: async () => addRepoMutation(loopCtx, msg.body as AddRepoInput),
|
timeout: 60_000,
|
||||||
});
|
run: async () => addRepoMutation(loopCtx, msg.body as AddRepoInput),
|
||||||
await msg.complete(result);
|
});
|
||||||
return Loop.continue(undefined);
|
await msg.complete(result);
|
||||||
}
|
return Loop.continue(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
if (msg.name === "workspace.command.createTask") {
|
if (msg.name === "workspace.command.createTask") {
|
||||||
const result = await loopCtx.step({
|
const result = await loopCtx.step({
|
||||||
name: "workspace-create-task",
|
name: "workspace-create-task",
|
||||||
timeout: 5 * 60_000,
|
timeout: 5 * 60_000,
|
||||||
run: async () => createTaskMutation(loopCtx, msg.body as CreateTaskInput),
|
run: async () => createTaskMutation(loopCtx, msg.body as CreateTaskInput),
|
||||||
});
|
});
|
||||||
await msg.complete(result);
|
await msg.complete(result);
|
||||||
return Loop.continue(undefined);
|
return Loop.continue(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.name === "workspace.command.refreshProviderProfiles") {
|
if (msg.name === "workspace.command.refreshProviderProfiles") {
|
||||||
await loopCtx.step("workspace-refresh-provider-profiles", async () =>
|
await loopCtx.step("workspace-refresh-provider-profiles", async () =>
|
||||||
refreshProviderProfilesMutation(loopCtx, msg.body as RefreshProviderProfilesCommand),
|
refreshProviderProfilesMutation(loopCtx, msg.body as RefreshProviderProfilesCommand),
|
||||||
);
|
);
|
||||||
await msg.complete({ ok: true });
|
await msg.complete({ ok: true });
|
||||||
return Loop.continue(undefined);
|
return Loop.continue(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.name === "workspace.command.syncGithubSession") {
|
if (msg.name === "workspace.command.syncGithubSession") {
|
||||||
await loopCtx.step({
|
await loopCtx.step({
|
||||||
name: "workspace-sync-github-session",
|
name: "workspace-sync-github-session",
|
||||||
timeout: 60_000,
|
timeout: 60_000,
|
||||||
run: async () => {
|
run: async () => {
|
||||||
const { syncGithubOrganizations } = await import("./app-shell.js");
|
const { syncGithubOrganizations } = await import("./app-shell.js");
|
||||||
await syncGithubOrganizations(loopCtx, msg.body as { sessionId: string; accessToken: string });
|
await syncGithubOrganizations(loopCtx, msg.body as { sessionId: string; accessToken: string });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await msg.complete({ ok: true });
|
await msg.complete({ ok: true });
|
||||||
return Loop.continue(undefined);
|
return Loop.continue(undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.name === "workspace.command.syncGithubOrganizationRepos") {
|
if (msg.name === "workspace.command.syncGithubOrganizationRepos") {
|
||||||
await loopCtx.step({
|
await loopCtx.step({
|
||||||
name: "workspace-sync-github-organization-repos",
|
name: "workspace-sync-github-organization-repos",
|
||||||
timeout: 60_000,
|
timeout: 60_000,
|
||||||
run: async () => {
|
run: async () => {
|
||||||
const { syncGithubOrganizationRepos } = await import("./app-shell.js");
|
const { syncGithubOrganizationRepos } = await import("./app-shell.js");
|
||||||
await syncGithubOrganizationRepos(loopCtx, msg.body as { sessionId: string; organizationId: string });
|
await syncGithubOrganizationRepos(loopCtx, msg.body as { sessionId: string; organizationId: string });
|
||||||
},
|
},
|
||||||
|
});
|
||||||
|
await msg.complete({ ok: true });
|
||||||
|
return Loop.continue(undefined);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const message = resolveErrorMessage(error);
|
||||||
|
logActorWarning("workspace", "workspace workflow command failed", {
|
||||||
|
workspaceId: loopCtx.state.workspaceId,
|
||||||
|
queueName: msg.name,
|
||||||
|
error: message,
|
||||||
|
});
|
||||||
|
await msg.complete({ error: message }).catch((completeError: unknown) => {
|
||||||
|
logActorWarning("workspace", "workspace workflow failed completing error response", {
|
||||||
|
workspaceId: loopCtx.state.workspaceId,
|
||||||
|
queueName: msg.name,
|
||||||
|
error: resolveErrorMessage(completeError),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
await msg.complete({ ok: true });
|
|
||||||
return Loop.continue(undefined);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return Loop.continue(undefined);
|
return Loop.continue(undefined);
|
||||||
|
|
@ -604,6 +633,175 @@ export const workspaceActions = {
|
||||||
c.broadcast("workspaceUpdated", { type: "taskRemoved", taskId: input.taskId } satisfies WorkspaceEvent);
|
c.broadcast("workspaceUpdated", { type: "taskRemoved", taskId: input.taskId } satisfies WorkspaceEvent);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async findTaskForGithubBranch(c: any, input: { repoId: string; branchName: string }): Promise<{ taskId: string | null }> {
|
||||||
|
const summaries = await c.db.select().from(taskSummaries).where(eq(taskSummaries.repoId, input.repoId)).all();
|
||||||
|
const existing = summaries.find((summary) => summary.branch === input.branchName);
|
||||||
|
return { taskId: existing?.taskId ?? null };
|
||||||
|
},
|
||||||
|
|
||||||
|
async refreshTaskSummaryForGithubBranch(c: any, input: { repoId: string; branchName: string }): Promise<void> {
|
||||||
|
const summaries = await c.db.select().from(taskSummaries).where(eq(taskSummaries.repoId, input.repoId)).all();
|
||||||
|
const matches = summaries.filter((summary) => summary.branch === input.branchName);
|
||||||
|
|
||||||
|
for (const summary of matches) {
|
||||||
|
try {
|
||||||
|
const task = getTask(c, c.state.workspaceId, input.repoId, summary.taskId);
|
||||||
|
await workspaceActions.applyTaskSummaryUpdate(c, {
|
||||||
|
taskSummary: await task.getTaskSummary({}),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logActorWarning("workspace", "failed refreshing task summary for GitHub branch", {
|
||||||
|
workspaceId: c.state.workspaceId,
|
||||||
|
repoId: input.repoId,
|
||||||
|
branchName: input.branchName,
|
||||||
|
taskId: summary.taskId,
|
||||||
|
error: resolveErrorMessage(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async applyOpenPullRequestUpdate(c: any, input: { pullRequest: WorkbenchOpenPrSummary }): Promise<void> {
|
||||||
|
const summaries = await c.db.select().from(taskSummaries).where(eq(taskSummaries.repoId, input.pullRequest.repoId)).all();
|
||||||
|
if (summaries.some((summary) => summary.branch === input.pullRequest.headRefName)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
c.broadcast("workspaceUpdated", { type: "pullRequestUpdated", pullRequest: input.pullRequest } satisfies WorkspaceEvent);
|
||||||
|
},
|
||||||
|
|
||||||
|
async removeOpenPullRequest(c: any, input: { prId: string }): Promise<void> {
|
||||||
|
c.broadcast("workspaceUpdated", { type: "pullRequestRemoved", prId: input.prId } satisfies WorkspaceEvent);
|
||||||
|
},
|
||||||
|
|
||||||
|
async applyGithubRepositoryProjection(c: any, input: { repoId: string; remoteUrl: string }): Promise<void> {
|
||||||
|
const now = Date.now();
|
||||||
|
const existing = await c.db.select({ repoId: repos.repoId }).from(repos).where(eq(repos.repoId, input.repoId)).get();
|
||||||
|
await c.db
|
||||||
|
.insert(repos)
|
||||||
|
.values({
|
||||||
|
repoId: input.repoId,
|
||||||
|
remoteUrl: input.remoteUrl,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: repos.repoId,
|
||||||
|
set: {
|
||||||
|
remoteUrl: input.remoteUrl,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
await broadcastRepoSummary(c, existing ? "repoUpdated" : "repoAdded", {
|
||||||
|
repoId: input.repoId,
|
||||||
|
remoteUrl: input.remoteUrl,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
async applyGithubDataProjection(
|
||||||
|
c: any,
|
||||||
|
input: {
|
||||||
|
connectedAccount: string;
|
||||||
|
installationStatus: string;
|
||||||
|
installationId: number | null;
|
||||||
|
syncStatus: string;
|
||||||
|
lastSyncLabel: string;
|
||||||
|
lastSyncAt: number | null;
|
||||||
|
repositories: Array<{ fullName: string; cloneUrl: string; private: boolean }>;
|
||||||
|
},
|
||||||
|
): Promise<void> {
|
||||||
|
const existingRepos = await c.db.select({ repoId: repos.repoId, remoteUrl: repos.remoteUrl, updatedAt: repos.updatedAt }).from(repos).all();
|
||||||
|
const existingById = new Map(existingRepos.map((repo) => [repo.repoId, repo]));
|
||||||
|
const nextRepoIds = new Set<string>();
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
for (const repository of input.repositories) {
|
||||||
|
const repoId = repoIdFromRemote(repository.cloneUrl);
|
||||||
|
nextRepoIds.add(repoId);
|
||||||
|
await c.db
|
||||||
|
.insert(repos)
|
||||||
|
.values({
|
||||||
|
repoId,
|
||||||
|
remoteUrl: repository.cloneUrl,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: repos.repoId,
|
||||||
|
set: {
|
||||||
|
remoteUrl: repository.cloneUrl,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.run();
|
||||||
|
await broadcastRepoSummary(c, existingById.has(repoId) ? "repoUpdated" : "repoAdded", {
|
||||||
|
repoId,
|
||||||
|
remoteUrl: repository.cloneUrl,
|
||||||
|
updatedAt: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const repo of existingRepos) {
|
||||||
|
if (nextRepoIds.has(repo.repoId)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await c.db.delete(repos).where(eq(repos.repoId, repo.repoId)).run();
|
||||||
|
c.broadcast("workspaceUpdated", { type: "repoRemoved", repoId: repo.repoId } satisfies WorkspaceEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = await c.db
|
||||||
|
.select({ id: organizationProfile.id })
|
||||||
|
.from(organizationProfile)
|
||||||
|
.where(eq(organizationProfile.id, ORGANIZATION_PROFILE_ROW_ID))
|
||||||
|
.get();
|
||||||
|
if (profile) {
|
||||||
|
await c.db
|
||||||
|
.update(organizationProfile)
|
||||||
|
.set({
|
||||||
|
githubConnectedAccount: input.connectedAccount,
|
||||||
|
githubInstallationStatus: input.installationStatus,
|
||||||
|
githubSyncStatus: input.syncStatus,
|
||||||
|
githubInstallationId: input.installationId,
|
||||||
|
githubLastSyncLabel: input.lastSyncLabel,
|
||||||
|
githubLastSyncAt: input.lastSyncAt,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.where(eq(organizationProfile.id, ORGANIZATION_PROFILE_ROW_ID))
|
||||||
|
.run();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async recordGithubWebhookReceipt(
|
||||||
|
c: any,
|
||||||
|
input: {
|
||||||
|
workspaceId: string;
|
||||||
|
event: string;
|
||||||
|
action?: string | null;
|
||||||
|
receivedAt?: number;
|
||||||
|
},
|
||||||
|
): Promise<void> {
|
||||||
|
assertWorkspace(c, input.workspaceId);
|
||||||
|
|
||||||
|
const profile = await c.db
|
||||||
|
.select({ id: organizationProfile.id })
|
||||||
|
.from(organizationProfile)
|
||||||
|
.where(eq(organizationProfile.id, ORGANIZATION_PROFILE_ROW_ID))
|
||||||
|
.get();
|
||||||
|
if (!profile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await c.db
|
||||||
|
.update(organizationProfile)
|
||||||
|
.set({
|
||||||
|
githubLastWebhookAt: input.receivedAt ?? Date.now(),
|
||||||
|
githubLastWebhookEvent: input.action ? `${input.event}.${input.action}` : input.event,
|
||||||
|
})
|
||||||
|
.where(eq(organizationProfile.id, ORGANIZATION_PROFILE_ROW_ID))
|
||||||
|
.run();
|
||||||
|
},
|
||||||
|
|
||||||
async getWorkspaceSummary(c: any, input: WorkspaceUseInput): Promise<WorkspaceSummarySnapshot> {
|
async getWorkspaceSummary(c: any, input: WorkspaceUseInput): Promise<WorkspaceSummarySnapshot> {
|
||||||
assertWorkspace(c, input.workspaceId);
|
assertWorkspace(c, input.workspaceId);
|
||||||
return await getWorkspaceSummarySnapshot(c);
|
return await getWorkspaceSummarySnapshot(c);
|
||||||
|
|
@ -620,7 +818,7 @@ export const workspaceActions = {
|
||||||
repoId: input.repoId,
|
repoId: input.repoId,
|
||||||
task: input.task,
|
task: input.task,
|
||||||
...(input.title ? { explicitTitle: input.title } : {}),
|
...(input.title ? { explicitTitle: input.title } : {}),
|
||||||
...(input.branch ? { explicitBranchName: input.branch } : {}),
|
...(input.onBranch ? { onBranch: input.onBranch } : input.branch ? { explicitBranchName: input.branch } : {}),
|
||||||
...(input.model ? { agentType: agentTypeForModel(input.model) } : {}),
|
...(input.model ? { agentType: agentTypeForModel(input.model) } : {}),
|
||||||
});
|
});
|
||||||
const task = await requireWorkbenchTask(c, created.taskId);
|
const task = await requireWorkbenchTask(c, created.taskId);
|
||||||
|
|
@ -634,6 +832,10 @@ export const workspaceActions = {
|
||||||
tabId: session.tabId,
|
tabId: session.tabId,
|
||||||
text: input.task,
|
text: input.task,
|
||||||
attachments: [],
|
attachments: [],
|
||||||
|
waitForCompletion: true,
|
||||||
|
});
|
||||||
|
await task.getSessionDetail({
|
||||||
|
sessionId: session.tabId,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
taskId: created.taskId,
|
taskId: created.taskId,
|
||||||
|
|
@ -706,6 +908,22 @@ export const workspaceActions = {
|
||||||
await task.revertWorkbenchFile(input);
|
await task.revertWorkbenchFile(input);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async reloadGithubOrganization(c: any): Promise<void> {
|
||||||
|
await getOrCreateGithubData(c, c.state.workspaceId).reloadOrganization({});
|
||||||
|
},
|
||||||
|
|
||||||
|
async reloadGithubPullRequests(c: any): Promise<void> {
|
||||||
|
await getOrCreateGithubData(c, c.state.workspaceId).reloadAllPullRequests({});
|
||||||
|
},
|
||||||
|
|
||||||
|
async reloadGithubRepository(c: any, input: { repoId: string }): Promise<void> {
|
||||||
|
await getOrCreateGithubData(c, c.state.workspaceId).reloadRepository(input);
|
||||||
|
},
|
||||||
|
|
||||||
|
async reloadGithubPullRequest(c: any, input: { repoId: string; prNumber: number }): Promise<void> {
|
||||||
|
await getOrCreateGithubData(c, c.state.workspaceId).reloadPullRequest(input);
|
||||||
|
},
|
||||||
|
|
||||||
async listTasks(c: any, input: ListTasksInput): Promise<TaskSummary[]> {
|
async listTasks(c: any, input: ListTasksInput): Promise<TaskSummary[]> {
|
||||||
assertWorkspace(c, input.workspaceId);
|
assertWorkspace(c, input.workspaceId);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import type {
|
||||||
UpdateFoundryOrganizationProfileInput,
|
UpdateFoundryOrganizationProfileInput,
|
||||||
} from "@sandbox-agent/foundry-shared";
|
} from "@sandbox-agent/foundry-shared";
|
||||||
import { getActorRuntimeContext } from "../context.js";
|
import { getActorRuntimeContext } from "../context.js";
|
||||||
import { getOrCreateWorkspace, selfWorkspace } from "../handles.js";
|
import { getOrCreateGithubData, getOrCreateWorkspace, selfWorkspace } from "../handles.js";
|
||||||
import { GitHubAppError } from "../../services/app-github.js";
|
import { GitHubAppError } from "../../services/app-github.js";
|
||||||
import { getBetterAuthService } from "../../services/better-auth.js";
|
import { getBetterAuthService } from "../../services/better-auth.js";
|
||||||
import { repoIdFromRemote, repoLabelFromRemote } from "../../services/repo.js";
|
import { repoIdFromRemote, repoLabelFromRemote } from "../../services/repo.js";
|
||||||
|
|
@ -601,40 +601,19 @@ export async function syncGithubOrganizationRepos(c: any, input: { sessionId: st
|
||||||
const session = await requireSignedInSession(c, input.sessionId);
|
const session = await requireSignedInSession(c, input.sessionId);
|
||||||
requireEligibleOrganization(session, input.organizationId);
|
requireEligibleOrganization(session, input.organizationId);
|
||||||
|
|
||||||
const { appShell } = getActorRuntimeContext();
|
|
||||||
const workspace = await getOrCreateWorkspace(c, input.organizationId);
|
const workspace = await getOrCreateWorkspace(c, input.organizationId);
|
||||||
const organization = await getOrganizationState(workspace);
|
const organization = await getOrganizationState(workspace);
|
||||||
|
const githubData = await getOrCreateGithubData(c, input.organizationId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let repositories;
|
await githubData.fullSync({
|
||||||
let installationStatus = organization.snapshot.github.installationStatus;
|
accessToken: session.githubAccessToken,
|
||||||
|
connectedAccount: organization.snapshot.github.connectedAccount,
|
||||||
if (organization.snapshot.kind === "personal") {
|
installationId: organization.githubInstallationId,
|
||||||
repositories = await appShell.github.listUserRepositories(session.githubAccessToken);
|
installationStatus: organization.snapshot.github.installationStatus,
|
||||||
installationStatus = "connected";
|
githubLogin: organization.githubLogin,
|
||||||
} else if (organization.githubInstallationId) {
|
kind: organization.snapshot.kind,
|
||||||
try {
|
label: "Importing repository catalog...",
|
||||||
repositories = await appShell.github.listInstallationRepositories(organization.githubInstallationId);
|
|
||||||
} catch (error) {
|
|
||||||
if (!(error instanceof GitHubAppError) || (error.status !== 403 && error.status !== 404)) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
repositories = (await appShell.github.listUserRepositories(session.githubAccessToken)).filter((repository) =>
|
|
||||||
repository.fullName.startsWith(`${organization.githubLogin}/`),
|
|
||||||
);
|
|
||||||
installationStatus = "reconnect_required";
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
repositories = (await appShell.github.listUserRepositories(session.githubAccessToken)).filter((repository) =>
|
|
||||||
repository.fullName.startsWith(`${organization.githubLogin}/`),
|
|
||||||
);
|
|
||||||
installationStatus = "reconnect_required";
|
|
||||||
}
|
|
||||||
|
|
||||||
await workspace.applyOrganizationSyncCompleted({
|
|
||||||
repositories,
|
|
||||||
installationStatus,
|
|
||||||
lastSyncLabel: repositories.length > 0 ? "Synced just now" : "No repositories available",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Broadcast updated app snapshot so connected clients see the new repos
|
// Broadcast updated app snapshot so connected clients see the new repos
|
||||||
|
|
@ -759,6 +738,8 @@ async function buildOrganizationStateFromRow(c: any, row: any, startedAt: number
|
||||||
importedRepoCount: repoCatalog.length,
|
importedRepoCount: repoCatalog.length,
|
||||||
lastSyncLabel: row.githubLastSyncLabel,
|
lastSyncLabel: row.githubLastSyncLabel,
|
||||||
lastSyncAt: row.githubLastSyncAt ?? null,
|
lastSyncAt: row.githubLastSyncAt ?? null,
|
||||||
|
lastWebhookAt: row.githubLastWebhookAt ?? null,
|
||||||
|
lastWebhookEvent: row.githubLastWebhookEvent ?? "",
|
||||||
},
|
},
|
||||||
billing: {
|
billing: {
|
||||||
planId: row.billingPlanId,
|
planId: row.billingPlanId,
|
||||||
|
|
@ -1433,8 +1414,8 @@ export const workspaceAppActions = {
|
||||||
const { appShell } = getActorRuntimeContext();
|
const { appShell } = getActorRuntimeContext();
|
||||||
const { event, body } = appShell.github.verifyWebhookEvent(input.payload, input.signatureHeader, input.eventHeader);
|
const { event, body } = appShell.github.verifyWebhookEvent(input.payload, input.signatureHeader, input.eventHeader);
|
||||||
|
|
||||||
const accountLogin = body.installation?.account?.login;
|
const accountLogin = body.installation?.account?.login ?? body.repository?.owner?.login ?? body.organization?.login ?? null;
|
||||||
const accountType = body.installation?.account?.type;
|
const accountType = body.installation?.account?.type ?? (body.organization?.login ? "Organization" : null);
|
||||||
if (!accountLogin) {
|
if (!accountLogin) {
|
||||||
githubWebhookLogger.info(
|
githubWebhookLogger.info(
|
||||||
{
|
{
|
||||||
|
|
@ -1449,6 +1430,15 @@ export const workspaceAppActions = {
|
||||||
|
|
||||||
const kind: FoundryOrganization["kind"] = accountType === "User" ? "personal" : "organization";
|
const kind: FoundryOrganization["kind"] = accountType === "User" ? "personal" : "organization";
|
||||||
const organizationId = organizationWorkspaceId(kind, accountLogin);
|
const organizationId = organizationWorkspaceId(kind, accountLogin);
|
||||||
|
const receivedAt = Date.now();
|
||||||
|
const workspace = await getOrCreateWorkspace(c, organizationId);
|
||||||
|
await workspace.recordGithubWebhookReceipt({
|
||||||
|
workspaceId: organizationId,
|
||||||
|
event,
|
||||||
|
action: body.action ?? null,
|
||||||
|
receivedAt,
|
||||||
|
});
|
||||||
|
const githubData = await getOrCreateGithubData(c, organizationId);
|
||||||
|
|
||||||
if (event === "installation" && (body.action === "created" || body.action === "deleted" || body.action === "suspend" || body.action === "unsuspend")) {
|
if (event === "installation" && (body.action === "created" || body.action === "deleted" || body.action === "suspend" || body.action === "unsuspend")) {
|
||||||
githubWebhookLogger.info(
|
githubWebhookLogger.info(
|
||||||
|
|
@ -1461,12 +1451,36 @@ export const workspaceAppActions = {
|
||||||
"installation_event",
|
"installation_event",
|
||||||
);
|
);
|
||||||
if (body.action === "deleted") {
|
if (body.action === "deleted") {
|
||||||
const workspace = await getOrCreateWorkspace(c, organizationId);
|
await githubData.clearState({
|
||||||
await workspace.applyGithubInstallationRemoved({});
|
connectedAccount: accountLogin,
|
||||||
|
installationStatus: "install_required",
|
||||||
|
installationId: null,
|
||||||
|
label: "GitHub App installation removed",
|
||||||
|
});
|
||||||
} else if (body.action === "created") {
|
} else if (body.action === "created") {
|
||||||
const workspace = await getOrCreateWorkspace(c, organizationId);
|
await githubData.fullSync({
|
||||||
await workspace.applyGithubInstallationCreated({
|
connectedAccount: accountLogin,
|
||||||
installationId: body.installation?.id ?? 0,
|
installationStatus: "connected",
|
||||||
|
installationId: body.installation?.id ?? null,
|
||||||
|
githubLogin: accountLogin,
|
||||||
|
kind,
|
||||||
|
label: "Syncing GitHub data from installation webhook...",
|
||||||
|
});
|
||||||
|
} else if (body.action === "suspend") {
|
||||||
|
await githubData.clearState({
|
||||||
|
connectedAccount: accountLogin,
|
||||||
|
installationStatus: "reconnect_required",
|
||||||
|
installationId: body.installation?.id ?? null,
|
||||||
|
label: "GitHub App installation suspended",
|
||||||
|
});
|
||||||
|
} else if (body.action === "unsuspend") {
|
||||||
|
await githubData.fullSync({
|
||||||
|
connectedAccount: accountLogin,
|
||||||
|
installationStatus: "connected",
|
||||||
|
installationId: body.installation?.id ?? null,
|
||||||
|
githubLogin: accountLogin,
|
||||||
|
kind,
|
||||||
|
label: "Resyncing GitHub data after unsuspend...",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
|
|
@ -1484,13 +1498,13 @@ export const workspaceAppActions = {
|
||||||
},
|
},
|
||||||
"repository_membership_changed",
|
"repository_membership_changed",
|
||||||
);
|
);
|
||||||
const workspace = await getOrCreateWorkspace(c, organizationId);
|
await githubData.fullSync({
|
||||||
await workspace.applyGithubRepositoryChanges({
|
connectedAccount: accountLogin,
|
||||||
added: (body.repositories_added ?? []).map((r) => ({
|
installationStatus: "connected",
|
||||||
fullName: r.full_name,
|
installationId: body.installation?.id ?? null,
|
||||||
private: r.private,
|
githubLogin: accountLogin,
|
||||||
})),
|
kind,
|
||||||
removed: (body.repositories_removed ?? []).map((r) => r.full_name),
|
label: "Resyncing GitHub data after repository access change...",
|
||||||
});
|
});
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
@ -1518,7 +1532,30 @@ export const workspaceAppActions = {
|
||||||
},
|
},
|
||||||
"repository_event",
|
"repository_event",
|
||||||
);
|
);
|
||||||
// TODO: Dispatch to GitHubStateActor / downstream actors
|
if (event === "pull_request" && body.repository?.clone_url && body.pull_request) {
|
||||||
|
await githubData.handlePullRequestWebhook({
|
||||||
|
connectedAccount: accountLogin,
|
||||||
|
installationStatus: "connected",
|
||||||
|
installationId: body.installation?.id ?? null,
|
||||||
|
repository: {
|
||||||
|
fullName: body.repository.full_name,
|
||||||
|
cloneUrl: body.repository.clone_url,
|
||||||
|
private: Boolean(body.repository.private),
|
||||||
|
},
|
||||||
|
pullRequest: {
|
||||||
|
number: body.pull_request.number,
|
||||||
|
title: body.pull_request.title ?? "",
|
||||||
|
body: body.pull_request.body ?? null,
|
||||||
|
state: body.pull_request.state ?? "open",
|
||||||
|
url: body.pull_request.html_url ?? `https://github.com/${body.repository.full_name}/pull/${body.pull_request.number}`,
|
||||||
|
headRefName: body.pull_request.head?.ref ?? "",
|
||||||
|
baseRefName: body.pull_request.base?.ref ?? "",
|
||||||
|
authorLogin: body.pull_request.user?.login ?? null,
|
||||||
|
isDraft: Boolean(body.pull_request.draft),
|
||||||
|
merged: Boolean(body.pull_request.merged),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,8 @@ CREATE TABLE `organization_profile` (
|
||||||
`github_installation_id` integer,
|
`github_installation_id` integer,
|
||||||
`github_last_sync_label` text NOT NULL,
|
`github_last_sync_label` text NOT NULL,
|
||||||
`github_last_sync_at` integer,
|
`github_last_sync_at` integer,
|
||||||
|
`github_last_webhook_at` integer,
|
||||||
|
`github_last_webhook_event` text,
|
||||||
`stripe_customer_id` text,
|
`stripe_customer_id` text,
|
||||||
`stripe_subscription_id` text,
|
`stripe_subscription_id` text,
|
||||||
`stripe_price_id` text,
|
`stripe_price_id` text,
|
||||||
|
|
|
||||||
|
|
@ -359,6 +359,20 @@
|
||||||
"notNull": false,
|
"notNull": false,
|
||||||
"autoincrement": false
|
"autoincrement": false
|
||||||
},
|
},
|
||||||
|
"github_last_webhook_at": {
|
||||||
|
"name": "github_last_webhook_at",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
|
"github_last_webhook_event": {
|
||||||
|
"name": "github_last_webhook_event",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false,
|
||||||
|
"autoincrement": false
|
||||||
|
},
|
||||||
"stripe_customer_id": {
|
"stripe_customer_id": {
|
||||||
"name": "stripe_customer_id",
|
"name": "stripe_customer_id",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,8 @@ CREATE TABLE \`organization_profile\` (
|
||||||
\`github_installation_id\` integer,
|
\`github_installation_id\` integer,
|
||||||
\`github_last_sync_label\` text NOT NULL,
|
\`github_last_sync_label\` text NOT NULL,
|
||||||
\`github_last_sync_at\` integer,
|
\`github_last_sync_at\` integer,
|
||||||
|
\`github_last_webhook_at\` integer,
|
||||||
|
\`github_last_webhook_event\` text,
|
||||||
\`stripe_customer_id\` text,
|
\`stripe_customer_id\` text,
|
||||||
\`stripe_subscription_id\` text,
|
\`stripe_subscription_id\` text,
|
||||||
\`stripe_price_id\` text,
|
\`stripe_price_id\` text,
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,8 @@ export const organizationProfile = sqliteTable("organization_profile", {
|
||||||
githubInstallationId: integer("github_installation_id"),
|
githubInstallationId: integer("github_installation_id"),
|
||||||
githubLastSyncLabel: text("github_last_sync_label").notNull(),
|
githubLastSyncLabel: text("github_last_sync_label").notNull(),
|
||||||
githubLastSyncAt: integer("github_last_sync_at"),
|
githubLastSyncAt: integer("github_last_sync_at"),
|
||||||
|
githubLastWebhookAt: integer("github_last_webhook_at"),
|
||||||
|
githubLastWebhookEvent: text("github_last_webhook_event"),
|
||||||
stripeCustomerId: text("stripe_customer_id"),
|
stripeCustomerId: text("stripe_customer_id"),
|
||||||
stripeSubscriptionId: text("stripe_subscription_id"),
|
stripeSubscriptionId: text("stripe_subscription_id"),
|
||||||
stripePriceId: text("stripe_price_id"),
|
stripePriceId: text("stripe_price_id"),
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,30 @@ export interface GitHubRepositoryRecord {
|
||||||
private: boolean;
|
private: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GitHubMemberRecord {
|
||||||
|
id: string;
|
||||||
|
login: string;
|
||||||
|
name: string;
|
||||||
|
email: string | null;
|
||||||
|
role: string | null;
|
||||||
|
state: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitHubPullRequestRecord {
|
||||||
|
repoFullName: string;
|
||||||
|
cloneUrl: string;
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
body: string | null;
|
||||||
|
state: string;
|
||||||
|
url: string;
|
||||||
|
headRefName: string;
|
||||||
|
baseRefName: string;
|
||||||
|
authorLogin: string | null;
|
||||||
|
isDraft: boolean;
|
||||||
|
merged: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface GitHubTokenResponse {
|
interface GitHubTokenResponse {
|
||||||
access_token?: string;
|
access_token?: string;
|
||||||
scope?: string;
|
scope?: string;
|
||||||
|
|
@ -58,11 +82,23 @@ const githubOAuthLogger = logger.child({
|
||||||
|
|
||||||
export interface GitHubWebhookEvent {
|
export interface GitHubWebhookEvent {
|
||||||
action?: string;
|
action?: string;
|
||||||
|
organization?: { login?: string; id?: number };
|
||||||
installation?: { id: number; account?: { login?: string; type?: string; id?: number } | null };
|
installation?: { id: number; account?: { login?: string; type?: string; id?: number } | null };
|
||||||
repositories_added?: Array<{ id: number; full_name: string; private: boolean }>;
|
repositories_added?: Array<{ id: number; full_name: string; private: boolean }>;
|
||||||
repositories_removed?: Array<{ id: number; full_name: string }>;
|
repositories_removed?: Array<{ id: number; full_name: string }>;
|
||||||
repository?: { id: number; full_name: string; clone_url?: string; private?: boolean; owner?: { login?: string } };
|
repository?: { id: number; full_name: string; clone_url?: string; private?: boolean; owner?: { login?: string } };
|
||||||
pull_request?: { number: number; title?: string; state?: string; head?: { ref?: string }; base?: { ref?: string } };
|
pull_request?: {
|
||||||
|
number: number;
|
||||||
|
title?: string;
|
||||||
|
body?: string | null;
|
||||||
|
state?: string;
|
||||||
|
html_url?: string;
|
||||||
|
draft?: boolean;
|
||||||
|
merged?: boolean;
|
||||||
|
user?: { login?: string } | null;
|
||||||
|
head?: { ref?: string };
|
||||||
|
base?: { ref?: string };
|
||||||
|
};
|
||||||
sender?: { login?: string; id?: number };
|
sender?: { login?: string; id?: number };
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
@ -329,6 +365,130 @@ export class GitHubAppClient {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getUserRepository(accessToken: string, fullName: string): Promise<GitHubRepositoryRecord | null> {
|
||||||
|
try {
|
||||||
|
const repository = await this.requestJson<{
|
||||||
|
full_name: string;
|
||||||
|
clone_url: string;
|
||||||
|
private: boolean;
|
||||||
|
}>(`/repos/${fullName}`, accessToken);
|
||||||
|
return {
|
||||||
|
fullName: repository.full_name,
|
||||||
|
cloneUrl: repository.clone_url,
|
||||||
|
private: repository.private,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof GitHubAppError && error.status === 404) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getInstallationRepository(installationId: number, fullName: string): Promise<GitHubRepositoryRecord | null> {
|
||||||
|
const accessToken = await this.createInstallationAccessToken(installationId);
|
||||||
|
return await this.getUserRepository(accessToken, fullName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listOrganizationMembers(accessToken: string, organizationLogin: string): Promise<GitHubMemberRecord[]> {
|
||||||
|
const members = await this.paginate<{
|
||||||
|
id: number;
|
||||||
|
login: string;
|
||||||
|
role?: string | null;
|
||||||
|
}>(`/orgs/${organizationLogin}/members?per_page=100&role=all`, accessToken);
|
||||||
|
|
||||||
|
const detailedMembers = await Promise.all(
|
||||||
|
members.map(async (member) => {
|
||||||
|
try {
|
||||||
|
const detail = await this.requestJson<{
|
||||||
|
id: number;
|
||||||
|
login: string;
|
||||||
|
name?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
}>(`/users/${member.login}`, accessToken);
|
||||||
|
return {
|
||||||
|
id: String(detail.id),
|
||||||
|
login: detail.login,
|
||||||
|
name: detail.name?.trim() || detail.login,
|
||||||
|
email: detail.email ?? null,
|
||||||
|
role: member.role ?? null,
|
||||||
|
state: "active",
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
id: String(member.id),
|
||||||
|
login: member.login,
|
||||||
|
name: member.login,
|
||||||
|
email: null,
|
||||||
|
role: member.role ?? null,
|
||||||
|
state: "active",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return detailedMembers;
|
||||||
|
}
|
||||||
|
|
||||||
|
async listInstallationMembers(installationId: number, organizationLogin: string): Promise<GitHubMemberRecord[]> {
|
||||||
|
const accessToken = await this.createInstallationAccessToken(installationId);
|
||||||
|
return await this.listOrganizationMembers(accessToken, organizationLogin);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listPullRequestsForUserRepositories(accessToken: string, repositories: GitHubRepositoryRecord[]): Promise<GitHubPullRequestRecord[]> {
|
||||||
|
return (await Promise.all(repositories.map((repository) => this.listRepositoryPullRequests(accessToken, repository.fullName, repository.cloneUrl)))).flat();
|
||||||
|
}
|
||||||
|
|
||||||
|
async listInstallationPullRequestsForRepositories(installationId: number, repositories: GitHubRepositoryRecord[]): Promise<GitHubPullRequestRecord[]> {
|
||||||
|
const accessToken = await this.createInstallationAccessToken(installationId);
|
||||||
|
return await this.listPullRequestsForUserRepositories(accessToken, repositories);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserPullRequest(accessToken: string, fullName: string, prNumber: number): Promise<GitHubPullRequestRecord | null> {
|
||||||
|
try {
|
||||||
|
const pullRequest = await this.requestJson<{
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
body?: string | null;
|
||||||
|
state: string;
|
||||||
|
html_url: string;
|
||||||
|
draft?: boolean;
|
||||||
|
merged?: boolean;
|
||||||
|
user?: { login?: string } | null;
|
||||||
|
head?: { ref?: string } | null;
|
||||||
|
base?: { ref?: string } | null;
|
||||||
|
}>(`/repos/${fullName}/pulls/${prNumber}`, accessToken);
|
||||||
|
const repository = await this.getUserRepository(accessToken, fullName);
|
||||||
|
if (!repository) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
repoFullName: fullName,
|
||||||
|
cloneUrl: repository.cloneUrl,
|
||||||
|
number: pullRequest.number,
|
||||||
|
title: pullRequest.title,
|
||||||
|
body: pullRequest.body ?? null,
|
||||||
|
state: pullRequest.state,
|
||||||
|
url: pullRequest.html_url,
|
||||||
|
headRefName: pullRequest.head?.ref?.trim() ?? "",
|
||||||
|
baseRefName: pullRequest.base?.ref?.trim() ?? "",
|
||||||
|
authorLogin: pullRequest.user?.login?.trim() ?? null,
|
||||||
|
isDraft: Boolean(pullRequest.draft),
|
||||||
|
merged: Boolean(pullRequest.merged),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof GitHubAppError && error.status === 404) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getInstallationPullRequest(installationId: number, fullName: string, prNumber: number): Promise<GitHubPullRequestRecord | null> {
|
||||||
|
const accessToken = await this.createInstallationAccessToken(installationId);
|
||||||
|
return await this.getUserPullRequest(accessToken, fullName, prNumber);
|
||||||
|
}
|
||||||
|
|
||||||
async buildInstallationUrl(organizationLogin: string, state: string): Promise<string> {
|
async buildInstallationUrl(organizationLogin: string, state: string): Promise<string> {
|
||||||
if (!this.isAppConfigured()) {
|
if (!this.isAppConfigured()) {
|
||||||
throw new GitHubAppError("GitHub App is not configured", 500);
|
throw new GitHubAppError("GitHub App is not configured", 500);
|
||||||
|
|
@ -437,6 +597,36 @@ export class GitHubAppClient {
|
||||||
return payload as T;
|
return payload as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async listRepositoryPullRequests(accessToken: string, fullName: string, cloneUrl: string): Promise<GitHubPullRequestRecord[]> {
|
||||||
|
const pullRequests = await this.paginate<{
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
body?: string | null;
|
||||||
|
state: string;
|
||||||
|
html_url: string;
|
||||||
|
draft?: boolean;
|
||||||
|
merged?: boolean;
|
||||||
|
user?: { login?: string } | null;
|
||||||
|
head?: { ref?: string } | null;
|
||||||
|
base?: { ref?: string } | null;
|
||||||
|
}>(`/repos/${fullName}/pulls?state=open&per_page=100&sort=updated&direction=desc`, accessToken);
|
||||||
|
|
||||||
|
return pullRequests.map((pullRequest) => ({
|
||||||
|
repoFullName: fullName,
|
||||||
|
cloneUrl,
|
||||||
|
number: pullRequest.number,
|
||||||
|
title: pullRequest.title,
|
||||||
|
body: pullRequest.body ?? null,
|
||||||
|
state: pullRequest.state,
|
||||||
|
url: pullRequest.html_url,
|
||||||
|
headRefName: pullRequest.head?.ref?.trim() ?? "",
|
||||||
|
baseRefName: pullRequest.base?.ref?.trim() ?? "",
|
||||||
|
authorLogin: pullRequest.user?.login?.trim() ?? null,
|
||||||
|
isDraft: Boolean(pullRequest.draft),
|
||||||
|
merged: Boolean(pullRequest.merged),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
private async paginate<T>(path: string, accessToken: string): Promise<T[]> {
|
private async paginate<T>(path: string, accessToken: string): Promise<T[]> {
|
||||||
let nextUrl = `${this.apiBaseUrl}${path.startsWith("/") ? path : `/${path}`}`;
|
let nextUrl = `${this.apiBaseUrl}${path.startsWith("/") ? path : `/${path}`}`;
|
||||||
const items: T[] = [];
|
const items: T[] = [];
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,14 @@ export function expectQueueResponse<T>(result: QueueSendResult | void): T {
|
||||||
if (!result || result.status === "timedOut") {
|
if (!result || result.status === "timedOut") {
|
||||||
throw new Error("Queue command timed out");
|
throw new Error("Queue command timed out");
|
||||||
}
|
}
|
||||||
|
if (
|
||||||
|
result.response &&
|
||||||
|
typeof result.response === "object" &&
|
||||||
|
"error" in result.response &&
|
||||||
|
typeof (result.response as { error?: unknown }).error === "string"
|
||||||
|
) {
|
||||||
|
throw new Error((result.response as { error: string }).error);
|
||||||
|
}
|
||||||
return result.response as T;
|
return result.response as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { taskKey, historyKey, projectBranchSyncKey, projectKey, projectPrSyncKey, taskSandboxKey, workspaceKey } from "../src/actors/keys.js";
|
import { githubDataKey, historyKey, projectBranchSyncKey, projectKey, taskKey, taskSandboxKey, workspaceKey } from "../src/actors/keys.js";
|
||||||
|
|
||||||
describe("actor keys", () => {
|
describe("actor keys", () => {
|
||||||
it("prefixes every key with workspace namespace", () => {
|
it("prefixes every key with workspace namespace", () => {
|
||||||
|
|
@ -9,7 +9,7 @@ describe("actor keys", () => {
|
||||||
taskKey("default", "repo", "task"),
|
taskKey("default", "repo", "task"),
|
||||||
taskSandboxKey("default", "sbx"),
|
taskSandboxKey("default", "sbx"),
|
||||||
historyKey("default", "repo"),
|
historyKey("default", "repo"),
|
||||||
projectPrSyncKey("default", "repo"),
|
githubDataKey("default"),
|
||||||
projectBranchSyncKey("default", "repo"),
|
projectBranchSyncKey("default", "repo"),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { shouldMarkSessionUnreadForStatus } from "../src/actors/task/workbench.js";
|
import { shouldMarkSessionUnreadForStatus, shouldRecreateSessionForModelChange } from "../src/actors/task/workbench.js";
|
||||||
|
|
||||||
describe("workbench unread status transitions", () => {
|
describe("workbench unread status transitions", () => {
|
||||||
it("marks unread when a running session first becomes idle", () => {
|
it("marks unread when a running session first becomes idle", () => {
|
||||||
|
|
@ -14,3 +14,46 @@ describe("workbench unread status transitions", () => {
|
||||||
expect(shouldMarkSessionUnreadForStatus({ thinkingSinceMs: Date.now() - 1_000 }, "running")).toBe(false);
|
expect(shouldMarkSessionUnreadForStatus({ thinkingSinceMs: Date.now() - 1_000 }, "running")).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("workbench model changes", () => {
|
||||||
|
it("recreates an unused ready session so the selected model takes effect", () => {
|
||||||
|
expect(
|
||||||
|
shouldRecreateSessionForModelChange({
|
||||||
|
status: "ready",
|
||||||
|
sandboxSessionId: "session-1",
|
||||||
|
created: false,
|
||||||
|
transcript: [],
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not recreate a session once the conversation has started", () => {
|
||||||
|
expect(
|
||||||
|
shouldRecreateSessionForModelChange({
|
||||||
|
status: "ready",
|
||||||
|
sandboxSessionId: "session-1",
|
||||||
|
created: true,
|
||||||
|
transcript: [],
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not recreate pending or anonymous sessions", () => {
|
||||||
|
expect(
|
||||||
|
shouldRecreateSessionForModelChange({
|
||||||
|
status: "pending_session_create",
|
||||||
|
sandboxSessionId: "session-1",
|
||||||
|
created: false,
|
||||||
|
transcript: [],
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
shouldRecreateSessionForModelChange({
|
||||||
|
status: "ready",
|
||||||
|
sandboxSessionId: null,
|
||||||
|
created: false,
|
||||||
|
transcript: [],
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,10 @@ interface WorkspaceHandle {
|
||||||
closeWorkbenchSession(input: TaskWorkbenchTabInput): Promise<void>;
|
closeWorkbenchSession(input: TaskWorkbenchTabInput): Promise<void>;
|
||||||
publishWorkbenchPr(input: TaskWorkbenchSelectInput): Promise<void>;
|
publishWorkbenchPr(input: TaskWorkbenchSelectInput): Promise<void>;
|
||||||
revertWorkbenchFile(input: TaskWorkbenchDiffInput): Promise<void>;
|
revertWorkbenchFile(input: TaskWorkbenchDiffInput): Promise<void>;
|
||||||
|
reloadGithubOrganization(): Promise<void>;
|
||||||
|
reloadGithubPullRequests(): Promise<void>;
|
||||||
|
reloadGithubRepository(input: { repoId: string }): Promise<void>;
|
||||||
|
reloadGithubPullRequest(input: { repoId: string; prNumber: number }): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AppWorkspaceHandle {
|
interface AppWorkspaceHandle {
|
||||||
|
|
@ -296,6 +300,10 @@ export interface BackendClient {
|
||||||
closeWorkbenchSession(workspaceId: string, input: TaskWorkbenchTabInput): Promise<void>;
|
closeWorkbenchSession(workspaceId: string, input: TaskWorkbenchTabInput): Promise<void>;
|
||||||
publishWorkbenchPr(workspaceId: string, input: TaskWorkbenchSelectInput): Promise<void>;
|
publishWorkbenchPr(workspaceId: string, input: TaskWorkbenchSelectInput): Promise<void>;
|
||||||
revertWorkbenchFile(workspaceId: string, input: TaskWorkbenchDiffInput): Promise<void>;
|
revertWorkbenchFile(workspaceId: string, input: TaskWorkbenchDiffInput): Promise<void>;
|
||||||
|
reloadGithubOrganization(workspaceId: string): Promise<void>;
|
||||||
|
reloadGithubPullRequests(workspaceId: string): Promise<void>;
|
||||||
|
reloadGithubRepository(workspaceId: string, repoId: string): Promise<void>;
|
||||||
|
reloadGithubPullRequest(workspaceId: string, repoId: string, prNumber: number): Promise<void>;
|
||||||
health(): Promise<{ ok: true }>;
|
health(): Promise<{ ok: true }>;
|
||||||
useWorkspace(workspaceId: string): Promise<{ workspaceId: string }>;
|
useWorkspace(workspaceId: string): Promise<{ workspaceId: string }>;
|
||||||
starSandboxAgentRepo(workspaceId: string): Promise<StarSandboxAgentRepoResult>;
|
starSandboxAgentRepo(workspaceId: string): Promise<StarSandboxAgentRepoResult>;
|
||||||
|
|
@ -1182,6 +1190,22 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
|
||||||
await (await workspace(workspaceId)).revertWorkbenchFile(input);
|
await (await workspace(workspaceId)).revertWorkbenchFile(input);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async reloadGithubOrganization(workspaceId: string): Promise<void> {
|
||||||
|
await (await workspace(workspaceId)).reloadGithubOrganization();
|
||||||
|
},
|
||||||
|
|
||||||
|
async reloadGithubPullRequests(workspaceId: string): Promise<void> {
|
||||||
|
await (await workspace(workspaceId)).reloadGithubPullRequests();
|
||||||
|
},
|
||||||
|
|
||||||
|
async reloadGithubRepository(workspaceId: string, repoId: string): Promise<void> {
|
||||||
|
await (await workspace(workspaceId)).reloadGithubRepository({ repoId });
|
||||||
|
},
|
||||||
|
|
||||||
|
async reloadGithubPullRequest(workspaceId: string, repoId: string, prNumber: number): Promise<void> {
|
||||||
|
await (await workspace(workspaceId)).reloadGithubPullRequest({ repoId, prNumber });
|
||||||
|
},
|
||||||
|
|
||||||
async health(): Promise<{ ok: true }> {
|
async health(): Promise<{ ok: true }> {
|
||||||
const workspaceId = options.defaultWorkspaceId;
|
const workspaceId = options.defaultWorkspaceId;
|
||||||
if (!workspaceId) {
|
if (!workspaceId) {
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,11 @@ function upsertById<T extends { id: string }>(items: T[], nextItem: T, sort: (le
|
||||||
return [...filtered, nextItem].sort(sort);
|
return [...filtered, nextItem].sort(sort);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function upsertByPrId<T extends { prId: string }>(items: T[], nextItem: T, sort: (left: T, right: T) => number): T[] {
|
||||||
|
const filtered = items.filter((item) => item.prId !== nextItem.prId);
|
||||||
|
return [...filtered, nextItem].sort(sort);
|
||||||
|
}
|
||||||
|
|
||||||
export const topicDefinitions = {
|
export const topicDefinitions = {
|
||||||
app: {
|
app: {
|
||||||
key: () => "app",
|
key: () => "app",
|
||||||
|
|
@ -90,6 +95,16 @@ export const topicDefinitions = {
|
||||||
...current,
|
...current,
|
||||||
repos: current.repos.filter((repo) => repo.id !== event.repoId),
|
repos: current.repos.filter((repo) => repo.id !== event.repoId),
|
||||||
};
|
};
|
||||||
|
case "pullRequestUpdated":
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
openPullRequests: upsertByPrId(current.openPullRequests, event.pullRequest, (left, right) => right.updatedAtMs - left.updatedAtMs),
|
||||||
|
};
|
||||||
|
case "pullRequestRemoved":
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
openPullRequests: current.openPullRequests.filter((pullRequest) => pullRequest.prId !== event.prId),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
} satisfies TopicDefinition<WorkspaceSummarySnapshot, WorkspaceTopicParams, WorkspaceEvent>,
|
} satisfies TopicDefinition<WorkspaceSummarySnapshot, WorkspaceTopicParams, WorkspaceEvent>,
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,8 @@ export interface MockFoundryGithubState {
|
||||||
importedRepoCount: number;
|
importedRepoCount: number;
|
||||||
lastSyncLabel: string;
|
lastSyncLabel: string;
|
||||||
lastSyncAt: number | null;
|
lastSyncAt: number | null;
|
||||||
|
lastWebhookAt: number | null;
|
||||||
|
lastWebhookEvent: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MockFoundryOrganizationSettings {
|
export interface MockFoundryOrganizationSettings {
|
||||||
|
|
@ -188,6 +190,8 @@ function buildRivetOrganization(): MockFoundryOrganization {
|
||||||
importedRepoCount: repos.length,
|
importedRepoCount: repos.length,
|
||||||
lastSyncLabel: "Synced just now",
|
lastSyncLabel: "Synced just now",
|
||||||
lastSyncAt: Date.now() - 60_000,
|
lastSyncAt: Date.now() - 60_000,
|
||||||
|
lastWebhookAt: Date.now() - 30_000,
|
||||||
|
lastWebhookEvent: "push",
|
||||||
},
|
},
|
||||||
billing: {
|
billing: {
|
||||||
planId: "team",
|
planId: "team",
|
||||||
|
|
@ -267,6 +271,8 @@ function buildDefaultSnapshot(): MockFoundryAppSnapshot {
|
||||||
importedRepoCount: 1,
|
importedRepoCount: 1,
|
||||||
lastSyncLabel: "Synced just now",
|
lastSyncLabel: "Synced just now",
|
||||||
lastSyncAt: Date.now() - 60_000,
|
lastSyncAt: Date.now() - 60_000,
|
||||||
|
lastWebhookAt: Date.now() - 120_000,
|
||||||
|
lastWebhookEvent: "pull_request.opened",
|
||||||
},
|
},
|
||||||
billing: {
|
billing: {
|
||||||
planId: "free",
|
planId: "free",
|
||||||
|
|
@ -301,6 +307,8 @@ function buildDefaultSnapshot(): MockFoundryAppSnapshot {
|
||||||
importedRepoCount: 3,
|
importedRepoCount: 3,
|
||||||
lastSyncLabel: "Waiting for first import",
|
lastSyncLabel: "Waiting for first import",
|
||||||
lastSyncAt: null,
|
lastSyncAt: null,
|
||||||
|
lastWebhookAt: null,
|
||||||
|
lastWebhookEvent: "",
|
||||||
},
|
},
|
||||||
billing: {
|
billing: {
|
||||||
planId: "team",
|
planId: "team",
|
||||||
|
|
@ -344,6 +352,8 @@ function buildDefaultSnapshot(): MockFoundryAppSnapshot {
|
||||||
importedRepoCount: 1,
|
importedRepoCount: 1,
|
||||||
lastSyncLabel: "Synced yesterday",
|
lastSyncLabel: "Synced yesterday",
|
||||||
lastSyncAt: Date.now() - 24 * 60 * 60_000,
|
lastSyncAt: Date.now() - 24 * 60 * 60_000,
|
||||||
|
lastWebhookAt: Date.now() - 3_600_000,
|
||||||
|
lastWebhookEvent: "check_run.completed",
|
||||||
},
|
},
|
||||||
billing: {
|
billing: {
|
||||||
planId: "free",
|
planId: "free",
|
||||||
|
|
@ -397,6 +407,8 @@ function parseStoredSnapshot(): MockFoundryAppSnapshot | null {
|
||||||
...organization.github,
|
...organization.github,
|
||||||
syncStatus: syncStatusFromLegacy(organization.github?.syncStatus ?? organization.repoImportStatus),
|
syncStatus: syncStatusFromLegacy(organization.github?.syncStatus ?? organization.repoImportStatus),
|
||||||
lastSyncAt: organization.github?.lastSyncAt ?? null,
|
lastSyncAt: organization.github?.lastSyncAt ?? null,
|
||||||
|
lastWebhookAt: organization.github?.lastWebhookAt ?? null,
|
||||||
|
lastWebhookEvent: organization.github?.lastWebhookEvent ?? "",
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
|
@ -567,6 +579,8 @@ class MockFoundryAppStore implements MockFoundryAppClient {
|
||||||
syncStatus: "synced",
|
syncStatus: "synced",
|
||||||
lastSyncLabel: "Synced just now",
|
lastSyncLabel: "Synced just now",
|
||||||
lastSyncAt: Date.now(),
|
lastSyncAt: Date.now(),
|
||||||
|
lastWebhookAt: Date.now(),
|
||||||
|
lastWebhookEvent: "installation_repositories.added",
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
this.importTimers.delete(organizationId);
|
this.importTimers.delete(organizationId);
|
||||||
|
|
|
||||||
|
|
@ -249,6 +249,7 @@ export function createMockBackendClient(defaultWorkspaceId = "default"): Backend
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
taskSummaries,
|
taskSummaries,
|
||||||
|
openPullRequests: [],
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -763,6 +764,14 @@ export function createMockBackendClient(defaultWorkspaceId = "default"): Backend
|
||||||
emitTaskUpdate(input.taskId);
|
emitTaskUpdate(input.taskId);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async reloadGithubOrganization(): Promise<void> {},
|
||||||
|
|
||||||
|
async reloadGithubPullRequests(): Promise<void> {},
|
||||||
|
|
||||||
|
async reloadGithubRepository(): Promise<void> {},
|
||||||
|
|
||||||
|
async reloadGithubPullRequest(): Promise<void> {},
|
||||||
|
|
||||||
async health(): Promise<{ ok: true }> {
|
async health(): Promise<{ ok: true }> {
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -100,7 +100,8 @@ class RemoteWorkbenchStore implements TaskWorkbenchClient {
|
||||||
|
|
||||||
async updateDraft(input: TaskWorkbenchUpdateDraftInput): Promise<void> {
|
async updateDraft(input: TaskWorkbenchUpdateDraftInput): Promise<void> {
|
||||||
await this.backend.updateWorkbenchDraft(this.workspaceId, input);
|
await this.backend.updateWorkbenchDraft(this.workspaceId, input);
|
||||||
await this.refresh();
|
// Skip refresh — the server broadcast will trigger it, and the frontend
|
||||||
|
// holds local draft state to avoid the round-trip overwriting user input.
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendMessage(input: TaskWorkbenchSendMessageInput): Promise<void> {
|
async sendMessage(input: TaskWorkbenchSendMessageInput): Promise<void> {
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@ function workspaceSnapshot(): WorkspaceSummarySnapshot {
|
||||||
sessionsSummary: [],
|
sessionsSummary: [],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
openPullRequests: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -71,10 +71,10 @@ function timeAgo(ts: number | null): string {
|
||||||
if (!ts) return "never";
|
if (!ts) return "never";
|
||||||
const seconds = Math.floor((Date.now() - ts) / 1000);
|
const seconds = Math.floor((Date.now() - ts) / 1000);
|
||||||
if (seconds < 5) return "now";
|
if (seconds < 5) return "now";
|
||||||
if (seconds < 60) return `${seconds}s`;
|
if (seconds < 60) return `${seconds}s ago`;
|
||||||
const minutes = Math.floor(seconds / 60);
|
const minutes = Math.floor(seconds / 60);
|
||||||
if (minutes < 60) return `${minutes}m`;
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
return `${Math.floor(minutes / 60)}h`;
|
return `${Math.floor(minutes / 60)}h ago`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusColor(status: string, t: ReturnType<typeof useFoundryTokens>): string {
|
function statusColor(status: string, t: ReturnType<typeof useFoundryTokens>): string {
|
||||||
|
|
@ -157,8 +157,11 @@ export const DevPanel = memo(function DevPanel({ workspaceId, snapshot, organiza
|
||||||
}, [now]);
|
}, [now]);
|
||||||
|
|
||||||
const repos = snapshot.repos ?? [];
|
const repos = snapshot.repos ?? [];
|
||||||
|
const prCount = (snapshot.tasks ?? []).filter((task) => task.pullRequest != null).length;
|
||||||
const focusedTaskStatus = focusedTask?.runtimeStatus ?? focusedTask?.status ?? null;
|
const focusedTaskStatus = focusedTask?.runtimeStatus ?? focusedTask?.status ?? null;
|
||||||
const focusedTaskState = describeTaskState(focusedTaskStatus, focusedTask?.statusMessage ?? null);
|
const focusedTaskState = describeTaskState(focusedTaskStatus, focusedTask?.statusMessage ?? null);
|
||||||
|
const lastWebhookAt = organization?.github.lastWebhookAt ?? null;
|
||||||
|
const hasRecentWebhook = lastWebhookAt != null && now - lastWebhookAt < 5 * 60_000;
|
||||||
|
|
||||||
const mono = css({
|
const mono = css({
|
||||||
fontFamily: "ui-monospace, SFMono-Regular, 'SF Mono', Consolas, monospace",
|
fontFamily: "ui-monospace, SFMono-Regular, 'SF Mono', Consolas, monospace",
|
||||||
|
|
@ -436,8 +439,28 @@ export const DevPanel = memo(function DevPanel({ workspaceId, snapshot, organiza
|
||||||
<span className={css({ color: t.textPrimary, flex: 1 })}>Sync</span>
|
<span className={css({ color: t.textPrimary, flex: 1 })}>Sync</span>
|
||||||
<span className={`${mono} ${css({ color: syncStatusColor(organization.github.syncStatus, t) })}`}>{organization.github.syncStatus}</span>
|
<span className={`${mono} ${css({ color: syncStatusColor(organization.github.syncStatus, t) })}`}>{organization.github.syncStatus}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className={css({ display: "flex", alignItems: "center", gap: "6px" })}>
|
||||||
|
<span
|
||||||
|
className={css({
|
||||||
|
width: "5px",
|
||||||
|
height: "5px",
|
||||||
|
borderRadius: "50%",
|
||||||
|
backgroundColor: hasRecentWebhook ? t.statusSuccess : t.textMuted,
|
||||||
|
flexShrink: 0,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<span className={css({ color: t.textPrimary, flex: 1 })}>Webhook</span>
|
||||||
|
{lastWebhookAt != null ? (
|
||||||
|
<span className={`${mono} ${css({ color: hasRecentWebhook ? t.textPrimary : t.textMuted })}`}>
|
||||||
|
{organization.github.lastWebhookEvent} · {timeAgo(lastWebhookAt)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className={`${mono} ${css({ color: t.textMuted })}`}>never received</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className={css({ display: "flex", gap: "10px", marginTop: "2px" })}>
|
<div className={css({ display: "flex", gap: "10px", marginTop: "2px" })}>
|
||||||
<Stat label="repos imported" value={organization.github.importedRepoCount} t={t} css={css} />
|
<Stat label="repos" value={organization.github.importedRepoCount} t={t} css={css} />
|
||||||
|
<Stat label="PRs" value={prCount} t={t} css={css} />
|
||||||
</div>
|
</div>
|
||||||
{organization.github.connectedAccount && (
|
{organization.github.connectedAccount && (
|
||||||
<div className={`${mono} ${css({ color: t.textMuted, marginTop: "1px" })}`}>@{organization.github.connectedAccount}</div>
|
<div className={`${mono} ${css({ color: t.textMuted, marginTop: "1px" })}`}>@{organization.github.connectedAccount}</div>
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,16 @@ import { useNavigate } from "@tanstack/react-router";
|
||||||
import { useStyletron } from "baseui";
|
import { useStyletron } from "baseui";
|
||||||
import {
|
import {
|
||||||
createErrorContext,
|
createErrorContext,
|
||||||
|
type FoundryOrganization,
|
||||||
type TaskWorkbenchSnapshot,
|
type TaskWorkbenchSnapshot,
|
||||||
|
type WorkbenchOpenPrSummary,
|
||||||
type WorkbenchSessionSummary,
|
type WorkbenchSessionSummary,
|
||||||
type WorkbenchTaskDetail,
|
type WorkbenchTaskDetail,
|
||||||
type WorkbenchTaskSummary,
|
type WorkbenchTaskSummary,
|
||||||
} from "@sandbox-agent/foundry-shared";
|
} 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 { CircleAlert, PanelLeft, PanelRight } from "lucide-react";
|
||||||
import { useFoundryTokens } from "../app/theme";
|
import { useFoundryTokens } from "../app/theme";
|
||||||
import { logger } from "../logging.js";
|
import { logger } from "../logging.js";
|
||||||
|
|
||||||
|
|
@ -75,6 +77,59 @@ function sanitizeActiveTabId(task: Task, tabId: string | null | undefined, openD
|
||||||
return openDiffs.length > 0 ? diffTabId(openDiffs[openDiffs.length - 1]!) : lastAgentTabId;
|
return openDiffs.length > 0 ? diffTabId(openDiffs[openDiffs.length - 1]!) : lastAgentTabId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function githubInstallationWarningTitle(organization: FoundryOrganization): string {
|
||||||
|
return organization.github.installationStatus === "install_required" ? "GitHub App not installed" : "GitHub App needs reconnection";
|
||||||
|
}
|
||||||
|
|
||||||
|
function githubInstallationWarningDetail(organization: FoundryOrganization): string {
|
||||||
|
const statusDetail = organization.github.lastSyncLabel.trim();
|
||||||
|
const requirementDetail =
|
||||||
|
organization.github.installationStatus === "install_required"
|
||||||
|
? "Webhooks are required for Foundry to function. Repo sync and PR updates will not work until the GitHub App is installed for this workspace."
|
||||||
|
: "Webhook delivery is unavailable. Repo sync and PR updates will not work until the GitHub App is reconnected.";
|
||||||
|
return statusDetail ? `${requirementDetail} ${statusDetail}.` : requirementDetail;
|
||||||
|
}
|
||||||
|
|
||||||
|
function GithubInstallationWarning({
|
||||||
|
organization,
|
||||||
|
css,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
organization: FoundryOrganization;
|
||||||
|
css: ReturnType<typeof useStyletron>[0];
|
||||||
|
t: ReturnType<typeof useFoundryTokens>;
|
||||||
|
}) {
|
||||||
|
if (organization.github.installationStatus === "connected") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
position: "fixed",
|
||||||
|
bottom: "8px",
|
||||||
|
left: "8px",
|
||||||
|
zIndex: 99998,
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
gap: "8px",
|
||||||
|
padding: "10px 12px",
|
||||||
|
backgroundColor: t.surfaceElevated,
|
||||||
|
border: `1px solid ${t.statusError}`,
|
||||||
|
borderRadius: "6px",
|
||||||
|
boxShadow: t.shadow,
|
||||||
|
maxWidth: "440px",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<CircleAlert size={15} color={t.statusError} />
|
||||||
|
<div className={css({ display: "flex", flexDirection: "column", gap: "3px" })}>
|
||||||
|
<div className={css({ fontSize: "11px", fontWeight: 600, color: t.textPrimary })}>{githubInstallationWarningTitle(organization)}</div>
|
||||||
|
<div className={css({ fontSize: "11px", lineHeight: 1.45, color: t.textMuted })}>{githubInstallationWarningDetail(organization)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function toLegacyTab(
|
function toLegacyTab(
|
||||||
summary: WorkbenchSessionSummary,
|
summary: WorkbenchSessionSummary,
|
||||||
sessionDetail?: { draft: Task["tabs"][number]["draft"]; transcript: Task["tabs"][number]["transcript"] },
|
sessionDetail?: { draft: Task["tabs"][number]["draft"]; transcript: Task["tabs"][number]["transcript"] },
|
||||||
|
|
@ -125,6 +180,40 @@ function toLegacyTask(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const OPEN_PR_TASK_PREFIX = "pr:";
|
||||||
|
|
||||||
|
function openPrTaskId(prId: string): string {
|
||||||
|
return `${OPEN_PR_TASK_PREFIX}${prId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOpenPrTaskId(taskId: string): boolean {
|
||||||
|
return taskId.startsWith(OPEN_PR_TASK_PREFIX);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toLegacyOpenPrTask(pullRequest: WorkbenchOpenPrSummary): Task {
|
||||||
|
return {
|
||||||
|
id: openPrTaskId(pullRequest.prId),
|
||||||
|
repoId: pullRequest.repoId,
|
||||||
|
title: pullRequest.title,
|
||||||
|
status: "new",
|
||||||
|
runtimeStatus: undefined,
|
||||||
|
statusMessage: pullRequest.authorLogin ? `@${pullRequest.authorLogin}` : null,
|
||||||
|
repoName: pullRequest.repoFullName,
|
||||||
|
updatedAtMs: pullRequest.updatedAtMs,
|
||||||
|
branch: pullRequest.headRefName,
|
||||||
|
pullRequest: {
|
||||||
|
number: pullRequest.number,
|
||||||
|
status: pullRequest.isDraft ? "draft" : "ready",
|
||||||
|
},
|
||||||
|
tabs: [],
|
||||||
|
fileChanges: [],
|
||||||
|
diffs: {},
|
||||||
|
fileTree: [],
|
||||||
|
minutesUsed: 0,
|
||||||
|
activeSandboxId: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function sessionStateMessage(tab: Task["tabs"][number] | null | undefined): string | null {
|
function sessionStateMessage(tab: Task["tabs"][number] | null | undefined): string | null {
|
||||||
if (!tab) {
|
if (!tab) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -153,7 +242,14 @@ function groupProjects(repos: Array<{ id: string; label: string }>, tasks: Task[
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WorkbenchActions {
|
interface WorkbenchActions {
|
||||||
createTask(input: { repoId: string; task: string; title?: string; branch?: string; model?: ModelId }): Promise<{ taskId: string; tabId?: string }>;
|
createTask(input: {
|
||||||
|
repoId: string;
|
||||||
|
task: string;
|
||||||
|
title?: string;
|
||||||
|
branch?: string;
|
||||||
|
onBranch?: string;
|
||||||
|
model?: ModelId;
|
||||||
|
}): Promise<{ taskId: string; tabId?: string }>;
|
||||||
markTaskUnread(input: { taskId: string }): Promise<void>;
|
markTaskUnread(input: { taskId: string }): Promise<void>;
|
||||||
renameTask(input: { taskId: string; value: string }): Promise<void>;
|
renameTask(input: { taskId: string; value: string }): Promise<void>;
|
||||||
renameBranch(input: { taskId: string; value: string }): Promise<void>;
|
renameBranch(input: { taskId: string; value: string }): Promise<void>;
|
||||||
|
|
@ -168,6 +264,10 @@ interface WorkbenchActions {
|
||||||
closeTab(input: { taskId: string; tabId: string }): Promise<void>;
|
closeTab(input: { taskId: string; tabId: string }): Promise<void>;
|
||||||
addTab(input: { taskId: string; model?: string }): Promise<{ tabId: string }>;
|
addTab(input: { taskId: string; model?: string }): Promise<{ tabId: string }>;
|
||||||
changeModel(input: { taskId: string; tabId: string; model: ModelId }): Promise<void>;
|
changeModel(input: { taskId: string; tabId: string; model: ModelId }): Promise<void>;
|
||||||
|
reloadGithubOrganization(): Promise<void>;
|
||||||
|
reloadGithubPullRequests(): Promise<void>;
|
||||||
|
reloadGithubRepository(repoId: string): Promise<void>;
|
||||||
|
reloadGithubPullRequest(repoId: string, prNumber: number): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TranscriptPanel = memo(function TranscriptPanel({
|
const TranscriptPanel = memo(function TranscriptPanel({
|
||||||
|
|
@ -187,6 +287,7 @@ const TranscriptPanel = memo(function TranscriptPanel({
|
||||||
onSidebarPeekEnd,
|
onSidebarPeekEnd,
|
||||||
rightSidebarCollapsed,
|
rightSidebarCollapsed,
|
||||||
onToggleRightSidebar,
|
onToggleRightSidebar,
|
||||||
|
selectedSessionHydrating = false,
|
||||||
onNavigateToUsage,
|
onNavigateToUsage,
|
||||||
}: {
|
}: {
|
||||||
taskWorkbenchClient: WorkbenchActions;
|
taskWorkbenchClient: WorkbenchActions;
|
||||||
|
|
@ -205,6 +306,7 @@ const TranscriptPanel = memo(function TranscriptPanel({
|
||||||
onSidebarPeekEnd?: () => void;
|
onSidebarPeekEnd?: () => void;
|
||||||
rightSidebarCollapsed?: boolean;
|
rightSidebarCollapsed?: boolean;
|
||||||
onToggleRightSidebar?: () => void;
|
onToggleRightSidebar?: () => void;
|
||||||
|
selectedSessionHydrating?: boolean;
|
||||||
onNavigateToUsage?: () => void;
|
onNavigateToUsage?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const t = useFoundryTokens();
|
const t = useFoundryTokens();
|
||||||
|
|
@ -216,6 +318,11 @@ const TranscriptPanel = memo(function TranscriptPanel({
|
||||||
const [pendingHistoryTarget, setPendingHistoryTarget] = useState<{ messageId: string; tabId: string } | null>(null);
|
const [pendingHistoryTarget, setPendingHistoryTarget] = useState<{ messageId: string; tabId: string } | null>(null);
|
||||||
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null);
|
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null);
|
||||||
const [timerNowMs, setTimerNowMs] = useState(() => Date.now());
|
const [timerNowMs, setTimerNowMs] = useState(() => Date.now());
|
||||||
|
const [localDraft, setLocalDraft] = useState("");
|
||||||
|
const [localAttachments, setLocalAttachments] = useState<LineAttachment[]>([]);
|
||||||
|
const lastEditTimeRef = useRef(0);
|
||||||
|
const throttleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const pendingDraftRef = useRef<{ text: string; attachments: LineAttachment[] } | null>(null);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const messageRefs = useRef(new Map<string, HTMLDivElement>());
|
const messageRefs = useRef(new Map<string, HTMLDivElement>());
|
||||||
|
|
@ -235,8 +342,27 @@ const TranscriptPanel = memo(function TranscriptPanel({
|
||||||
!!activeAgentTab &&
|
!!activeAgentTab &&
|
||||||
(activeAgentTab.status === "pending_provision" || activeAgentTab.status === "pending_session_create" || activeAgentTab.status === "error") &&
|
(activeAgentTab.status === "pending_provision" || activeAgentTab.status === "pending_session_create" || activeAgentTab.status === "error") &&
|
||||||
activeMessages.length === 0;
|
activeMessages.length === 0;
|
||||||
const draft = promptTab?.draft.text ?? "";
|
const serverDraft = promptTab?.draft.text ?? "";
|
||||||
const attachments = promptTab?.draft.attachments ?? [];
|
const serverAttachments = promptTab?.draft.attachments ?? [];
|
||||||
|
|
||||||
|
// Sync server → local only when user hasn't typed recently (3s cooldown)
|
||||||
|
const DRAFT_SYNC_COOLDOWN_MS = 3_000;
|
||||||
|
useEffect(() => {
|
||||||
|
if (Date.now() - lastEditTimeRef.current > DRAFT_SYNC_COOLDOWN_MS) {
|
||||||
|
setLocalDraft(serverDraft);
|
||||||
|
setLocalAttachments(serverAttachments);
|
||||||
|
}
|
||||||
|
}, [serverDraft, serverAttachments]);
|
||||||
|
|
||||||
|
// Reset local draft immediately on tab/task switch
|
||||||
|
useEffect(() => {
|
||||||
|
lastEditTimeRef.current = 0;
|
||||||
|
setLocalDraft(promptTab?.draft.text ?? "");
|
||||||
|
setLocalAttachments(promptTab?.draft.attachments ?? []);
|
||||||
|
}, [promptTab?.id, task.id]);
|
||||||
|
|
||||||
|
const draft = localDraft;
|
||||||
|
const attachments = localAttachments;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (scrollRef.current) {
|
if (scrollRef.current) {
|
||||||
|
|
@ -343,20 +469,53 @@ const TranscriptPanel = memo(function TranscriptPanel({
|
||||||
[editValue, task.id],
|
[editValue, task.id],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const DRAFT_THROTTLE_MS = 500;
|
||||||
|
|
||||||
|
const flushDraft = useCallback(
|
||||||
|
(text: string, nextAttachments: LineAttachment[], tabId: string) => {
|
||||||
|
void taskWorkbenchClient.updateDraft({
|
||||||
|
taskId: task.id,
|
||||||
|
tabId,
|
||||||
|
text,
|
||||||
|
attachments: nextAttachments,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[task.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clean up throttle timer on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (throttleTimerRef.current) {
|
||||||
|
clearTimeout(throttleTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const updateDraft = useCallback(
|
const updateDraft = useCallback(
|
||||||
(nextText: string, nextAttachments: LineAttachment[]) => {
|
(nextText: string, nextAttachments: LineAttachment[]) => {
|
||||||
if (!promptTab) {
|
if (!promptTab) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
void taskWorkbenchClient.updateDraft({
|
// Update local state immediately for responsive typing
|
||||||
taskId: task.id,
|
lastEditTimeRef.current = Date.now();
|
||||||
tabId: promptTab.id,
|
setLocalDraft(nextText);
|
||||||
text: nextText,
|
setLocalAttachments(nextAttachments);
|
||||||
attachments: nextAttachments,
|
|
||||||
});
|
// Throttle the network call
|
||||||
|
pendingDraftRef.current = { text: nextText, attachments: nextAttachments };
|
||||||
|
if (!throttleTimerRef.current) {
|
||||||
|
throttleTimerRef.current = setTimeout(() => {
|
||||||
|
throttleTimerRef.current = null;
|
||||||
|
if (pendingDraftRef.current) {
|
||||||
|
flushDraft(pendingDraftRef.current.text, pendingDraftRef.current.attachments, promptTab.id);
|
||||||
|
pendingDraftRef.current = null;
|
||||||
|
}
|
||||||
|
}, DRAFT_THROTTLE_MS);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[task.id, promptTab],
|
[promptTab, flushDraft],
|
||||||
);
|
);
|
||||||
|
|
||||||
const sendMessage = useCallback(() => {
|
const sendMessage = useCallback(() => {
|
||||||
|
|
@ -687,6 +846,33 @@ const TranscriptPanel = memo(function TranscriptPanel({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ScrollBody>
|
</ScrollBody>
|
||||||
|
) : selectedSessionHydrating ? (
|
||||||
|
<ScrollBody>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
minHeight: "100%",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
padding: "32px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
maxWidth: "420px",
|
||||||
|
textAlign: "center",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "12px",
|
||||||
|
alignItems: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SpinnerDot size={16} />
|
||||||
|
<h2 style={{ margin: 0, fontSize: "20px", fontWeight: 600 }}>Loading session</h2>
|
||||||
|
<p style={{ margin: 0, opacity: 0.75 }}>Fetching the latest transcript for this session.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ScrollBody>
|
||||||
) : showPendingSessionState ? (
|
) : showPendingSessionState ? (
|
||||||
<ScrollBody>
|
<ScrollBody>
|
||||||
<div
|
<div
|
||||||
|
|
@ -1099,12 +1285,25 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
||||||
closeTab: (input) => backendClient.closeWorkbenchSession(workspaceId, input),
|
closeTab: (input) => backendClient.closeWorkbenchSession(workspaceId, input),
|
||||||
addTab: (input) => backendClient.createWorkbenchSession(workspaceId, input),
|
addTab: (input) => backendClient.createWorkbenchSession(workspaceId, input),
|
||||||
changeModel: (input) => backendClient.changeWorkbenchModel(workspaceId, input),
|
changeModel: (input) => backendClient.changeWorkbenchModel(workspaceId, input),
|
||||||
|
reloadGithubOrganization: () => backendClient.reloadGithubOrganization(workspaceId),
|
||||||
|
reloadGithubPullRequests: () => backendClient.reloadGithubPullRequests(workspaceId),
|
||||||
|
reloadGithubRepository: (repoId) => backendClient.reloadGithubRepository(workspaceId, repoId),
|
||||||
|
reloadGithubPullRequest: (repoId, prNumber) => backendClient.reloadGithubPullRequest(workspaceId, repoId, prNumber),
|
||||||
}),
|
}),
|
||||||
[workspaceId],
|
[workspaceId],
|
||||||
);
|
);
|
||||||
const workspaceState = useInterest(interestManager, "workspace", { workspaceId });
|
const workspaceState = useInterest(interestManager, "workspace", { workspaceId });
|
||||||
const workspaceRepos = workspaceState.data?.repos ?? [];
|
const workspaceRepos = workspaceState.data?.repos ?? [];
|
||||||
const taskSummaries = workspaceState.data?.taskSummaries ?? [];
|
const taskSummaries = workspaceState.data?.taskSummaries ?? [];
|
||||||
|
const openPullRequests = workspaceState.data?.openPullRequests ?? [];
|
||||||
|
const openPullRequestsByTaskId = useMemo(
|
||||||
|
() => new Map(openPullRequests.map((pullRequest) => [openPrTaskId(pullRequest.prId), pullRequest])),
|
||||||
|
[openPullRequests],
|
||||||
|
);
|
||||||
|
const selectedOpenPullRequest = useMemo(
|
||||||
|
() => (selectedTaskId ? (openPullRequestsByTaskId.get(selectedTaskId) ?? null) : null),
|
||||||
|
[openPullRequestsByTaskId, selectedTaskId],
|
||||||
|
);
|
||||||
const selectedTaskSummary = useMemo(
|
const selectedTaskSummary = useMemo(
|
||||||
() => taskSummaries.find((task) => task.id === selectedTaskId) ?? taskSummaries[0] ?? null,
|
() => taskSummaries.find((task) => task.id === selectedTaskId) ?? taskSummaries[0] ?? null,
|
||||||
[selectedTaskId, taskSummaries],
|
[selectedTaskId, taskSummaries],
|
||||||
|
|
@ -1169,10 +1368,12 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return taskSummaries.map((summary) =>
|
const legacyTasks = taskSummaries.map((summary) =>
|
||||||
summary.id === selectedTaskSummary?.id ? toLegacyTask(summary, taskState.data, sessionCache) : toLegacyTask(summary),
|
summary.id === selectedTaskSummary?.id ? toLegacyTask(summary, taskState.data, sessionCache) : toLegacyTask(summary),
|
||||||
);
|
);
|
||||||
}, [selectedTaskSummary, selectedSessionId, sessionState.data, taskState.data, taskSummaries, workspaceId]);
|
const legacyOpenPrs = openPullRequests.map((pullRequest) => toLegacyOpenPrTask(pullRequest));
|
||||||
|
return [...legacyTasks, ...legacyOpenPrs].sort((left, right) => right.updatedAtMs - left.updatedAtMs);
|
||||||
|
}, [openPullRequests, selectedTaskSummary, selectedSessionId, sessionState.data, taskState.data, taskSummaries, workspaceId]);
|
||||||
const rawProjects = useMemo(() => groupProjects(workspaceRepos, tasks), [tasks, workspaceRepos]);
|
const rawProjects = useMemo(() => groupProjects(workspaceRepos, tasks), [tasks, workspaceRepos]);
|
||||||
const appSnapshot = useMockAppSnapshot();
|
const appSnapshot = useMockAppSnapshot();
|
||||||
const activeOrg = activeMockOrganization(appSnapshot);
|
const activeOrg = activeMockOrganization(appSnapshot);
|
||||||
|
|
@ -1200,9 +1401,11 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
||||||
const leftWidthRef = useRef(leftWidth);
|
const leftWidthRef = useRef(leftWidth);
|
||||||
const rightWidthRef = useRef(rightWidth);
|
const rightWidthRef = useRef(rightWidth);
|
||||||
const autoCreatingSessionForTaskRef = useRef<Set<string>>(new Set());
|
const autoCreatingSessionForTaskRef = useRef<Set<string>>(new Set());
|
||||||
|
const resolvingOpenPullRequestsRef = useRef<Set<string>>(new Set());
|
||||||
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 [materializingOpenPrId, setMaterializingOpenPrId] = useState<string | null>(null);
|
||||||
const showDevPanel = useDevPanel();
|
const showDevPanel = useDevPanel();
|
||||||
const peekTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const peekTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
|
@ -1268,13 +1471,81 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
||||||
startRightRef.current = rightWidthRef.current;
|
startRightRef.current = rightWidthRef.current;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const activeTask = useMemo(() => tasks.find((task) => task.id === selectedTaskId) ?? tasks[0] ?? null, [tasks, selectedTaskId]);
|
const activeTask = useMemo(() => {
|
||||||
|
const realTasks = tasks.filter((task) => !isOpenPrTaskId(task.id));
|
||||||
|
if (selectedOpenPullRequest) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (selectedTaskId) {
|
||||||
|
return realTasks.find((task) => task.id === selectedTaskId) ?? realTasks[0] ?? null;
|
||||||
|
}
|
||||||
|
return realTasks[0] ?? null;
|
||||||
|
}, [selectedOpenPullRequest, selectedTaskId, tasks]);
|
||||||
|
|
||||||
|
const materializeOpenPullRequest = useCallback(
|
||||||
|
async (pullRequest: WorkbenchOpenPrSummary) => {
|
||||||
|
if (resolvingOpenPullRequestsRef.current.has(pullRequest.prId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolvingOpenPullRequestsRef.current.add(pullRequest.prId);
|
||||||
|
setMaterializingOpenPrId(pullRequest.prId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { taskId, tabId } = await taskWorkbenchClient.createTask({
|
||||||
|
repoId: pullRequest.repoId,
|
||||||
|
task: `Continue work on GitHub PR #${pullRequest.number}: ${pullRequest.title}`,
|
||||||
|
model: "gpt-5.3-codex",
|
||||||
|
title: pullRequest.title,
|
||||||
|
onBranch: pullRequest.headRefName,
|
||||||
|
});
|
||||||
|
await navigate({
|
||||||
|
to: "/workspaces/$workspaceId/tasks/$taskId",
|
||||||
|
params: {
|
||||||
|
workspaceId,
|
||||||
|
taskId,
|
||||||
|
},
|
||||||
|
search: { sessionId: tabId ?? undefined },
|
||||||
|
replace: true,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
setMaterializingOpenPrId((current) => (current === pullRequest.prId ? null : current));
|
||||||
|
resolvingOpenPullRequestsRef.current.delete(pullRequest.prId);
|
||||||
|
logger.error(
|
||||||
|
{
|
||||||
|
prId: pullRequest.prId,
|
||||||
|
repoId: pullRequest.repoId,
|
||||||
|
branchName: pullRequest.headRefName,
|
||||||
|
...createErrorContext(error),
|
||||||
|
},
|
||||||
|
"failed_to_materialize_open_pull_request_task",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[navigate, taskWorkbenchClient, workspaceId],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedOpenPullRequest) {
|
||||||
|
if (materializingOpenPrId) {
|
||||||
|
resolvingOpenPullRequestsRef.current.delete(materializingOpenPrId);
|
||||||
|
}
|
||||||
|
setMaterializingOpenPrId(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void materializeOpenPullRequest(selectedOpenPullRequest);
|
||||||
|
}, [materializeOpenPullRequest, materializingOpenPrId, selectedOpenPullRequest]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeTask) {
|
if (activeTask) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (selectedOpenPullRequest || materializingOpenPrId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const fallbackTaskId = tasks[0]?.id;
|
const fallbackTaskId = tasks[0]?.id;
|
||||||
if (!fallbackTaskId) {
|
if (!fallbackTaskId) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -1291,11 +1562,12 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
||||||
search: { sessionId: fallbackTask?.tabs[0]?.id ?? undefined },
|
search: { sessionId: fallbackTask?.tabs[0]?.id ?? undefined },
|
||||||
replace: true,
|
replace: true,
|
||||||
});
|
});
|
||||||
}, [activeTask, tasks, navigate, workspaceId]);
|
}, [activeTask, materializingOpenPrId, navigate, selectedOpenPullRequest, tasks, workspaceId]);
|
||||||
|
|
||||||
const openDiffs = activeTask ? sanitizeOpenDiffs(activeTask, openDiffsByTask[activeTask.id]) : [];
|
const openDiffs = activeTask ? sanitizeOpenDiffs(activeTask, openDiffsByTask[activeTask.id]) : [];
|
||||||
const lastAgentTabId = activeTask ? sanitizeLastAgentTabId(activeTask, lastAgentTabIdByTask[activeTask.id]) : null;
|
const lastAgentTabId = activeTask ? sanitizeLastAgentTabId(activeTask, lastAgentTabIdByTask[activeTask.id]) : null;
|
||||||
const activeTabId = activeTask ? sanitizeActiveTabId(activeTask, activeTabIdByTask[activeTask.id], openDiffs, lastAgentTabId) : null;
|
const activeTabId = activeTask ? sanitizeActiveTabId(activeTask, activeTabIdByTask[activeTask.id], openDiffs, lastAgentTabId) : null;
|
||||||
|
const selectedSessionHydrating = Boolean(selectedSessionId && activeTabId === selectedSessionId && sessionState.status === "loading" && !sessionState.data);
|
||||||
|
|
||||||
const syncRouteSession = useCallback(
|
const syncRouteSession = useCallback(
|
||||||
(taskId: string, sessionId: string | null, replace = false) => {
|
(taskId: string, sessionId: string | null, replace = false) => {
|
||||||
|
|
@ -1395,7 +1667,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
||||||
}, [activeTask, selectedSessionId, syncRouteSession, taskWorkbenchClient]);
|
}, [activeTask, selectedSessionId, syncRouteSession, taskWorkbenchClient]);
|
||||||
|
|
||||||
const createTask = useCallback(
|
const createTask = useCallback(
|
||||||
(overrideRepoId?: string) => {
|
(overrideRepoId?: string, options?: { title?: string; task?: string; branch?: string; onBranch?: string }) => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const repoId = overrideRepoId || selectedNewTaskRepoId;
|
const repoId = overrideRepoId || selectedNewTaskRepoId;
|
||||||
if (!repoId) {
|
if (!repoId) {
|
||||||
|
|
@ -1404,9 +1676,11 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
||||||
|
|
||||||
const { taskId, tabId } = await taskWorkbenchClient.createTask({
|
const { taskId, tabId } = await taskWorkbenchClient.createTask({
|
||||||
repoId,
|
repoId,
|
||||||
task: "New task",
|
task: options?.task ?? "New task",
|
||||||
model: "gpt-5.3-codex",
|
model: "gpt-5.3-codex",
|
||||||
title: "New task",
|
title: options?.title ?? "New task",
|
||||||
|
...(options?.branch ? { branch: options.branch } : {}),
|
||||||
|
...(options?.onBranch ? { onBranch: options.onBranch } : {}),
|
||||||
});
|
});
|
||||||
await navigate({
|
await navigate({
|
||||||
to: "/workspaces/$workspaceId/tasks/$taskId",
|
to: "/workspaces/$workspaceId/tasks/$taskId",
|
||||||
|
|
@ -1418,7 +1692,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
},
|
},
|
||||||
[navigate, selectedNewTaskRepoId, workspaceId],
|
[navigate, selectedNewTaskRepoId, taskWorkbenchClient, workspaceId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const openDiffTab = useCallback(
|
const openDiffTab = useCallback(
|
||||||
|
|
@ -1447,6 +1721,14 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
||||||
|
|
||||||
const selectTask = useCallback(
|
const selectTask = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
|
if (isOpenPrTaskId(id)) {
|
||||||
|
const pullRequest = openPullRequestsByTaskId.get(id);
|
||||||
|
if (!pullRequest) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void materializeOpenPullRequest(pullRequest);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const task = tasks.find((candidate) => candidate.id === id) ?? null;
|
const task = tasks.find((candidate) => candidate.id === id) ?? null;
|
||||||
void navigate({
|
void navigate({
|
||||||
to: "/workspaces/$workspaceId/tasks/$taskId",
|
to: "/workspaces/$workspaceId/tasks/$taskId",
|
||||||
|
|
@ -1457,7 +1739,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
||||||
search: { sessionId: task?.tabs[0]?.id ?? undefined },
|
search: { sessionId: task?.tabs[0]?.id ?? undefined },
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[tasks, navigate, workspaceId],
|
[materializeOpenPullRequest, navigate, openPullRequestsByTaskId, tasks, workspaceId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const markTaskUnread = useCallback((id: string) => {
|
const markTaskUnread = useCallback((id: string) => {
|
||||||
|
|
@ -1616,6 +1898,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!activeTask) {
|
if (!activeTask) {
|
||||||
|
const isMaterializingSelectedOpenPr = Boolean(selectedOpenPullRequest) || materializingOpenPrId != null;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{dragRegion}
|
{dragRegion}
|
||||||
|
|
@ -1636,7 +1919,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
||||||
projects={projects}
|
projects={projects}
|
||||||
newTaskRepos={workspaceRepos}
|
newTaskRepos={workspaceRepos}
|
||||||
selectedNewTaskRepoId={selectedNewTaskRepoId}
|
selectedNewTaskRepoId={selectedNewTaskRepoId}
|
||||||
activeId=""
|
activeId={selectedTaskId ?? ""}
|
||||||
onSelect={selectTask}
|
onSelect={selectTask}
|
||||||
onCreate={createTask}
|
onCreate={createTask}
|
||||||
onSelectNewTaskRepo={setSelectedNewTaskRepoId}
|
onSelectNewTaskRepo={setSelectedNewTaskRepoId}
|
||||||
|
|
@ -1646,6 +1929,10 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
||||||
onReorderProjects={reorderProjects}
|
onReorderProjects={reorderProjects}
|
||||||
taskOrderByProject={taskOrderByProject}
|
taskOrderByProject={taskOrderByProject}
|
||||||
onReorderTasks={reorderTasks}
|
onReorderTasks={reorderTasks}
|
||||||
|
onReloadOrganization={() => void taskWorkbenchClient.reloadGithubOrganization()}
|
||||||
|
onReloadPullRequests={() => void taskWorkbenchClient.reloadGithubPullRequests()}
|
||||||
|
onReloadRepository={(repoId) => void taskWorkbenchClient.reloadGithubRepository(repoId)}
|
||||||
|
onReloadPullRequest={(repoId, prNumber) => void taskWorkbenchClient.reloadGithubPullRequest(repoId, prNumber)}
|
||||||
onToggleSidebar={() => setLeftSidebarOpen(false)}
|
onToggleSidebar={() => setLeftSidebarOpen(false)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1712,6 +1999,14 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
||||||
{activeOrg.github.importedRepoCount > 0 && <> {activeOrg.github.importedRepoCount} repos imported so far.</>}
|
{activeOrg.github.importedRepoCount > 0 && <> {activeOrg.github.importedRepoCount} repos imported so far.</>}
|
||||||
</p>
|
</p>
|
||||||
</>
|
</>
|
||||||
|
) : isMaterializingSelectedOpenPr && selectedOpenPullRequest ? (
|
||||||
|
<>
|
||||||
|
<SpinnerDot />
|
||||||
|
<h2 style={{ margin: 0, fontSize: "20px", fontWeight: 600 }}>Creating task from pull request</h2>
|
||||||
|
<p style={{ margin: 0, opacity: 0.75 }}>
|
||||||
|
Preparing a task for <strong>{selectedOpenPullRequest.title}</strong> on <strong>{selectedOpenPullRequest.headRefName}</strong>.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
) : activeOrg?.github.syncStatus === "error" ? (
|
) : activeOrg?.github.syncStatus === "error" ? (
|
||||||
<>
|
<>
|
||||||
<h2 style={{ margin: 0, fontSize: "20px", fontWeight: 600, color: t.statusError }}>GitHub sync failed</h2>
|
<h2 style={{ margin: 0, fontSize: "20px", fontWeight: 600, color: t.statusError }}>GitHub sync failed</h2>
|
||||||
|
|
@ -1766,40 +2061,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Shell>
|
</Shell>
|
||||||
{activeOrg && (activeOrg.github.installationStatus === "install_required" || activeOrg.github.installationStatus === "reconnect_required") && (
|
{activeOrg && <GithubInstallationWarning organization={activeOrg} css={css} t={t} />}
|
||||||
<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 && (
|
{showDevPanel && (
|
||||||
<DevPanel
|
<DevPanel
|
||||||
workspaceId={workspaceId}
|
workspaceId={workspaceId}
|
||||||
|
|
@ -1832,7 +2094,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
||||||
projects={projects}
|
projects={projects}
|
||||||
newTaskRepos={workspaceRepos}
|
newTaskRepos={workspaceRepos}
|
||||||
selectedNewTaskRepoId={selectedNewTaskRepoId}
|
selectedNewTaskRepoId={selectedNewTaskRepoId}
|
||||||
activeId={activeTask.id}
|
activeId={selectedTaskId ?? activeTask.id}
|
||||||
onSelect={selectTask}
|
onSelect={selectTask}
|
||||||
onCreate={createTask}
|
onCreate={createTask}
|
||||||
onSelectNewTaskRepo={setSelectedNewTaskRepoId}
|
onSelectNewTaskRepo={setSelectedNewTaskRepoId}
|
||||||
|
|
@ -1842,6 +2104,10 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
||||||
onReorderProjects={reorderProjects}
|
onReorderProjects={reorderProjects}
|
||||||
taskOrderByProject={taskOrderByProject}
|
taskOrderByProject={taskOrderByProject}
|
||||||
onReorderTasks={reorderTasks}
|
onReorderTasks={reorderTasks}
|
||||||
|
onReloadOrganization={() => void taskWorkbenchClient.reloadGithubOrganization()}
|
||||||
|
onReloadPullRequests={() => void taskWorkbenchClient.reloadGithubPullRequests()}
|
||||||
|
onReloadRepository={(repoId) => void taskWorkbenchClient.reloadGithubRepository(repoId)}
|
||||||
|
onReloadPullRequest={(repoId, prNumber) => void taskWorkbenchClient.reloadGithubPullRequest(repoId, prNumber)}
|
||||||
onToggleSidebar={() => setLeftSidebarOpen(false)}
|
onToggleSidebar={() => setLeftSidebarOpen(false)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1880,7 +2146,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
||||||
projects={projects}
|
projects={projects}
|
||||||
newTaskRepos={workspaceRepos}
|
newTaskRepos={workspaceRepos}
|
||||||
selectedNewTaskRepoId={selectedNewTaskRepoId}
|
selectedNewTaskRepoId={selectedNewTaskRepoId}
|
||||||
activeId={activeTask.id}
|
activeId={selectedTaskId ?? activeTask.id}
|
||||||
onSelect={(id) => {
|
onSelect={(id) => {
|
||||||
selectTask(id);
|
selectTask(id);
|
||||||
setLeftSidebarPeeking(false);
|
setLeftSidebarPeeking(false);
|
||||||
|
|
@ -1893,6 +2159,10 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
||||||
onReorderProjects={reorderProjects}
|
onReorderProjects={reorderProjects}
|
||||||
taskOrderByProject={taskOrderByProject}
|
taskOrderByProject={taskOrderByProject}
|
||||||
onReorderTasks={reorderTasks}
|
onReorderTasks={reorderTasks}
|
||||||
|
onReloadOrganization={() => void taskWorkbenchClient.reloadGithubOrganization()}
|
||||||
|
onReloadPullRequests={() => void taskWorkbenchClient.reloadGithubPullRequests()}
|
||||||
|
onReloadRepository={(repoId) => void taskWorkbenchClient.reloadGithubRepository(repoId)}
|
||||||
|
onReloadPullRequest={(repoId, prNumber) => void taskWorkbenchClient.reloadGithubPullRequest(repoId, prNumber)}
|
||||||
onToggleSidebar={() => {
|
onToggleSidebar={() => {
|
||||||
setLeftSidebarPeeking(false);
|
setLeftSidebarPeeking(false);
|
||||||
setLeftSidebarOpen(true);
|
setLeftSidebarOpen(true);
|
||||||
|
|
@ -1930,6 +2200,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
||||||
onSidebarPeekEnd={endPeek}
|
onSidebarPeekEnd={endPeek}
|
||||||
rightSidebarCollapsed={!rightSidebarOpen}
|
rightSidebarCollapsed={!rightSidebarOpen}
|
||||||
onToggleRightSidebar={() => setRightSidebarOpen(true)}
|
onToggleRightSidebar={() => setRightSidebarOpen(true)}
|
||||||
|
selectedSessionHydrating={selectedSessionHydrating}
|
||||||
onNavigateToUsage={navigateToUsage}
|
onNavigateToUsage={navigateToUsage}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1959,40 +2230,7 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{activeOrg && (activeOrg.github.installationStatus === "install_required" || activeOrg.github.installationStatus === "reconnect_required") && (
|
{activeOrg && <GithubInstallationWarning organization={activeOrg} css={css} t={t} />}
|
||||||
<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 && (
|
{showDevPanel && (
|
||||||
<DevPanel
|
<DevPanel
|
||||||
workspaceId={workspaceId}
|
workspaceId={workspaceId}
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,7 @@ export const HistoryMinimap = memo(function HistoryMinimap({ events, onSelect }:
|
||||||
className={css({
|
className={css({
|
||||||
appearance: "none",
|
appearance: "none",
|
||||||
WebkitAppearance: "none",
|
WebkitAppearance: "none",
|
||||||
background: "none",
|
backgroundColor: "transparent",
|
||||||
border: "none",
|
border: "none",
|
||||||
margin: "0",
|
margin: "0",
|
||||||
padding: "6px 8px",
|
padding: "6px 8px",
|
||||||
|
|
@ -110,7 +110,7 @@ export const HistoryMinimap = memo(function HistoryMinimap({ events, onSelect }:
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
textOverflow: "ellipsis",
|
textOverflow: "ellipsis",
|
||||||
whiteSpace: "nowrap",
|
whiteSpace: "nowrap",
|
||||||
transition: "background 160ms ease, color 160ms ease",
|
transition: "background-color 160ms ease, color 160ms ease",
|
||||||
":hover": {
|
":hover": {
|
||||||
backgroundColor: t.interactiveHover,
|
backgroundColor: t.interactiveHover,
|
||||||
color: t.textPrimary,
|
color: t.textPrimary,
|
||||||
|
|
|
||||||
|
|
@ -146,7 +146,6 @@ export const ModelPicker = memo(function ModelPicker({
|
||||||
className={css({
|
className={css({
|
||||||
appearance: "none",
|
appearance: "none",
|
||||||
WebkitAppearance: "none",
|
WebkitAppearance: "none",
|
||||||
background: "none",
|
|
||||||
border: "none",
|
border: "none",
|
||||||
margin: "0",
|
margin: "0",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,9 @@ const FileTree = memo(function FileTree({
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: "4px",
|
gap: "4px",
|
||||||
padding: "3px 10px",
|
paddingTop: "3px",
|
||||||
|
paddingRight: "10px",
|
||||||
|
paddingBottom: "3px",
|
||||||
paddingLeft: `${10 + depth * 16}px`,
|
paddingLeft: `${10 + depth * 16}px`,
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
fontSize: "12px",
|
fontSize: "12px",
|
||||||
|
|
@ -175,7 +177,7 @@ export const RightSidebar = memo(function RightSidebar({
|
||||||
className={css({
|
className={css({
|
||||||
appearance: "none",
|
appearance: "none",
|
||||||
WebkitAppearance: "none",
|
WebkitAppearance: "none",
|
||||||
background: "none",
|
backgroundColor: "transparent",
|
||||||
border: "none",
|
border: "none",
|
||||||
margin: "0",
|
margin: "0",
|
||||||
boxSizing: "border-box",
|
boxSizing: "border-box",
|
||||||
|
|
@ -202,7 +204,7 @@ export const RightSidebar = memo(function RightSidebar({
|
||||||
className={css({
|
className={css({
|
||||||
appearance: "none",
|
appearance: "none",
|
||||||
WebkitAppearance: "none",
|
WebkitAppearance: "none",
|
||||||
background: "none",
|
backgroundColor: "transparent",
|
||||||
border: "none",
|
border: "none",
|
||||||
margin: "0",
|
margin: "0",
|
||||||
boxSizing: "border-box",
|
boxSizing: "border-box",
|
||||||
|
|
@ -230,7 +232,7 @@ export const RightSidebar = memo(function RightSidebar({
|
||||||
className={css({
|
className={css({
|
||||||
appearance: "none",
|
appearance: "none",
|
||||||
WebkitAppearance: "none",
|
WebkitAppearance: "none",
|
||||||
background: "none",
|
backgroundColor: "transparent",
|
||||||
border: "none",
|
border: "none",
|
||||||
margin: "0",
|
margin: "0",
|
||||||
boxSizing: "border-box",
|
boxSizing: "border-box",
|
||||||
|
|
@ -312,17 +314,16 @@ export const RightSidebar = memo(function RightSidebar({
|
||||||
className={css({
|
className={css({
|
||||||
appearance: "none",
|
appearance: "none",
|
||||||
WebkitAppearance: "none",
|
WebkitAppearance: "none",
|
||||||
background: "none",
|
|
||||||
border: "none",
|
border: "none",
|
||||||
margin: "0",
|
marginTop: "6px",
|
||||||
|
marginRight: "0",
|
||||||
|
marginBottom: "6px",
|
||||||
|
marginLeft: "6px",
|
||||||
boxSizing: "border-box",
|
boxSizing: "border-box",
|
||||||
display: "inline-flex",
|
display: "inline-flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: "6px",
|
gap: "6px",
|
||||||
padding: "4px 12px",
|
padding: "4px 12px",
|
||||||
marginTop: "6px",
|
|
||||||
marginBottom: "6px",
|
|
||||||
marginLeft: "6px",
|
|
||||||
borderRadius: "8px",
|
borderRadius: "8px",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
fontSize: "12px",
|
fontSize: "12px",
|
||||||
|
|
@ -363,15 +364,15 @@ export const RightSidebar = memo(function RightSidebar({
|
||||||
className={css({
|
className={css({
|
||||||
appearance: "none",
|
appearance: "none",
|
||||||
WebkitAppearance: "none",
|
WebkitAppearance: "none",
|
||||||
background: "none",
|
|
||||||
border: "none",
|
border: "none",
|
||||||
margin: "0",
|
marginTop: "6px",
|
||||||
|
marginRight: "0",
|
||||||
|
marginBottom: "6px",
|
||||||
|
marginLeft: "0",
|
||||||
boxSizing: "border-box",
|
boxSizing: "border-box",
|
||||||
display: "inline-flex",
|
display: "inline-flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
padding: "4px 12px",
|
padding: "4px 12px",
|
||||||
marginTop: "6px",
|
|
||||||
marginBottom: "6px",
|
|
||||||
borderRadius: "8px",
|
borderRadius: "8px",
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
fontSize: "12px",
|
fontSize: "12px",
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
GitPullRequestDraft,
|
GitPullRequestDraft,
|
||||||
ListChecks,
|
ListChecks,
|
||||||
LogOut,
|
LogOut,
|
||||||
|
MoreHorizontal,
|
||||||
PanelLeft,
|
PanelLeft,
|
||||||
Plus,
|
Plus,
|
||||||
Settings,
|
Settings,
|
||||||
|
|
@ -52,6 +53,10 @@ function projectIconColor(label: string): string {
|
||||||
return PROJECT_COLORS[Math.abs(hash) % PROJECT_COLORS.length]!;
|
return PROJECT_COLORS[Math.abs(hash) % PROJECT_COLORS.length]!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isPullRequestSidebarItem(task: Task): boolean {
|
||||||
|
return task.id.startsWith("pr:");
|
||||||
|
}
|
||||||
|
|
||||||
export const Sidebar = memo(function Sidebar({
|
export const Sidebar = memo(function Sidebar({
|
||||||
projects,
|
projects,
|
||||||
newTaskRepos,
|
newTaskRepos,
|
||||||
|
|
@ -66,6 +71,10 @@ export const Sidebar = memo(function Sidebar({
|
||||||
onReorderProjects,
|
onReorderProjects,
|
||||||
taskOrderByProject,
|
taskOrderByProject,
|
||||||
onReorderTasks,
|
onReorderTasks,
|
||||||
|
onReloadOrganization,
|
||||||
|
onReloadPullRequests,
|
||||||
|
onReloadRepository,
|
||||||
|
onReloadPullRequest,
|
||||||
onToggleSidebar,
|
onToggleSidebar,
|
||||||
}: {
|
}: {
|
||||||
projects: ProjectSection[];
|
projects: ProjectSection[];
|
||||||
|
|
@ -81,6 +90,10 @@ export const Sidebar = memo(function Sidebar({
|
||||||
onReorderProjects: (fromIndex: number, toIndex: number) => void;
|
onReorderProjects: (fromIndex: number, toIndex: number) => void;
|
||||||
taskOrderByProject: Record<string, string[]>;
|
taskOrderByProject: Record<string, string[]>;
|
||||||
onReorderTasks: (projectId: string, fromIndex: number, toIndex: number) => void;
|
onReorderTasks: (projectId: string, fromIndex: number, toIndex: number) => void;
|
||||||
|
onReloadOrganization: () => void;
|
||||||
|
onReloadPullRequests: () => void;
|
||||||
|
onReloadRepository: (repoId: string) => void;
|
||||||
|
onReloadPullRequest: (repoId: string, prNumber: number) => void;
|
||||||
onToggleSidebar?: () => void;
|
onToggleSidebar?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [css] = useStyletron();
|
const [css] = useStyletron();
|
||||||
|
|
@ -88,6 +101,8 @@ export const Sidebar = memo(function Sidebar({
|
||||||
const contextMenu = useContextMenu();
|
const contextMenu = useContextMenu();
|
||||||
const [collapsedProjects, setCollapsedProjects] = useState<Record<string, boolean>>({});
|
const [collapsedProjects, setCollapsedProjects] = useState<Record<string, boolean>>({});
|
||||||
const [hoveredProjectId, setHoveredProjectId] = useState<string | null>(null);
|
const [hoveredProjectId, setHoveredProjectId] = useState<string | null>(null);
|
||||||
|
const [headerMenuOpen, setHeaderMenuOpen] = useState(false);
|
||||||
|
const headerMenuRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Mouse-based drag and drop state
|
// Mouse-based drag and drop state
|
||||||
type DragState =
|
type DragState =
|
||||||
|
|
@ -149,6 +164,20 @@ export const Sidebar = memo(function Sidebar({
|
||||||
};
|
};
|
||||||
}, [drag, onReorderProjects, onReorderTasks]);
|
}, [drag, onReorderProjects, onReorderTasks]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!headerMenuOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const onMouseDown = (event: MouseEvent) => {
|
||||||
|
if (headerMenuRef.current?.contains(event.target as Node)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setHeaderMenuOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", onMouseDown);
|
||||||
|
return () => document.removeEventListener("mousedown", onMouseDown);
|
||||||
|
}, [headerMenuOpen]);
|
||||||
|
|
||||||
const [createSelectOpen, setCreateSelectOpen] = useState(false);
|
const [createSelectOpen, setCreateSelectOpen] = useState(false);
|
||||||
const selectOptions = useMemo(() => newTaskRepos.map((repo) => ({ id: repo.id, label: stripCommonOrgPrefix(repo.label, newTaskRepos) })), [newTaskRepos]);
|
const selectOptions = useMemo(() => newTaskRepos.map((repo) => ({ id: repo.id, label: stripCommonOrgPrefix(repo.label, newTaskRepos) })), [newTaskRepos]);
|
||||||
|
|
||||||
|
|
@ -326,47 +355,111 @@ export const Sidebar = memo(function Sidebar({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div className={css({ display: "flex", alignItems: "center", gap: "6px", position: "relative" })} ref={headerMenuRef}>
|
||||||
role="button"
|
<button
|
||||||
tabIndex={0}
|
type="button"
|
||||||
aria-disabled={newTaskRepos.length === 0}
|
onClick={() => setHeaderMenuOpen((value) => !value)}
|
||||||
onClick={() => {
|
className={css({
|
||||||
if (newTaskRepos.length === 0) return;
|
width: "26px",
|
||||||
if (newTaskRepos.length === 1) {
|
height: "26px",
|
||||||
onSelectNewTaskRepo(newTaskRepos[0]!.id);
|
borderRadius: "8px",
|
||||||
onCreate(newTaskRepos[0]!.id);
|
border: "none",
|
||||||
} else {
|
backgroundColor: t.interactiveHover,
|
||||||
setCreateSelectOpen(true);
|
color: t.textPrimary,
|
||||||
}
|
cursor: "pointer",
|
||||||
}}
|
display: "flex",
|
||||||
onKeyDown={(event) => {
|
alignItems: "center",
|
||||||
if (newTaskRepos.length === 0) return;
|
justifyContent: "center",
|
||||||
if (event.key === "Enter" || event.key === " ") {
|
transition: "background 200ms ease",
|
||||||
|
flexShrink: 0,
|
||||||
|
":hover": { backgroundColor: t.borderMedium },
|
||||||
|
})}
|
||||||
|
title="GitHub actions"
|
||||||
|
>
|
||||||
|
<MoreHorizontal size={14} />
|
||||||
|
</button>
|
||||||
|
{headerMenuOpen ? (
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
position: "absolute",
|
||||||
|
top: "32px",
|
||||||
|
right: 0,
|
||||||
|
minWidth: "180px",
|
||||||
|
padding: "6px",
|
||||||
|
borderRadius: "10px",
|
||||||
|
backgroundColor: t.surfaceElevated,
|
||||||
|
border: `1px solid ${t.borderDefault}`,
|
||||||
|
boxShadow: `${t.shadow}, 0 0 0 1px ${t.interactiveSubtle}`,
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
gap: "4px",
|
||||||
|
zIndex: 20,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setHeaderMenuOpen(false);
|
||||||
|
onReloadOrganization();
|
||||||
|
}}
|
||||||
|
className={css(menuButtonStyle(false, t))}
|
||||||
|
>
|
||||||
|
Reload organization
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setHeaderMenuOpen(false);
|
||||||
|
onReloadPullRequests();
|
||||||
|
}}
|
||||||
|
className={css(menuButtonStyle(false, t))}
|
||||||
|
>
|
||||||
|
Reload all PRs
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-disabled={newTaskRepos.length === 0}
|
||||||
|
onClick={() => {
|
||||||
|
if (newTaskRepos.length === 0) return;
|
||||||
if (newTaskRepos.length === 1) {
|
if (newTaskRepos.length === 1) {
|
||||||
onSelectNewTaskRepo(newTaskRepos[0]!.id);
|
onSelectNewTaskRepo(newTaskRepos[0]!.id);
|
||||||
onCreate(newTaskRepos[0]!.id);
|
onCreate(newTaskRepos[0]!.id);
|
||||||
} else {
|
} else {
|
||||||
setCreateSelectOpen(true);
|
setCreateSelectOpen(true);
|
||||||
}
|
}
|
||||||
}
|
}}
|
||||||
}}
|
onKeyDown={(event) => {
|
||||||
className={css({
|
if (newTaskRepos.length === 0) return;
|
||||||
width: "26px",
|
if (event.key === "Enter" || event.key === " ") {
|
||||||
height: "26px",
|
if (newTaskRepos.length === 1) {
|
||||||
borderRadius: "8px",
|
onSelectNewTaskRepo(newTaskRepos[0]!.id);
|
||||||
backgroundColor: newTaskRepos.length > 0 ? t.borderMedium : t.interactiveHover,
|
onCreate(newTaskRepos[0]!.id);
|
||||||
color: t.textPrimary,
|
} else {
|
||||||
cursor: newTaskRepos.length > 0 ? "pointer" : "not-allowed",
|
setCreateSelectOpen(true);
|
||||||
display: "flex",
|
}
|
||||||
alignItems: "center",
|
}
|
||||||
justifyContent: "center",
|
}}
|
||||||
transition: "background 200ms ease",
|
className={css({
|
||||||
flexShrink: 0,
|
width: "26px",
|
||||||
opacity: newTaskRepos.length > 0 ? 1 : 0.6,
|
height: "26px",
|
||||||
":hover": newTaskRepos.length > 0 ? { backgroundColor: "rgba(255, 255, 255, 0.20)" } : undefined,
|
borderRadius: "8px",
|
||||||
})}
|
backgroundColor: newTaskRepos.length > 0 ? t.borderMedium : t.interactiveHover,
|
||||||
>
|
color: t.textPrimary,
|
||||||
<Plus size={14} style={{ display: "block" }} />
|
cursor: newTaskRepos.length > 0 ? "pointer" : "not-allowed",
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
transition: "background 200ms ease",
|
||||||
|
flexShrink: 0,
|
||||||
|
opacity: newTaskRepos.length > 0 ? 1 : 0.6,
|
||||||
|
":hover": newTaskRepos.length > 0 ? { backgroundColor: "rgba(255, 255, 255, 0.20)" } : undefined,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Plus size={14} style={{ display: "block" }} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</PanelHeaderBar>
|
</PanelHeaderBar>
|
||||||
|
|
@ -431,6 +524,12 @@ export const Sidebar = memo(function Sidebar({
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onContextMenu={(event) =>
|
||||||
|
contextMenu.open(event, [
|
||||||
|
{ label: "Reload repository", onClick: () => onReloadRepository(project.id) },
|
||||||
|
{ label: "New task", onClick: () => onCreate(project.id) },
|
||||||
|
])
|
||||||
|
}
|
||||||
data-project-header
|
data-project-header
|
||||||
className={css({
|
className={css({
|
||||||
display: "flex",
|
display: "flex",
|
||||||
|
|
@ -499,13 +598,13 @@ export const Sidebar = memo(function Sidebar({
|
||||||
height: "26px",
|
height: "26px",
|
||||||
borderRadius: "6px",
|
borderRadius: "6px",
|
||||||
border: "none",
|
border: "none",
|
||||||
background: "none",
|
backgroundColor: "transparent",
|
||||||
padding: 0,
|
padding: 0,
|
||||||
margin: 0,
|
margin: 0,
|
||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
color: t.textTertiary,
|
color: t.textTertiary,
|
||||||
opacity: hoveredProjectId === project.id ? 1 : 0,
|
opacity: hoveredProjectId === project.id ? 1 : 0,
|
||||||
transition: "opacity 150ms ease, background 200ms ease, color 200ms ease",
|
transition: "opacity 150ms ease, background-color 200ms ease, color 200ms ease",
|
||||||
pointerEvents: hoveredProjectId === project.id ? "auto" : "none",
|
pointerEvents: hoveredProjectId === project.id ? "auto" : "none",
|
||||||
":hover": { backgroundColor: t.interactiveHover, color: t.textSecondary },
|
":hover": { backgroundColor: t.interactiveHover, color: t.textSecondary },
|
||||||
})}
|
})}
|
||||||
|
|
@ -519,12 +618,14 @@ export const Sidebar = memo(function Sidebar({
|
||||||
{!isCollapsed &&
|
{!isCollapsed &&
|
||||||
orderedTasks.map((task, taskIndex) => {
|
orderedTasks.map((task, taskIndex) => {
|
||||||
const isActive = task.id === activeId;
|
const isActive = task.id === activeId;
|
||||||
|
const isPullRequestItem = isPullRequestSidebarItem(task);
|
||||||
const isDim = task.status === "archived";
|
const isDim = task.status === "archived";
|
||||||
const isRunning = task.tabs.some((tab) => tab.status === "running");
|
const isRunning = task.tabs.some((tab) => tab.status === "running");
|
||||||
const isProvisioning =
|
const isProvisioning =
|
||||||
String(task.status).startsWith("init_") ||
|
!isPullRequestItem &&
|
||||||
task.status === "new" ||
|
(String(task.status).startsWith("init_") ||
|
||||||
task.tabs.some((tab) => tab.status === "pending_provision" || tab.status === "pending_session_create");
|
task.status === "new" ||
|
||||||
|
task.tabs.some((tab) => tab.status === "pending_provision" || tab.status === "pending_session_create"));
|
||||||
const hasUnread = task.tabs.some((tab) => tab.unread);
|
const hasUnread = task.tabs.some((tab) => tab.unread);
|
||||||
const isDraft = task.pullRequest == null || task.pullRequest.status === "draft";
|
const isDraft = task.pullRequest == null || task.pullRequest.status === "draft";
|
||||||
const totalAdded = task.fileChanges.reduce((sum, file) => sum + file.added, 0);
|
const totalAdded = task.fileChanges.reduce((sum, file) => sum + file.added, 0);
|
||||||
|
|
@ -554,13 +655,20 @@ export const Sidebar = memo(function Sidebar({
|
||||||
onSelect(task.id);
|
onSelect(task.id);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onContextMenu={(event) =>
|
onContextMenu={(event) => {
|
||||||
|
if (isPullRequestItem && task.pullRequest) {
|
||||||
|
contextMenu.open(event, [
|
||||||
|
{ label: "Reload pull request", onClick: () => onReloadPullRequest(task.repoId, task.pullRequest!.number) },
|
||||||
|
{ label: "Create task", onClick: () => onSelect(task.id) },
|
||||||
|
]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
contextMenu.open(event, [
|
contextMenu.open(event, [
|
||||||
{ label: "Rename task", onClick: () => onRenameTask(task.id) },
|
{ label: "Rename task", onClick: () => onRenameTask(task.id) },
|
||||||
{ label: "Rename branch", onClick: () => onRenameBranch(task.id) },
|
{ label: "Rename branch", onClick: () => onRenameBranch(task.id) },
|
||||||
{ label: "Mark as unread", onClick: () => onMarkUnread(task.id) },
|
{ label: "Mark as unread", onClick: () => onMarkUnread(task.id) },
|
||||||
])
|
]);
|
||||||
}
|
}}
|
||||||
className={css({
|
className={css({
|
||||||
padding: "8px 12px",
|
padding: "8px 12px",
|
||||||
borderRadius: "8px",
|
borderRadius: "8px",
|
||||||
|
|
@ -596,21 +704,32 @@ export const Sidebar = memo(function Sidebar({
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<TaskIndicator isRunning={isRunning} isProvisioning={isProvisioning} hasUnread={hasUnread} isDraft={isDraft} />
|
{isPullRequestItem ? (
|
||||||
|
<GitPullRequestDraft size={13} color={isDraft ? t.accent : t.textSecondary} />
|
||||||
|
) : (
|
||||||
|
<TaskIndicator isRunning={isRunning} isProvisioning={isProvisioning} hasUnread={hasUnread} isDraft={isDraft} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={css({ minWidth: 0, flex: 1, display: "flex", flexDirection: "column", gap: "1px" })}>
|
||||||
|
<LabelSmall
|
||||||
|
$style={{
|
||||||
|
fontWeight: hasUnread ? 600 : 400,
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
minWidth: 0,
|
||||||
|
flexShrink: 1,
|
||||||
|
}}
|
||||||
|
color={hasUnread ? t.textPrimary : t.textSecondary}
|
||||||
|
>
|
||||||
|
{task.title}
|
||||||
|
</LabelSmall>
|
||||||
|
{isPullRequestItem && task.statusMessage ? (
|
||||||
|
<LabelXSmall color={t.textTertiary} $style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||||
|
{task.statusMessage}
|
||||||
|
</LabelXSmall>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<LabelSmall
|
|
||||||
$style={{
|
|
||||||
fontWeight: hasUnread ? 600 : 400,
|
|
||||||
overflow: "hidden",
|
|
||||||
textOverflow: "ellipsis",
|
|
||||||
whiteSpace: "nowrap",
|
|
||||||
minWidth: 0,
|
|
||||||
flexShrink: 1,
|
|
||||||
}}
|
|
||||||
color={hasUnread ? t.textPrimary : t.textSecondary}
|
|
||||||
>
|
|
||||||
{task.title}
|
|
||||||
</LabelSmall>
|
|
||||||
{task.pullRequest != null ? (
|
{task.pullRequest != null ? (
|
||||||
<span className={css({ display: "inline-flex", alignItems: "center", gap: "4px", flexShrink: 0 })}>
|
<span className={css({ display: "inline-flex", alignItems: "center", gap: "4px", flexShrink: 0 })}>
|
||||||
<LabelXSmall color={t.textSecondary} $style={{ fontWeight: 600 }}>
|
<LabelXSmall color={t.textSecondary} $style={{ fontWeight: 600 }}>
|
||||||
|
|
|
||||||
|
|
@ -543,7 +543,10 @@ export function TerminalPane({ workspaceId, taskId, isExpanded, onExpand, onColl
|
||||||
gap: "6px",
|
gap: "6px",
|
||||||
minHeight: "39px",
|
minHeight: "39px",
|
||||||
maxHeight: "39px",
|
maxHeight: "39px",
|
||||||
padding: "0 14px",
|
paddingTop: "0",
|
||||||
|
paddingRight: "14px",
|
||||||
|
paddingBottom: "0",
|
||||||
|
paddingLeft: "14px",
|
||||||
borderTop: `1px solid ${t.borderDefault}`,
|
borderTop: `1px solid ${t.borderDefault}`,
|
||||||
backgroundColor: t.surfacePrimary,
|
backgroundColor: t.surfacePrimary,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
|
|
|
||||||
|
|
@ -134,7 +134,6 @@ export const TranscriptHeader = memo(function TranscriptHeader({
|
||||||
className={css({
|
className={css({
|
||||||
appearance: "none",
|
appearance: "none",
|
||||||
WebkitAppearance: "none",
|
WebkitAppearance: "none",
|
||||||
background: "none",
|
|
||||||
margin: "0",
|
margin: "0",
|
||||||
outline: "none",
|
outline: "none",
|
||||||
padding: "2px 8px",
|
padding: "2px 8px",
|
||||||
|
|
|
||||||
|
|
@ -299,7 +299,10 @@ export const PanelHeaderBar = styled("div", ({ $theme }) => {
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
minHeight: HEADER_HEIGHT,
|
minHeight: HEADER_HEIGHT,
|
||||||
maxHeight: HEADER_HEIGHT,
|
maxHeight: HEADER_HEIGHT,
|
||||||
padding: "0 14px",
|
paddingTop: "0",
|
||||||
|
paddingRight: "14px",
|
||||||
|
paddingBottom: "0",
|
||||||
|
paddingLeft: "14px",
|
||||||
borderBottom: `1px solid ${t.borderDefault}`,
|
borderBottom: `1px solid ${t.borderDefault}`,
|
||||||
backgroundColor: t.surfaceTertiary,
|
backgroundColor: t.surfaceTertiary,
|
||||||
gap: "8px",
|
gap: "8px",
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,8 @@ export interface FoundryGithubState {
|
||||||
importedRepoCount: number;
|
importedRepoCount: number;
|
||||||
lastSyncLabel: string;
|
lastSyncLabel: string;
|
||||||
lastSyncAt: number | null;
|
lastSyncAt: number | null;
|
||||||
|
lastWebhookAt: number | null;
|
||||||
|
lastWebhookEvent: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FoundryOrganizationSettings {
|
export interface FoundryOrganizationSettings {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { FoundryAppSnapshot } from "./app-shell.js";
|
import type { FoundryAppSnapshot } from "./app-shell.js";
|
||||||
import type { WorkbenchRepoSummary, WorkbenchSessionDetail, WorkbenchTaskDetail, WorkbenchTaskSummary } from "./workbench.js";
|
import type { WorkbenchOpenPrSummary, WorkbenchRepoSummary, WorkbenchSessionDetail, WorkbenchTaskDetail, WorkbenchTaskSummary } from "./workbench.js";
|
||||||
|
|
||||||
export interface SandboxProcessSnapshot {
|
export interface SandboxProcessSnapshot {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -21,7 +21,9 @@ export type WorkspaceEvent =
|
||||||
| { type: "taskRemoved"; taskId: string }
|
| { type: "taskRemoved"; taskId: string }
|
||||||
| { type: "repoAdded"; repo: WorkbenchRepoSummary }
|
| { type: "repoAdded"; repo: WorkbenchRepoSummary }
|
||||||
| { type: "repoUpdated"; repo: WorkbenchRepoSummary }
|
| { type: "repoUpdated"; repo: WorkbenchRepoSummary }
|
||||||
| { type: "repoRemoved"; repoId: string };
|
| { type: "repoRemoved"; repoId: string }
|
||||||
|
| { type: "pullRequestUpdated"; pullRequest: WorkbenchOpenPrSummary }
|
||||||
|
| { type: "pullRequestRemoved"; prId: string };
|
||||||
|
|
||||||
/** Task-level events broadcast by the task actor. */
|
/** Task-level events broadcast by the task actor. */
|
||||||
export type TaskEvent = { type: "taskDetailUpdated"; detail: WorkbenchTaskDetail };
|
export type TaskEvent = { type: "taskDetailUpdated"; detail: WorkbenchTaskDetail };
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,21 @@ export interface WorkbenchPullRequestSummary {
|
||||||
status: "draft" | "ready";
|
status: "draft" | "ready";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WorkbenchOpenPrSummary {
|
||||||
|
prId: string;
|
||||||
|
repoId: string;
|
||||||
|
repoFullName: string;
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
state: string;
|
||||||
|
url: string;
|
||||||
|
headRefName: string;
|
||||||
|
baseRefName: string;
|
||||||
|
authorLogin: string | null;
|
||||||
|
isDraft: boolean;
|
||||||
|
updatedAtMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface WorkbenchSandboxSummary {
|
export interface WorkbenchSandboxSummary {
|
||||||
providerId: ProviderId;
|
providerId: ProviderId;
|
||||||
sandboxId: string;
|
sandboxId: string;
|
||||||
|
|
@ -161,6 +176,7 @@ export interface WorkspaceSummarySnapshot {
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
repos: WorkbenchRepoSummary[];
|
repos: WorkbenchRepoSummary[];
|
||||||
taskSummaries: WorkbenchTaskSummary[];
|
taskSummaries: WorkbenchTaskSummary[];
|
||||||
|
openPullRequests: WorkbenchOpenPrSummary[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -229,6 +245,7 @@ export interface TaskWorkbenchCreateTaskInput {
|
||||||
task: string;
|
task: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
branch?: string;
|
branch?: string;
|
||||||
|
onBranch?: string;
|
||||||
model?: WorkbenchModelId;
|
model?: WorkbenchModelId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
169
foundry/research/specs/github-data-actor.md
Normal file
169
foundry/research/specs/github-data-actor.md
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
# Spec: GitHub Data Actor & Webhook-Driven State
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Replace the per-repo polling PR sync actor (`ProjectPrSyncActor`) and per-repo PR cache (`prCache` table) with a single organization-scoped `github-state` actor that owns all GitHub data (repos, PRs, members). All GitHub state updates flow exclusively through webhooks, with a one-shot full sync on initial connection. Manual reload actions are exposed per-entity (org, repo, PR) for recovery from missed webhooks.
|
||||||
|
|
||||||
|
Open PRs are surfaced in the left sidebar alongside tasks via a unified workspace interest topic, with lazy task/sandbox creation when a user clicks on a PR.
|
||||||
|
|
||||||
|
## Reference Implementation
|
||||||
|
|
||||||
|
A prior implementation of the `github-state` actor exists in git checkpoint `0aca2c7` (from PR #247 "Refactor Foundry GitHub state and sandbox runtime"). This was never merged to a branch but contains working code for:
|
||||||
|
|
||||||
|
- `foundry/packages/backend/src/actors/github-state/index.ts` — full actor with DB, sync workflow, webhook handler, PR CRUD
|
||||||
|
- `foundry/packages/backend/src/actors/github-state/db/schema.ts` — `github_meta`, `github_repositories`, `github_members`, `github_pull_requests` tables
|
||||||
|
- `foundry/packages/backend/src/actors/organization/app-shell.ts` lines 1056-1180 — webhook dispatch to `githubState.handlePullRequestWebhook()` and `githubState.fullSync()`
|
||||||
|
|
||||||
|
Use `git show 0aca2c7:<path>` to read the reference files. Adapt (don't copy blindly) — the current branch structure has diverged.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
1. **No polling.** Delete `ProjectPrSyncActor` (`actors/project-pr-sync/`), all references to it in handles/keys/index, and the `prCache` table in `ProjectActor`'s DB schema. Remove `prSyncStatus`/`prSyncAt` from `getRepoOverview`.
|
||||||
|
2. **Keep `ProjectBranchSyncActor`.** This polls the local git clone (not GitHub API) and is the sandbox git status mechanism. It stays.
|
||||||
|
3. **Webhooks are the sole live update path.** The only GitHub API calls happen during:
|
||||||
|
- Initial full sync on org connection/installation
|
||||||
|
- Manual reload actions (per-entity)
|
||||||
|
4. **GitHub does not auto-retry failed webhook deliveries** ([docs](https://docs.github.com/en/webhooks/using-webhooks/handling-failed-webhook-deliveries)). Manual reload is the recovery mechanism.
|
||||||
|
5. **No `user-github-data` actor in this spec.** OAuth/auth is already handled correctly on the current branch. Only the org-scoped `github-state` actor is in scope.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Actor: `github-state` (one per organization)
|
||||||
|
|
||||||
|
**Key:** `["org", organizationId, "github"]`
|
||||||
|
|
||||||
|
**DB tables:**
|
||||||
|
- `github_meta` — sync status, installation info, connected account
|
||||||
|
- `github_repositories` — repos accessible via the GitHub App installation
|
||||||
|
- `github_pull_requests` — all open PRs across all repos in the org
|
||||||
|
- `github_members` — org members (existing from checkpoint, keep for completeness)
|
||||||
|
|
||||||
|
**Actions (from checkpoint, to adapt):**
|
||||||
|
- `fullSync(input)` — one-shot fetch of repos + PRs via installation token. Enqueues as a workflow step. Used on initial connection and `installation.created`/`unsuspend` webhooks.
|
||||||
|
- `handlePullRequestWebhook(input)` — upserts a single PR from webhook payload, notifies downstream.
|
||||||
|
- `getSummary()` — returns sync meta + row counts.
|
||||||
|
- `listRepositories()` — returns all known repos.
|
||||||
|
- `listPullRequestsForRepository({ repoId })` — returns PRs for a repo.
|
||||||
|
- `getPullRequestForBranch({ repoId, branchName })` — returns PR info for a branch.
|
||||||
|
- `createPullRequest({ repoId, repoPath, branchName, title, body })` — creates PR via GitHub API, stores locally.
|
||||||
|
- `clearState(input)` — wipes all data (on `installation.deleted`, `suspend`).
|
||||||
|
|
||||||
|
**New actions (not in checkpoint):**
|
||||||
|
- `reloadOrganization()` — re-fetches repos + members from GitHub API (not PRs). Updates `github_repositories` and `github_members`. Notifies downstream.
|
||||||
|
- `reloadRepository({ repoId })` — re-fetches metadata for a single repo from GitHub API. Updates the `github_repositories` row. Does NOT re-fetch PRs.
|
||||||
|
- `reloadPullRequest({ repoId, prNumber })` — re-fetches a single PR from GitHub API by number. Updates the `github_pull_requests` row. Notifies downstream.
|
||||||
|
|
||||||
|
### Webhook Dispatch (in app-shell)
|
||||||
|
|
||||||
|
Replace the current TODO at `app-shell.ts:1521` with dispatch logic adapted from checkpoint `0aca2c7:foundry/packages/backend/src/actors/organization/app-shell.ts` lines 1056-1180:
|
||||||
|
|
||||||
|
| Webhook event | Action |
|
||||||
|
|---|---|
|
||||||
|
| `installation.created` | `githubState.fullSync({ force: true })` |
|
||||||
|
| `installation.deleted` | `githubState.clearState(...)` |
|
||||||
|
| `installation.suspend` | `githubState.clearState(...)` |
|
||||||
|
| `installation.unsuspend` | `githubState.fullSync({ force: true })` |
|
||||||
|
| `installation_repositories` | `githubState.fullSync({ force: true })` |
|
||||||
|
| `pull_request` (any action) | `githubState.handlePullRequestWebhook(...)` |
|
||||||
|
| `push`, `create`, `delete`, `check_run`, `check_suite`, `status`, `pull_request_review`, `pull_request_review_comment` | Log for now, extend later |
|
||||||
|
|
||||||
|
### Downstream Notifications
|
||||||
|
|
||||||
|
When `github-state` receives a PR update (webhook or manual reload), it should:
|
||||||
|
|
||||||
|
1. Update its own `github_pull_requests` table
|
||||||
|
2. Call `notifyOrganizationUpdated()` → which broadcasts `workspaceUpdated` to connected clients
|
||||||
|
3. If the PR branch matches an existing task's branch, update that task's `pullRequest` summary in the workspace actor
|
||||||
|
|
||||||
|
### Workspace Summary Changes
|
||||||
|
|
||||||
|
Extend `WorkspaceSummarySnapshot` to include open PRs:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export interface WorkspaceSummarySnapshot {
|
||||||
|
workspaceId: string;
|
||||||
|
repos: WorkbenchRepoSummary[];
|
||||||
|
taskSummaries: WorkbenchTaskSummary[];
|
||||||
|
openPullRequests: WorkbenchOpenPrSummary[]; // NEW
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkbenchOpenPrSummary {
|
||||||
|
prId: string; // "repoId#number"
|
||||||
|
repoId: string;
|
||||||
|
repoFullName: string;
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
state: string;
|
||||||
|
url: string;
|
||||||
|
headRefName: string;
|
||||||
|
baseRefName: string;
|
||||||
|
authorLogin: string | null;
|
||||||
|
isDraft: boolean;
|
||||||
|
updatedAtMs: number;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The workspace actor fetches open PRs from the `github-state` actor when building the summary snapshot. PRs that already have an associated task (matched by branch name) should be excluded from `openPullRequests` (they already appear in `taskSummaries` with their `pullRequest` field populated).
|
||||||
|
|
||||||
|
### Interest Manager
|
||||||
|
|
||||||
|
The `workspace` interest topic already returns `WorkspaceSummarySnapshot`. Adding `openPullRequests` to that type means the sidebar automatically gets PR data without a new topic.
|
||||||
|
|
||||||
|
`workspaceUpdated` events should include a new variant for PR changes:
|
||||||
|
```typescript
|
||||||
|
{ type: "pullRequestUpdated", pullRequest: WorkbenchOpenPrSummary }
|
||||||
|
{ type: "pullRequestRemoved", prId: string }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sidebar Changes
|
||||||
|
|
||||||
|
The left sidebar currently renders `projects: ProjectSection[]` where each project has `tasks: Task[]`. Extend this to include open PRs as lightweight entries within each project section:
|
||||||
|
|
||||||
|
- Open PRs appear in the same list as tasks, sorted by `updatedAtMs`
|
||||||
|
- PRs should be visually distinct: show PR icon instead of task indicator, display `#number` and author
|
||||||
|
- Clicking a PR creates a task lazily (creates the task + sandbox on demand), then navigates to it
|
||||||
|
- PRs that already have a task are filtered out (they show as the task instead)
|
||||||
|
|
||||||
|
This is similar to what `buildPrTasks()` does in the mock data (`workbench-model.ts:1154-1182`), but driven by real data from the `github-state` actor.
|
||||||
|
|
||||||
|
### Frontend: Manual Reload
|
||||||
|
|
||||||
|
Add a "three dots" menu button in the top-right of the sidebar header. Dropdown options:
|
||||||
|
|
||||||
|
- **Reload organization** — calls `githubState.reloadOrganization()` via backend API
|
||||||
|
- **Reload all PRs** — calls `githubState.fullSync({ force: true })` (convenience shortcut)
|
||||||
|
|
||||||
|
For per-repo and per-PR reload, add context menu options:
|
||||||
|
- Right-click a project header → "Reload repository"
|
||||||
|
- Right-click a PR entry → "Reload pull request"
|
||||||
|
|
||||||
|
These call the corresponding `reloadRepository`/`reloadPullRequest` actions on the `github-state` actor.
|
||||||
|
|
||||||
|
## Deletions
|
||||||
|
|
||||||
|
Files/code to remove:
|
||||||
|
|
||||||
|
1. `foundry/packages/backend/src/actors/project-pr-sync/` — entire directory
|
||||||
|
2. `foundry/packages/backend/src/actors/project/db/schema.ts` — `prCache` table
|
||||||
|
3. `foundry/packages/backend/src/actors/project/actions.ts` — `applyPrSyncResultMutation`, `getPullRequestForBranch` (moves to github-state), `prSyncStatus`/`prSyncAt` from `getRepoOverview`
|
||||||
|
4. `foundry/packages/backend/src/actors/handles.ts` — `getOrCreateProjectPrSync`, `selfProjectPrSync`
|
||||||
|
5. `foundry/packages/backend/src/actors/keys.ts` — any PR sync key helper
|
||||||
|
6. `foundry/packages/backend/src/actors/index.ts` — `projectPrSync` import and registration
|
||||||
|
7. All call sites in `ProjectActor` that spawn or call the PR sync actor (`initProject`, `refreshProject`)
|
||||||
|
|
||||||
|
## Migration Path
|
||||||
|
|
||||||
|
The `prCache` table in `ProjectActor`'s DB can simply be dropped — no data migration needed since the `github-state` actor will re-fetch everything on its first `fullSync`. Existing task `pullRequest` fields are populated from the github-state actor going forward.
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
|
||||||
|
1. Create `github-state` actor (adapt from checkpoint `0aca2c7`)
|
||||||
|
2. Wire up actor in registry, handles, keys
|
||||||
|
3. Implement webhook dispatch in app-shell (replace TODO)
|
||||||
|
4. Delete `ProjectPrSyncActor` and `prCache` from project actor
|
||||||
|
5. Add manual reload actions to github-state
|
||||||
|
6. Extend `WorkspaceSummarySnapshot` with `openPullRequests`
|
||||||
|
7. Wire through interest manager + workspace events
|
||||||
|
8. Update sidebar to render open PRs
|
||||||
|
9. Add three-dots menu with reload options
|
||||||
|
10. Update task creation flow for lazy PR→task conversion
|
||||||
Loading…
Add table
Add a link
Reference in a new issue