mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 05:02:11 +00:00
feat(foundry): add foundry base sandbox image with sudo, chromium, and dev tooling
Add a custom Docker image (foundry-base.Dockerfile) that builds sandbox-agent from source and layers sudo, git, neovim, gh, node, bun, chromium, and agent-browser. Includes publish script for timestamped + latest tags to rivetdev/sandbox-agent on Docker Hub. Update local sandbox provider default to use foundry-base-latest and wire HF_LOCAL_SANDBOX_IMAGE env var through compose.dev.yaml. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
eafe0f9fe4
commit
3895e34bdb
36 changed files with 800 additions and 1126 deletions
|
|
@ -59,6 +59,19 @@ Use `pnpm` workspaces and Turborepo.
|
|||
- The dev server has debug logging enabled by default (`RIVET_LOG_LEVEL=debug`, `FOUNDRY_LOG_LEVEL=debug`) via `compose.dev.yaml`. Error stacks and timestamps are also enabled.
|
||||
- The frontend client uses JSON encoding for RivetKit in development (`import.meta.env.DEV`) for easier debugging. Production uses the default encoding.
|
||||
|
||||
## Foundry Base Sandbox Image
|
||||
|
||||
Local Docker sandboxes use the `rivetdev/sandbox-agent:foundry-base-latest` image by default. This image extends the sandbox-agent runtime with sudo, git, neovim, gh, node, bun, chromium, and agent-browser.
|
||||
|
||||
- **Dockerfile:** `docker/foundry-base.Dockerfile` (builds sandbox-agent from source, x86_64 only)
|
||||
- **Publish script:** `scripts/publish-foundry-base.sh` (builds and pushes to Docker Hub `rivetdev/sandbox-agent`)
|
||||
- **Tags:** `foundry-base-<YYYYMMDD>T<HHMMSS>Z` (timestamped) + `foundry-base-latest` (rolling)
|
||||
- **Build from repo root:** `./foundry/scripts/publish-foundry-base.sh` (or `--dry-run` to skip push)
|
||||
- **Override image in dev:** set `HF_LOCAL_SANDBOX_IMAGE` in `foundry/.env` or environment. The env var is passed through `compose.dev.yaml` to the backend.
|
||||
- **Resolution order:** `config.sandboxProviders.local.image` (config.toml) > `HF_LOCAL_SANDBOX_IMAGE` (env var) > `DEFAULT_LOCAL_SANDBOX_IMAGE` constant in `packages/backend/src/actors/sandbox/index.ts`.
|
||||
- The image must be built with `--platform linux/amd64`. The Rust build is memory-intensive; Docker Desktop needs at least 8GB RAM allocated.
|
||||
- When updating the base image contents (new system packages, agent versions), rebuild and push with the publish script, then update the `foundry-base-latest` tag.
|
||||
|
||||
## Railway Logs
|
||||
|
||||
- Production Foundry Railway logs can be read from a linked checkout with `railway logs --deployment --lines 200` or `railway logs <deployment-id> --deployment --lines 200`.
|
||||
|
|
@ -199,16 +212,6 @@ For all Rivet/RivetKit implementation:
|
|||
- Example: the `task` actor instance already represents `(organizationId, repoId, taskId)`, so its SQLite tables should not need those columns for primary keys.
|
||||
3. Do not use backend-global SQLite singletons; database access must go through actor `db` providers (`c.db`).
|
||||
4. The default dependency source for RivetKit is the published `rivetkit` package so monorepo installs and CI remain self-contained.
|
||||
5. When working on coordinated RivetKit changes, you may temporarily relink to a local checkout instead of the published package.
|
||||
- Dedicated local checkout for this repo: `/Users/nathan/conductor/workspaces/task/rivet-checkout`
|
||||
- Preferred local link target: `../rivet-checkout/rivetkit-typescript/packages/rivetkit`
|
||||
- Sub-packages (`@rivetkit/sqlite-vfs`, etc.) resolve transitively from the RivetKit monorepo when using the local checkout.
|
||||
6. Before using a local checkout, build RivetKit in the rivet repo:
|
||||
```bash
|
||||
cd ../rivet-checkout/rivetkit-typescript
|
||||
pnpm install
|
||||
pnpm build -F rivetkit
|
||||
```
|
||||
|
||||
## Rivet Routing
|
||||
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ services:
|
|||
STRIPE_WEBHOOK_SECRET: "${STRIPE_WEBHOOK_SECRET:-}"
|
||||
STRIPE_PRICE_TEAM: "${STRIPE_PRICE_TEAM:-}"
|
||||
FOUNDRY_SANDBOX_PROVIDER: "${FOUNDRY_SANDBOX_PROVIDER:-local}"
|
||||
HF_LOCAL_SANDBOX_IMAGE: "${HF_LOCAL_SANDBOX_IMAGE:-rivetdev/sandbox-agent:foundry-base-latest}"
|
||||
E2B_API_KEY: "${E2B_API_KEY:-}"
|
||||
E2B_TEMPLATE: "${E2B_TEMPLATE:-}"
|
||||
HF_E2B_TEMPLATE: "${HF_E2B_TEMPLATE:-${E2B_TEMPLATE:-}}"
|
||||
|
|
@ -56,8 +57,6 @@ services:
|
|||
- "7741:7741"
|
||||
volumes:
|
||||
- "..:/app"
|
||||
# The linked RivetKit checkout resolves from Foundry packages to /task/rivet-checkout in-container.
|
||||
- "../../../task/rivet-checkout:/task/rivet-checkout:ro"
|
||||
# Reuse the host Codex auth profile for local sandbox-agent Codex sessions in dev.
|
||||
- "${HOME}/.codex:/root/.codex"
|
||||
- "/var/run/docker.sock:/var/run/docker.sock"
|
||||
|
|
@ -86,7 +85,6 @@ services:
|
|||
- "..:/app"
|
||||
# Ensure logs in .foundry/ persist on the host even if we change source mounts later.
|
||||
- "./.foundry:/app/foundry/.foundry"
|
||||
- "../../../task/rivet-checkout:/task/rivet-checkout:ro"
|
||||
# Use Linux-native repo dependencies inside the container instead of host node_modules.
|
||||
- "foundry_node_modules:/app/node_modules"
|
||||
- "foundry_client_node_modules:/app/foundry/packages/client/node_modules"
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ services:
|
|||
volumes:
|
||||
- "..:/app"
|
||||
- "./.foundry:/app/foundry/.foundry"
|
||||
- "../../../task/rivet-checkout:/task/rivet-checkout:ro"
|
||||
- "mock_node_modules:/app/node_modules"
|
||||
- "mock_client_node_modules:/app/foundry/packages/client/node_modules"
|
||||
- "mock_frontend_node_modules:/app/foundry/packages/frontend/node_modules"
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ ENV SANDBOX_AGENT_BIN="/root/.local/bin/sandbox-agent"
|
|||
WORKDIR /workspace/quebec
|
||||
|
||||
COPY quebec /workspace/quebec
|
||||
COPY rivet-checkout /workspace/rivet-checkout
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
RUN pnpm --filter @sandbox-agent/foundry-shared build
|
||||
|
|
|
|||
190
foundry/docker/foundry-base.Dockerfile
Normal file
190
foundry/docker/foundry-base.Dockerfile
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
# syntax=docker/dockerfile:1.10.0
|
||||
#
|
||||
# Foundry base sandbox image.
|
||||
#
|
||||
# Builds sandbox-agent from source (reusing the upstream Dockerfile.full build
|
||||
# stages) and layers Foundry-specific tooling on top: sudo, git, neovim, gh,
|
||||
# node, bun, chromium, and agent-browser.
|
||||
#
|
||||
# Build:
|
||||
# docker build --platform linux/amd64 \
|
||||
# -f foundry/docker/foundry-base.Dockerfile \
|
||||
# -t rivetdev/sandbox-agent:foundry-base-<timestamp> .
|
||||
#
|
||||
# Must be invoked from the repository root so the COPY . picks up the full
|
||||
# source tree for the Rust + inspector build stages.
|
||||
|
||||
# ============================================================================
|
||||
# Build inspector frontend
|
||||
# ============================================================================
|
||||
FROM --platform=linux/amd64 node:22-alpine AS inspector-build
|
||||
WORKDIR /app
|
||||
RUN npm install -g pnpm
|
||||
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY frontend/packages/inspector/package.json ./frontend/packages/inspector/
|
||||
COPY sdks/cli-shared/package.json ./sdks/cli-shared/
|
||||
COPY sdks/acp-http-client/package.json ./sdks/acp-http-client/
|
||||
COPY sdks/react/package.json ./sdks/react/
|
||||
COPY sdks/typescript/package.json ./sdks/typescript/
|
||||
|
||||
RUN pnpm install --filter @sandbox-agent/inspector...
|
||||
|
||||
COPY docs/openapi.json ./docs/
|
||||
COPY sdks/cli-shared ./sdks/cli-shared
|
||||
COPY sdks/acp-http-client ./sdks/acp-http-client
|
||||
COPY sdks/react ./sdks/react
|
||||
COPY sdks/typescript ./sdks/typescript
|
||||
|
||||
RUN cd sdks/cli-shared && pnpm exec tsup
|
||||
RUN cd sdks/acp-http-client && pnpm exec tsup
|
||||
RUN cd sdks/typescript && SKIP_OPENAPI_GEN=1 pnpm exec tsup
|
||||
RUN cd sdks/react && pnpm exec tsup
|
||||
|
||||
COPY frontend/packages/inspector ./frontend/packages/inspector
|
||||
RUN cd frontend/packages/inspector && pnpm exec vite build
|
||||
|
||||
# ============================================================================
|
||||
# AMD64 Builder - sandbox-agent static binary
|
||||
# ============================================================================
|
||||
FROM --platform=linux/amd64 rust:1.88.0 AS builder
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
musl-tools \
|
||||
musl-dev \
|
||||
llvm-14-dev \
|
||||
libclang-14-dev \
|
||||
clang-14 \
|
||||
libssl-dev \
|
||||
pkg-config \
|
||||
ca-certificates \
|
||||
g++ \
|
||||
g++-multilib \
|
||||
git \
|
||||
curl \
|
||||
wget && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN wget -q https://github.com/cross-tools/musl-cross/releases/latest/download/x86_64-unknown-linux-musl.tar.xz && \
|
||||
tar -xf x86_64-unknown-linux-musl.tar.xz -C /opt/ && \
|
||||
rm x86_64-unknown-linux-musl.tar.xz && \
|
||||
rustup target add x86_64-unknown-linux-musl
|
||||
|
||||
ENV PATH="/opt/x86_64-unknown-linux-musl/bin:$PATH" \
|
||||
LIBCLANG_PATH=/usr/lib/llvm-14/lib \
|
||||
CLANG_PATH=/usr/bin/clang-14 \
|
||||
CC_x86_64_unknown_linux_musl=x86_64-unknown-linux-musl-gcc \
|
||||
CXX_x86_64_unknown_linux_musl=x86_64-unknown-linux-musl-g++ \
|
||||
AR_x86_64_unknown_linux_musl=x86_64-unknown-linux-musl-ar \
|
||||
CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_LINKER=x86_64-unknown-linux-musl-gcc \
|
||||
CARGO_INCREMENTAL=0 \
|
||||
CARGO_NET_GIT_FETCH_WITH_CLI=true
|
||||
|
||||
ENV SSL_VER=1.1.1w
|
||||
RUN wget https://www.openssl.org/source/openssl-$SSL_VER.tar.gz && \
|
||||
tar -xzf openssl-$SSL_VER.tar.gz && \
|
||||
cd openssl-$SSL_VER && \
|
||||
./Configure no-shared no-async --prefix=/musl --openssldir=/musl/ssl linux-x86_64 && \
|
||||
make -j$(nproc) && \
|
||||
make install_sw && \
|
||||
cd .. && \
|
||||
rm -rf openssl-$SSL_VER*
|
||||
|
||||
ENV OPENSSL_DIR=/musl \
|
||||
OPENSSL_INCLUDE_DIR=/musl/include \
|
||||
OPENSSL_LIB_DIR=/musl/lib \
|
||||
PKG_CONFIG_ALLOW_CROSS=1 \
|
||||
RUSTFLAGS="-C target-feature=+crt-static -C link-arg=-static-libgcc"
|
||||
|
||||
WORKDIR /build
|
||||
COPY . .
|
||||
|
||||
COPY --from=inspector-build /app/frontend/packages/inspector/dist ./frontend/packages/inspector/dist
|
||||
|
||||
RUN --mount=type=cache,target=/usr/local/cargo/registry \
|
||||
--mount=type=cache,target=/usr/local/cargo/git \
|
||||
--mount=type=cache,target=/build/target \
|
||||
cargo build -p sandbox-agent --release --target x86_64-unknown-linux-musl -j4 && \
|
||||
cp target/x86_64-unknown-linux-musl/release/sandbox-agent /sandbox-agent
|
||||
|
||||
# ============================================================================
|
||||
# Runtime - Foundry base sandbox image
|
||||
# ============================================================================
|
||||
FROM --platform=linux/amd64 node:22-bookworm-slim
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# --- System packages --------------------------------------------------------
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
bash \
|
||||
ca-certificates \
|
||||
curl \
|
||||
git \
|
||||
gnupg \
|
||||
neovim \
|
||||
sudo \
|
||||
unzip \
|
||||
wget \
|
||||
# Chromium and its runtime deps
|
||||
chromium \
|
||||
fonts-liberation \
|
||||
libasound2 \
|
||||
libatk-bridge2.0-0 \
|
||||
libatk1.0-0 \
|
||||
libcups2 \
|
||||
libdbus-1-3 \
|
||||
libdrm2 \
|
||||
libgbm1 \
|
||||
libgtk-3-0 \
|
||||
libnspr4 \
|
||||
libnss3 \
|
||||
libx11-xcb1 \
|
||||
libxcomposite1 \
|
||||
libxdamage1 \
|
||||
libxrandr2 \
|
||||
xdg-utils \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# --- GitHub CLI (gh) -------------------------------------------------------
|
||||
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
|
||||
| dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
|
||||
&& chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg \
|
||||
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \
|
||||
> /etc/apt/sources.list.d/github-cli.list \
|
||||
&& apt-get update && apt-get install -y gh \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# --- Bun --------------------------------------------------------------------
|
||||
RUN curl -fsSL https://bun.sh/install | bash \
|
||||
&& mv /root/.bun/bin/bun /usr/local/bin/bun \
|
||||
&& ln -sf /usr/local/bin/bun /usr/local/bin/bunx \
|
||||
&& rm -rf /root/.bun
|
||||
|
||||
# --- sandbox-agent binary (from local build) --------------------------------
|
||||
COPY --from=builder /sandbox-agent /usr/local/bin/sandbox-agent
|
||||
RUN chmod +x /usr/local/bin/sandbox-agent
|
||||
|
||||
# --- sandbox user with passwordless sudo ------------------------------------
|
||||
RUN useradd -m -s /bin/bash sandbox \
|
||||
&& echo "sandbox ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/sandbox \
|
||||
&& chmod 0440 /etc/sudoers.d/sandbox
|
||||
|
||||
USER sandbox
|
||||
WORKDIR /home/sandbox
|
||||
|
||||
# Point Chromium/Playwright at the system binary
|
||||
ENV CHROME_PATH=/usr/bin/chromium
|
||||
ENV CHROMIUM_PATH=/usr/bin/chromium
|
||||
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
|
||||
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
|
||||
|
||||
# --- Install all sandbox-agent agents + agent-browser -----------------------
|
||||
RUN sandbox-agent install-agent --all
|
||||
RUN sudo npm install -g agent-browser
|
||||
|
||||
EXPOSE 2468
|
||||
|
||||
ENTRYPOINT ["sandbox-agent"]
|
||||
CMD ["server", "--host", "0.0.0.0", "--port", "2468"]
|
||||
|
|
@ -7,7 +7,6 @@ RUN npm install -g pnpm@10.28.2
|
|||
WORKDIR /workspace/quebec
|
||||
|
||||
COPY quebec /workspace/quebec
|
||||
COPY rivet-checkout /workspace/rivet-checkout
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
RUN pnpm --filter @sandbox-agent/foundry-shared build
|
||||
|
|
|
|||
|
|
@ -49,6 +49,14 @@ OrganizationActor (coordinator for tasks + auth users)
|
|||
|
||||
When adding a new index table, annotate it in the schema file with a doc comment identifying it as a coordinator index and which child actor it indexes (see existing examples).
|
||||
|
||||
## GitHub Sync Data Model
|
||||
|
||||
The GithubDataActor syncs **repositories** and **pull requests** from GitHub, not branches. We only need repos (to know which repos exist and their metadata) and PRs (to lazily populate virtual tasks in the sidebar). Branch data is not synced because we only create tasks from PRs or fresh user-initiated creation, never from bare branches. Generated branch names for new tasks are treated as unique enough to skip conflict detection against remote branches.
|
||||
|
||||
Tasks are either:
|
||||
1. **Created fresh** by the user (no PR yet, branch name generated from task description)
|
||||
2. **Lazily populated from pull requests** during PR sync (virtual task entries in org tables, no actor spawned)
|
||||
|
||||
## Lazy Task Actor Creation — CRITICAL
|
||||
|
||||
**Task actors must NEVER be created during GitHub sync or bulk operations.** Creating hundreds of task actors simultaneously causes OOM crashes. An org can have 200+ PRs; spawning an actor per PR kills the process.
|
||||
|
|
@ -86,6 +94,46 @@ When the user interacts with a virtual task (clicks it, creates a session):
|
|||
- `refreshTaskSummaryForBranchMutation` — called in bulk during sync. Must ONLY write to org local tables. Never create task actors or call task actor actions.
|
||||
- `emitPullRequestChangeEvents` in github-data — iterates all changed PRs. Must remain fire-and-forget with no actor fan-out.
|
||||
|
||||
## Queue vs Action Decision Framework
|
||||
|
||||
The default is a direct action. Use a queue only if the answer to one or more of these questions is **yes**.
|
||||
|
||||
Actions are pure RPCs with no DB overhead on send — fast, but if the call fails the operation is lost. Queues persist the message to the database on send, guaranteeing it will be processed even if the target actor is busy, slow, or recovering. The tradeoff: queues add write overhead and serialize processing.
|
||||
|
||||
### 1. Does this operation coordinate multi-step work?
|
||||
|
||||
Does it involve external I/O (sandbox API, GitHub API, agent process management) or state machine transitions where interleaving would corrupt state? This is different from database-level serialization — a simple read-then-write on SQLite can use a transaction. The queue is for ordering operations that span DB writes + external I/O.
|
||||
|
||||
**Queue examples:**
|
||||
- `workspace.send_message` — sends to sandbox agent, writes session status, does owner-swap. Multi-step with external I/O.
|
||||
- `push` / `sync` / `merge` — git operations in sandbox that must not interleave.
|
||||
- `createTask` — read-then-write across task index + actor creation. Returns result, so `wait: true`.
|
||||
|
||||
**Action examples:**
|
||||
- `billing.stripe_customer.apply` — single column upsert, no external I/O.
|
||||
- `workspace.update_draft` — writes draft text, no coordination with sandbox ops.
|
||||
- `workspace.rename_task` — updates title column, queue handlers don't touch title.
|
||||
|
||||
### 2. Must this message be processed no matter what?
|
||||
|
||||
Is this a cross-actor fire-and-forget where the caller won't retry and data loss is unacceptable? A queue persists the message — if the target is down, it waits. An action RPC that fails is gone.
|
||||
|
||||
**Queue examples:**
|
||||
- `audit.append` — caller must never be affected by audit failures, and audit entries must not be lost.
|
||||
- `applyTaskSummaryUpdate` — task actor pushes summary to org and moves on. Won't retry if org is busy.
|
||||
- `refreshTaskSummaryForBranch` — webhook-driven, won't be redelivered for the same event.
|
||||
|
||||
**Action examples:**
|
||||
- `billing.invoice.upsert` — Stripe retries handle failures externally. No durability need on our side.
|
||||
- `workspace.mark_unread` — UI convenience state. Acceptable to lose on transient failure.
|
||||
- `github.webhook_receipt.record` — timestamp columns with no downstream effects.
|
||||
|
||||
### Once on a queue: wait or fire-and-forget?
|
||||
|
||||
If the caller needs a return value, use `wait: true`. If the UI updates via push events, use `wait: false`.
|
||||
|
||||
Full migration plan: `QUEUE_TO_ACTION_MIGRATION.md`.
|
||||
|
||||
## Ownership Rules
|
||||
|
||||
- `OrganizationActor` is the organization coordinator, direct coordinator for tasks, and lookup/index owner. It owns the task index, task summaries, and repo catalog.
|
||||
|
|
|
|||
|
|
@ -24,6 +24,12 @@ const journal = {
|
|||
tag: "0003_sync_progress",
|
||||
breakpoints: true,
|
||||
},
|
||||
{
|
||||
idx: 4,
|
||||
when: 1773993600000,
|
||||
tag: "0004_drop_github_branches",
|
||||
breakpoints: true,
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
|
||||
|
|
@ -101,6 +107,8 @@ ALTER TABLE \`github_members\` ADD \`sync_generation\` integer NOT NULL DEFAULT
|
|||
ALTER TABLE \`github_pull_requests\` ADD \`sync_generation\` integer NOT NULL DEFAULT 0;
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE \`github_branches\` ADD \`sync_generation\` integer NOT NULL DEFAULT 0;
|
||||
`,
|
||||
m0004: `DROP TABLE IF EXISTS \`github_branches\`;
|
||||
`,
|
||||
} as const,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -30,15 +30,6 @@ export const githubRepositories = sqliteTable("github_repositories", {
|
|||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
export const githubBranches = sqliteTable("github_branches", {
|
||||
branchId: text("branch_id").notNull().primaryKey(),
|
||||
repoId: text("repo_id").notNull(),
|
||||
branchName: text("branch_name").notNull(),
|
||||
commitSha: text("commit_sha").notNull(),
|
||||
syncGeneration: integer("sync_generation").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
||||
export const githubMembers = sqliteTable("github_members", {
|
||||
memberId: text("member_id").notNull().primaryKey(),
|
||||
login: text("login").notNull(),
|
||||
|
|
|
|||
|
|
@ -11,12 +11,12 @@ import { repoIdFromRemote } from "../../services/repo.js";
|
|||
import { resolveOrganizationGithubAuth } from "../../services/github-auth.js";
|
||||
import { organizationWorkflowQueueName } from "../organization/queues.js";
|
||||
import { githubDataDb } from "./db/db.js";
|
||||
import { githubBranches, githubMembers, githubMeta, githubPullRequests, githubRepositories } from "./db/schema.js";
|
||||
import { githubMembers, githubMeta, githubPullRequests, githubRepositories } from "./db/schema.js";
|
||||
|
||||
const META_ROW_ID = 1;
|
||||
const SYNC_REPOSITORY_BATCH_SIZE = 10;
|
||||
|
||||
type GithubSyncPhase = "discovering_repositories" | "syncing_repositories" | "syncing_branches" | "syncing_members" | "syncing_pull_requests";
|
||||
type GithubSyncPhase = "discovering_repositories" | "syncing_repositories" | "syncing_members" | "syncing_pull_requests";
|
||||
|
||||
interface GithubDataInput {
|
||||
organizationId: string;
|
||||
|
|
@ -38,12 +38,6 @@ interface GithubRepositoryRecord {
|
|||
defaultBranch: string;
|
||||
}
|
||||
|
||||
interface GithubBranchRecord {
|
||||
repoId: string;
|
||||
branchName: string;
|
||||
commitSha: string;
|
||||
}
|
||||
|
||||
interface GithubPullRequestRecord {
|
||||
repoId: string;
|
||||
repoFullName: string;
|
||||
|
|
@ -81,7 +75,6 @@ export const GITHUB_DATA_QUEUE_NAMES = [
|
|||
"githubData.command.syncRepos",
|
||||
"githubData.command.handlePullRequestWebhook",
|
||||
"githubData.command.clearState",
|
||||
"githubData.command.reloadRepository",
|
||||
] as const;
|
||||
|
||||
type GithubDataQueueName = (typeof GITHUB_DATA_QUEUE_NAMES)[number];
|
||||
|
|
@ -308,42 +301,6 @@ async function sweepRepositories(c: any, syncGeneration: number) {
|
|||
}
|
||||
}
|
||||
|
||||
async function upsertBranches(c: any, branches: GithubBranchRecord[], updatedAt: number, syncGeneration: number) {
|
||||
for (const branch of branches) {
|
||||
await c.db
|
||||
.insert(githubBranches)
|
||||
.values({
|
||||
branchId: `${branch.repoId}:${branch.branchName}`,
|
||||
repoId: branch.repoId,
|
||||
branchName: branch.branchName,
|
||||
commitSha: branch.commitSha,
|
||||
syncGeneration,
|
||||
updatedAt,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: githubBranches.branchId,
|
||||
set: {
|
||||
repoId: branch.repoId,
|
||||
branchName: branch.branchName,
|
||||
commitSha: branch.commitSha,
|
||||
syncGeneration,
|
||||
updatedAt,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
}
|
||||
}
|
||||
|
||||
async function sweepBranches(c: any, syncGeneration: number) {
|
||||
const rows = await c.db.select({ branchId: githubBranches.branchId, syncGeneration: githubBranches.syncGeneration }).from(githubBranches).all();
|
||||
for (const row of rows) {
|
||||
if (row.syncGeneration === syncGeneration) {
|
||||
continue;
|
||||
}
|
||||
await c.db.delete(githubBranches).where(eq(githubBranches.branchId, row.branchId)).run();
|
||||
}
|
||||
}
|
||||
|
||||
async function upsertMembers(c: any, members: GithubMemberRecord[], updatedAt: number, syncGeneration: number) {
|
||||
for (const member of members) {
|
||||
await c.db
|
||||
|
|
@ -602,63 +559,6 @@ async function listPullRequestsForRepositories(
|
|||
}));
|
||||
}
|
||||
|
||||
async function listRepositoryBranchesForContext(
|
||||
context: Awaited<ReturnType<typeof getOrganizationContext>>,
|
||||
repository: GithubRepositoryRecord,
|
||||
): Promise<GithubBranchRecord[]> {
|
||||
const { appShell } = getActorRuntimeContext();
|
||||
let branches: Array<{ name: string; commitSha: string }> = [];
|
||||
|
||||
if (context.installationId != null) {
|
||||
try {
|
||||
branches = await appShell.github.listInstallationRepositoryBranches(context.installationId, repository.fullName);
|
||||
} catch (error) {
|
||||
if (!context.accessToken) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (branches.length === 0 && context.accessToken) {
|
||||
branches = await appShell.github.listUserRepositoryBranches(context.accessToken, repository.fullName);
|
||||
}
|
||||
|
||||
const repoId = repoIdFromRemote(repository.cloneUrl);
|
||||
return branches.map((branch) => ({
|
||||
repoId,
|
||||
branchName: branch.name,
|
||||
commitSha: branch.commitSha,
|
||||
}));
|
||||
}
|
||||
|
||||
async function refreshRepositoryBranches(
|
||||
c: any,
|
||||
context: Awaited<ReturnType<typeof getOrganizationContext>>,
|
||||
repository: GithubRepositoryRecord,
|
||||
updatedAt: number,
|
||||
): Promise<void> {
|
||||
const currentMeta = await readMeta(c);
|
||||
const nextBranches = await listRepositoryBranchesForContext(context, repository);
|
||||
await c.db
|
||||
.delete(githubBranches)
|
||||
.where(eq(githubBranches.repoId, repoIdFromRemote(repository.cloneUrl)))
|
||||
.run();
|
||||
|
||||
for (const branch of nextBranches) {
|
||||
await c.db
|
||||
.insert(githubBranches)
|
||||
.values({
|
||||
branchId: `${branch.repoId}:${branch.branchName}`,
|
||||
repoId: branch.repoId,
|
||||
branchName: branch.branchName,
|
||||
commitSha: branch.commitSha,
|
||||
syncGeneration: currentMeta.syncGeneration,
|
||||
updatedAt,
|
||||
})
|
||||
.run();
|
||||
}
|
||||
}
|
||||
|
||||
async function readAllPullRequestRows(c: any) {
|
||||
return await c.db.select().from(githubPullRequests).all();
|
||||
}
|
||||
|
|
@ -736,41 +636,7 @@ export async function fullSyncSetup(c: any, input: FullSyncInput = {}): Promise<
|
|||
}
|
||||
|
||||
/**
|
||||
* Phase 2 (per-batch): Fetch and upsert branches for one batch of repos.
|
||||
* Returns true when all batches have been processed.
|
||||
*/
|
||||
export async function fullSyncBranchBatch(c: any, config: FullSyncConfig, batchIndex: number): Promise<boolean> {
|
||||
const repos = await readRepositoriesFromDb(c);
|
||||
const batches = chunkItems(repos, SYNC_REPOSITORY_BATCH_SIZE);
|
||||
if (batchIndex >= batches.length) return true;
|
||||
|
||||
const batch = batches[batchIndex]!;
|
||||
const context = await getOrganizationContext(c, {
|
||||
connectedAccount: config.connectedAccount,
|
||||
installationStatus: config.installationStatus as any,
|
||||
installationId: config.installationId,
|
||||
});
|
||||
const batchBranches = (await Promise.all(batch.map((repo) => listRepositoryBranchesForContext(context, repo)))).flat();
|
||||
await upsertBranches(c, batchBranches, config.startedAt, config.syncGeneration);
|
||||
|
||||
const processedCount = Math.min((batchIndex + 1) * SYNC_REPOSITORY_BATCH_SIZE, repos.length);
|
||||
await publishSyncProgress(c, {
|
||||
connectedAccount: config.connectedAccount,
|
||||
installationStatus: config.installationStatus,
|
||||
installationId: config.installationId,
|
||||
syncStatus: "syncing",
|
||||
lastSyncLabel: `Synced branches for ${processedCount} of ${repos.length} repositories`,
|
||||
syncGeneration: config.syncGeneration,
|
||||
syncPhase: "syncing_branches",
|
||||
processedRepositoryCount: processedCount,
|
||||
totalRepositoryCount: repos.length,
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase 3: Resolve, upsert, and sweep members.
|
||||
* Phase 2: Resolve, upsert, and sweep members.
|
||||
*/
|
||||
export async function fullSyncMembers(c: any, config: FullSyncConfig): Promise<void> {
|
||||
await publishSyncProgress(c, {
|
||||
|
|
@ -796,7 +662,7 @@ export async function fullSyncMembers(c: any, config: FullSyncConfig): Promise<v
|
|||
}
|
||||
|
||||
/**
|
||||
* Phase 4 (per-batch): Fetch and upsert pull requests for one batch of repos.
|
||||
* Phase 3 (per-batch): Fetch and upsert pull requests for one batch of repos.
|
||||
* Returns true when all batches have been processed.
|
||||
*/
|
||||
export async function fullSyncPullRequestBatch(c: any, config: FullSyncConfig, batchIndex: number): Promise<boolean> {
|
||||
|
|
@ -830,10 +696,9 @@ export async function fullSyncPullRequestBatch(c: any, config: FullSyncConfig, b
|
|||
}
|
||||
|
||||
/**
|
||||
* Phase 5: Sweep stale data, publish final state, emit PR change events.
|
||||
* Phase 4: Sweep stale data, publish final state, emit PR change events.
|
||||
*/
|
||||
export async function fullSyncFinalize(c: any, config: FullSyncConfig): Promise<void> {
|
||||
await sweepBranches(c, config.syncGeneration);
|
||||
await sweepPullRequests(c, config.syncGeneration);
|
||||
await sweepRepositories(c, config.syncGeneration);
|
||||
|
||||
|
|
@ -866,12 +731,6 @@ export async function fullSyncFinalize(c: any, config: FullSyncConfig): Promise<
|
|||
export async function runFullSync(c: any, input: FullSyncInput = {}): Promise<void> {
|
||||
const config = await fullSyncSetup(c, input);
|
||||
|
||||
// Branches — native loop over batches
|
||||
for (let i = 0; ; i++) {
|
||||
const done = await fullSyncBranchBatch(c, config, i);
|
||||
if (done) break;
|
||||
}
|
||||
|
||||
// Members
|
||||
await fullSyncMembers(c, config);
|
||||
|
||||
|
|
@ -929,7 +788,6 @@ const GITHUB_DATA_COMMAND_HANDLERS: Record<GithubDataQueueName, GithubDataWorkfl
|
|||
await clearStateMutation(c, body);
|
||||
return { ok: true };
|
||||
},
|
||||
"githubData.command.reloadRepository": async (c, body) => reloadRepositoryMutation(c, body),
|
||||
};
|
||||
|
||||
async function runGithubDataWorkflow(ctx: any): Promise<void> {
|
||||
|
|
@ -985,13 +843,11 @@ export const githubData = actor({
|
|||
actions: {
|
||||
async getSummary(c) {
|
||||
const repositories = await c.db.select().from(githubRepositories).all();
|
||||
const branches = await c.db.select().from(githubBranches).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,
|
||||
branchCount: branches.length,
|
||||
memberCount: members.length,
|
||||
pullRequestCount: pullRequests.length,
|
||||
};
|
||||
|
|
@ -1030,88 +886,14 @@ export const githubData = actor({
|
|||
.all();
|
||||
return rows.map((row) => pullRequestSummaryFromRow(row));
|
||||
},
|
||||
|
||||
async listBranchesForRepository(c, input: { repoId: string }) {
|
||||
const rows = await c.db.select().from(githubBranches).where(eq(githubBranches.repoId, input.repoId)).all();
|
||||
return rows
|
||||
.map((row) => ({
|
||||
branchName: row.branchName,
|
||||
commitSha: row.commitSha,
|
||||
}))
|
||||
.sort((left, right) => left.branchName.localeCompare(right.branchName));
|
||||
},
|
||||
},
|
||||
run: workflow(runGithubDataWorkflow),
|
||||
});
|
||||
|
||||
export async function reloadRepositoryMutation(c: any, 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();
|
||||
const currentMeta = await readMeta(c);
|
||||
await c.db
|
||||
.insert(githubRepositories)
|
||||
.values({
|
||||
repoId: input.repoId,
|
||||
fullName: repository.fullName,
|
||||
cloneUrl: repository.cloneUrl,
|
||||
private: repository.private ? 1 : 0,
|
||||
defaultBranch: repository.defaultBranch,
|
||||
syncGeneration: currentMeta.syncGeneration,
|
||||
updatedAt,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: githubRepositories.repoId,
|
||||
set: {
|
||||
fullName: repository.fullName,
|
||||
cloneUrl: repository.cloneUrl,
|
||||
private: repository.private ? 1 : 0,
|
||||
defaultBranch: repository.defaultBranch,
|
||||
syncGeneration: currentMeta.syncGeneration,
|
||||
updatedAt,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
await refreshRepositoryBranches(
|
||||
c,
|
||||
context,
|
||||
{
|
||||
fullName: repository.fullName,
|
||||
cloneUrl: repository.cloneUrl,
|
||||
private: repository.private,
|
||||
defaultBranch: repository.defaultBranch,
|
||||
},
|
||||
updatedAt,
|
||||
);
|
||||
|
||||
return {
|
||||
repoId: input.repoId,
|
||||
fullName: repository.fullName,
|
||||
cloneUrl: repository.cloneUrl,
|
||||
private: repository.private,
|
||||
defaultBranch: repository.defaultBranch,
|
||||
};
|
||||
}
|
||||
|
||||
export async function clearStateMutation(c: any, input: ClearStateInput) {
|
||||
const beforeRows = await readAllPullRequestRows(c);
|
||||
const currentMeta = await readMeta(c);
|
||||
await c.db.delete(githubPullRequests).run();
|
||||
await c.db.delete(githubBranches).run();
|
||||
await c.db.delete(githubRepositories).run();
|
||||
await c.db.delete(githubMembers).run();
|
||||
await writeMeta(c, {
|
||||
|
|
|
|||
|
|
@ -9,9 +9,8 @@ async function getIndexModule() {
|
|||
|
||||
export const GITHUB_DATA_QUEUE_NAMES = [
|
||||
"githubData.command.syncRepos",
|
||||
"githubData.command.reloadRepository",
|
||||
"githubData.command.clearState",
|
||||
"githubData.command.handlePullRequestWebhook",
|
||||
"githubData.command.clearState",
|
||||
] as const;
|
||||
|
||||
export type GithubDataQueueName = (typeof GITHUB_DATA_QUEUE_NAMES)[number];
|
||||
|
|
@ -46,10 +45,10 @@ export async function runGithubDataCommandLoop(c: any): Promise<void> {
|
|||
continue;
|
||||
}
|
||||
|
||||
if (msg.name === "githubData.command.reloadRepository") {
|
||||
const { reloadRepositoryMutation } = await getIndexModule();
|
||||
const result = await reloadRepositoryMutation(c, msg.body);
|
||||
await msg.complete(result);
|
||||
if (msg.name === "githubData.command.handlePullRequestWebhook") {
|
||||
const { handlePullRequestWebhookMutation } = await getIndexModule();
|
||||
await handlePullRequestWebhookMutation(c, msg.body);
|
||||
await msg.complete({ ok: true });
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -60,13 +59,6 @@ export async function runGithubDataCommandLoop(c: any): Promise<void> {
|
|||
continue;
|
||||
}
|
||||
|
||||
if (msg.name === "githubData.command.handlePullRequestWebhook") {
|
||||
const { handlePullRequestWebhookMutation } = await getIndexModule();
|
||||
await handlePullRequestWebhookMutation(c, msg.body);
|
||||
await msg.complete({ ok: true });
|
||||
continue;
|
||||
}
|
||||
|
||||
logActorWarning("githubData", "unknown queue message", { queueName: msg.name });
|
||||
await msg.complete({ error: `Unknown command: ${msg.name}` });
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import { organizationOnboardingActions } from "./actions/onboarding.js";
|
|||
import { organizationGithubActions } from "./actions/github.js";
|
||||
import { organizationShellActions } from "./actions/organization.js";
|
||||
import { organizationTaskActions } from "./actions/tasks.js";
|
||||
import { updateOrganizationShellProfileMutation } from "./app-shell.js";
|
||||
|
||||
interface OrganizationState {
|
||||
organizationId: string;
|
||||
|
|
@ -169,6 +170,11 @@ export const organizationActions = {
|
|||
assertOrganization(c, input.organizationId);
|
||||
return await getOrganizationSummarySnapshot(c);
|
||||
},
|
||||
|
||||
// updateShellProfile stays as a direct action — called with await from HTTP handler where the user can retry
|
||||
async updateShellProfile(c: any, input: { displayName?: string; slug?: string; primaryDomain?: string }): Promise<void> {
|
||||
await updateOrganizationShellProfileMutation(c, input);
|
||||
},
|
||||
};
|
||||
|
||||
export async function applyGithubSyncProgressMutation(
|
||||
|
|
|
|||
|
|
@ -242,71 +242,49 @@ export async function betterAuthDeleteManyVerificationMutation(c: any, input: {
|
|||
return rows.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Better Auth adapter actions — exposed as actions (not queue commands) so they
|
||||
* execute immediately without competing in the organization workflow queue.
|
||||
*
|
||||
* The org actor's workflow queue is shared with GitHub sync, webhook processing,
|
||||
* task mutations, and billing operations. When the queue is busy, auth operations
|
||||
* would time out (10s), causing Better Auth's parseState to throw a non-StateError
|
||||
* which redirects to ?error=please_restart_the_process.
|
||||
*
|
||||
* Auth operations are safe to run as actions because they are simple SQLite
|
||||
* reads/writes scoped to this actor instance with no cross-actor side effects.
|
||||
*/
|
||||
// Exception to the CLAUDE.md queue-for-mutations rule: Better Auth adapter operations
|
||||
// use direct actions even for mutations. Better Auth runs during OAuth callbacks on the
|
||||
// HTTP request path, not through the normal organization lifecycle. Routing through the
|
||||
// queue adds multiple sequential round-trips (each with actor wake-up + step overhead)
|
||||
// that cause 30-second OAuth callbacks and proxy retry storms. These mutations are simple
|
||||
// SQLite upserts/deletes with no cross-actor coordination or broadcast side effects.
|
||||
export const organizationBetterAuthActions = {
|
||||
// --- Mutation actions (formerly queue commands) ---
|
||||
|
||||
// --- Mutation actions (called by the Better Auth adapter in better-auth.ts) ---
|
||||
async betterAuthUpsertSessionIndex(c: any, input: { sessionId: string; sessionToken: string; userId: string }) {
|
||||
return await betterAuthUpsertSessionIndexMutation(c, input);
|
||||
},
|
||||
async betterAuthDeleteSessionIndex(c: any, input: { sessionId?: string; sessionToken?: string }) {
|
||||
await betterAuthDeleteSessionIndexMutation(c, input);
|
||||
},
|
||||
async betterAuthUpsertEmailIndex(c: any, input: { email: string; userId: string }) {
|
||||
return await betterAuthUpsertEmailIndexMutation(c, input);
|
||||
},
|
||||
async betterAuthDeleteEmailIndex(c: any, input: { email: string }) {
|
||||
await betterAuthDeleteEmailIndexMutation(c, input);
|
||||
},
|
||||
async betterAuthUpsertAccountIndex(c: any, input: { id: string; providerId: string; accountId: string; userId: string }) {
|
||||
return await betterAuthUpsertAccountIndexMutation(c, input);
|
||||
},
|
||||
async betterAuthDeleteAccountIndex(c: any, input: { id?: string; providerId?: string; accountId?: string }) {
|
||||
await betterAuthDeleteAccountIndexMutation(c, input);
|
||||
},
|
||||
async betterAuthCreateVerification(c: any, input: { data: Record<string, unknown> }) {
|
||||
return await betterAuthCreateVerificationMutation(c, input);
|
||||
},
|
||||
|
||||
async betterAuthUpdateVerification(c: any, input: { where: any[]; update: Record<string, unknown> }) {
|
||||
return await betterAuthUpdateVerificationMutation(c, input);
|
||||
},
|
||||
|
||||
async betterAuthUpdateManyVerification(c: any, input: { where: any[]; update: Record<string, unknown> }) {
|
||||
return await betterAuthUpdateManyVerificationMutation(c, input);
|
||||
},
|
||||
|
||||
async betterAuthDeleteVerification(c: any, input: { where: any[] }) {
|
||||
await betterAuthDeleteVerificationMutation(c, input);
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
async betterAuthDeleteManyVerification(c: any, input: { where: any[] }) {
|
||||
return await betterAuthDeleteManyVerificationMutation(c, input);
|
||||
},
|
||||
|
||||
async betterAuthUpsertSessionIndex(c: any, input: { sessionId: string; sessionToken: string; userId: string }) {
|
||||
return await betterAuthUpsertSessionIndexMutation(c, input);
|
||||
},
|
||||
|
||||
async betterAuthDeleteSessionIndex(c: any, input: { sessionId?: string; sessionToken?: string }) {
|
||||
await betterAuthDeleteSessionIndexMutation(c, input);
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
async betterAuthUpsertEmailIndex(c: any, input: { email: string; userId: string }) {
|
||||
return await betterAuthUpsertEmailIndexMutation(c, input);
|
||||
},
|
||||
|
||||
async betterAuthDeleteEmailIndex(c: any, input: { email: string }) {
|
||||
await betterAuthDeleteEmailIndexMutation(c, input);
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
async betterAuthUpsertAccountIndex(c: any, input: { id: string; providerId: string; accountId: string; userId: string }) {
|
||||
return await betterAuthUpsertAccountIndexMutation(c, input);
|
||||
},
|
||||
|
||||
async betterAuthDeleteAccountIndex(c: any, input: { id?: string; providerId?: string; accountId?: string }) {
|
||||
await betterAuthDeleteAccountIndexMutation(c, input);
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
// --- Read actions ---
|
||||
|
||||
async betterAuthFindSessionIndex(c: any, input: { sessionId?: string; sessionToken?: string }) {
|
||||
assertAppOrganization(c);
|
||||
|
||||
|
|
|
|||
|
|
@ -3,13 +3,7 @@ import type { FoundryAppSnapshot } from "@sandbox-agent/foundry-shared";
|
|||
import { getOrCreateGithubData, getOrCreateOrganization } from "../../handles.js";
|
||||
import { githubDataWorkflowQueueName } from "../../github-data/index.js";
|
||||
import { authSessionIndex } from "../db/schema.js";
|
||||
import {
|
||||
assertAppOrganization,
|
||||
buildAppSnapshot,
|
||||
requireEligibleOrganization,
|
||||
requireSignedInSession,
|
||||
markOrganizationSyncStartedMutation,
|
||||
} from "../app-shell.js";
|
||||
import { assertAppOrganization, buildAppSnapshot, requireEligibleOrganization, requireSignedInSession } from "../app-shell.js";
|
||||
import { getBetterAuthService } from "../../../services/better-auth.js";
|
||||
import { refreshOrganizationSnapshotMutation } from "../actions.js";
|
||||
import { organizationWorkflowQueueName } from "../queues.js";
|
||||
|
|
@ -79,8 +73,8 @@ export const organizationGithubActions = {
|
|||
await githubData.send(githubDataWorkflowQueueName("githubData.command.syncRepos"), { label: "Reloading GitHub organization..." }, { wait: false });
|
||||
},
|
||||
|
||||
async adminReloadGithubRepository(c: any, input: { repoId: string }): Promise<void> {
|
||||
async adminReloadGithubRepository(c: any, _input: { repoId: string }): Promise<void> {
|
||||
const githubData = await getOrCreateGithubData(c, c.state.organizationId);
|
||||
await githubData.send(githubDataWorkflowQueueName("githubData.command.reloadRepository"), input, { wait: false });
|
||||
await githubData.send(githubDataWorkflowQueueName("githubData.command.syncRepos"), { label: "Reloading repository..." }, { wait: false });
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import {
|
|||
requireEligibleOrganization,
|
||||
requireSignedInSession,
|
||||
} from "../app-shell.js";
|
||||
import { organizationWorkflowQueueName } from "../queues.js";
|
||||
|
||||
export const organizationShellActions = {
|
||||
async getAppSnapshot(c: any, input: { sessionId: string }): Promise<FoundryAppSnapshot> {
|
||||
|
|
@ -34,15 +33,11 @@ export const organizationShellActions = {
|
|||
const session = await requireSignedInSession(c, input.sessionId);
|
||||
requireEligibleOrganization(session, input.organizationId);
|
||||
const organization = await getOrCreateOrganization(c, input.organizationId);
|
||||
await organization.send(
|
||||
organizationWorkflowQueueName("organization.command.shell.profile.update"),
|
||||
{
|
||||
displayName: input.displayName,
|
||||
slug: input.slug,
|
||||
primaryDomain: input.primaryDomain,
|
||||
},
|
||||
{ wait: true, timeout: 10_000 },
|
||||
);
|
||||
await organization.updateShellProfile({
|
||||
displayName: input.displayName,
|
||||
slug: input.slug,
|
||||
primaryDomain: input.primaryDomain,
|
||||
});
|
||||
return await buildAppSnapshot(c, input.sessionId);
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@ interface RegisterTaskBranchCommand {
|
|||
repoId: string;
|
||||
taskId: string;
|
||||
branchName: string;
|
||||
requireExistingRemote?: boolean;
|
||||
}
|
||||
|
||||
function isStaleTaskReferenceError(error: unknown): boolean {
|
||||
|
|
@ -120,11 +119,6 @@ async function resolveGitHubRepository(c: any, repoId: string) {
|
|||
return await githubData.getRepository({ repoId }).catch(() => null);
|
||||
}
|
||||
|
||||
async function listGitHubBranches(c: any, repoId: string): Promise<Array<{ branchName: string; commitSha: string }>> {
|
||||
const githubData = getGithubData(c, c.state.organizationId);
|
||||
return await githubData.listBranchesForRepository({ repoId }).catch(() => []);
|
||||
}
|
||||
|
||||
async function resolveRepositoryRemoteUrl(c: any, repoId: string): Promise<string> {
|
||||
const repository = await resolveGitHubRepository(c, repoId);
|
||||
const remoteUrl = repository?.cloneUrl?.trim();
|
||||
|
|
@ -161,7 +155,6 @@ export async function createTaskMutation(c: any, cmd: CreateTaskCommand): Promis
|
|||
repoId,
|
||||
taskId,
|
||||
branchName: onBranch,
|
||||
requireExistingRemote: true,
|
||||
});
|
||||
} else {
|
||||
const reservedBranches = await listKnownTaskBranches(c, repoId);
|
||||
|
|
@ -255,7 +248,7 @@ export async function createTaskMutation(c: any, cmd: CreateTaskCommand): Promis
|
|||
return created;
|
||||
}
|
||||
|
||||
export async function registerTaskBranchMutation(c: any, cmd: RegisterTaskBranchCommand): Promise<{ branchName: string; headSha: string }> {
|
||||
export async function registerTaskBranchMutation(c: any, cmd: RegisterTaskBranchCommand): Promise<{ branchName: string }> {
|
||||
const branchName = cmd.branchName.trim();
|
||||
if (!branchName) {
|
||||
throw new Error("branchName is required");
|
||||
|
|
@ -284,16 +277,6 @@ export async function registerTaskBranchMutation(c: any, cmd: RegisterTaskBranch
|
|||
}
|
||||
}
|
||||
|
||||
const branches = await listGitHubBranches(c, cmd.repoId);
|
||||
const branchMatch = branches.find((branch) => branch.branchName === branchName) ?? null;
|
||||
if (cmd.requireExistingRemote && !branchMatch) {
|
||||
throw new Error(`Remote branch not found: ${branchName}`);
|
||||
}
|
||||
|
||||
const repository = await resolveGitHubRepository(c, cmd.repoId);
|
||||
const defaultBranch = repository?.defaultBranch ?? "main";
|
||||
const headSha = branchMatch?.commitSha ?? branches.find((branch) => branch.branchName === defaultBranch)?.commitSha ?? "";
|
||||
|
||||
const now = Date.now();
|
||||
await c.db
|
||||
.insert(taskIndex)
|
||||
|
|
@ -313,7 +296,7 @@ export async function registerTaskBranchMutation(c: any, cmd: RegisterTaskBranch
|
|||
})
|
||||
.run();
|
||||
|
||||
return { branchName, headSha };
|
||||
return { branchName };
|
||||
}
|
||||
|
||||
export async function applyTaskSummaryUpdateMutation(c: any, input: { taskSummary: WorkspaceTaskSummary }): Promise<void> {
|
||||
|
|
@ -392,7 +375,7 @@ export async function refreshTaskSummaryForBranchMutation(
|
|||
// Best-effort notify the task actor if it exists (fire-and-forget)
|
||||
try {
|
||||
const task = getTask(c, c.state.organizationId, input.repoId, row.taskId);
|
||||
void task.send(taskWorkflowQueueName("task.command.pull_request.sync"), { pullRequest }, { wait: false }).catch(() => {});
|
||||
void task.syncPullRequest({ pullRequest }).catch(() => {});
|
||||
} catch {
|
||||
// Task actor doesn't exist yet — that's fine, it's virtual
|
||||
}
|
||||
|
|
@ -402,34 +385,6 @@ export async function refreshTaskSummaryForBranchMutation(
|
|||
await refreshOrganizationSnapshotMutation(c);
|
||||
}
|
||||
|
||||
export function sortOverviewBranches(
|
||||
branches: Array<{
|
||||
branchName: string;
|
||||
commitSha: string;
|
||||
taskId: string | null;
|
||||
taskTitle: string | null;
|
||||
taskStatus: TaskRecord["status"] | null;
|
||||
pullRequest: WorkspacePullRequestSummary | null;
|
||||
ciStatus: string | null;
|
||||
updatedAt: number;
|
||||
}>,
|
||||
defaultBranch: string | null,
|
||||
) {
|
||||
return [...branches].sort((left, right) => {
|
||||
if (defaultBranch) {
|
||||
if (left.branchName === defaultBranch && right.branchName !== defaultBranch) return -1;
|
||||
if (right.branchName === defaultBranch && left.branchName !== defaultBranch) return 1;
|
||||
}
|
||||
if (Boolean(left.taskId) !== Boolean(right.taskId)) {
|
||||
return left.taskId ? -1 : 1;
|
||||
}
|
||||
if (left.updatedAt !== right.updatedAt) {
|
||||
return right.updatedAt - left.updatedAt;
|
||||
}
|
||||
return left.branchName.localeCompare(right.branchName);
|
||||
});
|
||||
}
|
||||
|
||||
export async function listTaskSummariesForRepo(c: any, repoId: string, includeArchived = false): Promise<TaskSummary[]> {
|
||||
const rows = await c.db.select().from(taskSummaries).where(eq(taskSummaries.repoId, repoId)).orderBy(desc(taskSummaries.updatedAtMs)).all();
|
||||
return rows
|
||||
|
|
@ -471,56 +426,24 @@ export async function getRepoOverviewFromOrg(c: any, repoId: string): Promise<Re
|
|||
const now = Date.now();
|
||||
const repository = await resolveGitHubRepository(c, repoId);
|
||||
const remoteUrl = await resolveRepositoryRemoteUrl(c, repoId);
|
||||
const githubBranches = await listGitHubBranches(c, repoId).catch(() => []);
|
||||
const taskRows = await c.db.select().from(taskSummaries).where(eq(taskSummaries.repoId, repoId)).all();
|
||||
|
||||
const taskMetaByBranch = new Map<
|
||||
string,
|
||||
{ taskId: string; title: string | null; status: TaskRecord["status"] | null; updatedAt: number; pullRequest: WorkspacePullRequestSummary | null }
|
||||
>();
|
||||
for (const row of taskRows) {
|
||||
if (!row.branch) {
|
||||
continue;
|
||||
}
|
||||
taskMetaByBranch.set(row.branch, {
|
||||
taskId: row.taskId,
|
||||
title: row.title ?? null,
|
||||
status: row.status,
|
||||
updatedAt: row.updatedAtMs,
|
||||
pullRequest: parseJsonValue<WorkspacePullRequestSummary | null>(row.pullRequestJson, null),
|
||||
});
|
||||
}
|
||||
|
||||
const branchMap = new Map<string, { branchName: string; commitSha: string }>();
|
||||
for (const branch of githubBranches) {
|
||||
branchMap.set(branch.branchName, branch);
|
||||
}
|
||||
for (const branchName of taskMetaByBranch.keys()) {
|
||||
if (!branchMap.has(branchName)) {
|
||||
branchMap.set(branchName, { branchName, commitSha: "" });
|
||||
}
|
||||
}
|
||||
if (repository?.defaultBranch && !branchMap.has(repository.defaultBranch)) {
|
||||
branchMap.set(repository.defaultBranch, { branchName: repository.defaultBranch, commitSha: "" });
|
||||
}
|
||||
|
||||
const branches = sortOverviewBranches(
|
||||
[...branchMap.values()].map((branch) => {
|
||||
const taskMeta = taskMetaByBranch.get(branch.branchName);
|
||||
const pr = taskMeta?.pullRequest ?? null;
|
||||
const branches = taskRows
|
||||
.filter((row: any) => row.branch)
|
||||
.map((row: any) => {
|
||||
const pr = parseJsonValue<WorkspacePullRequestSummary | null>(row.pullRequestJson, null);
|
||||
return {
|
||||
branchName: branch.branchName,
|
||||
commitSha: branch.commitSha,
|
||||
taskId: taskMeta?.taskId ?? null,
|
||||
taskTitle: taskMeta?.title ?? null,
|
||||
taskStatus: taskMeta?.status ?? null,
|
||||
branchName: row.branch!,
|
||||
commitSha: "",
|
||||
taskId: row.taskId,
|
||||
taskTitle: row.title ?? null,
|
||||
taskStatus: row.status ?? null,
|
||||
pullRequest: pr,
|
||||
ciStatus: null,
|
||||
updatedAt: Math.max(taskMeta?.updatedAt ?? 0, pr?.updatedAtMs ?? 0, now),
|
||||
updatedAt: Math.max(row.updatedAtMs ?? 0, pr?.updatedAtMs ?? 0, now),
|
||||
};
|
||||
}),
|
||||
repository?.defaultBranch ?? null,
|
||||
);
|
||||
})
|
||||
.sort((a: any, b: any) => b.updatedAt - a.updatedAt);
|
||||
|
||||
return {
|
||||
organizationId: c.state.organizationId,
|
||||
|
|
|
|||
|
|
@ -149,12 +149,12 @@ export const organizationTaskActions = {
|
|||
|
||||
async markWorkspaceUnread(c: any, input: TaskWorkspaceSelectInput): Promise<void> {
|
||||
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
|
||||
await task.send(taskWorkflowQueueName("task.command.workspace.mark_unread"), { authSessionId: input.authSessionId }, { wait: false });
|
||||
await task.markUnread({ authSessionId: input.authSessionId });
|
||||
},
|
||||
|
||||
async renameWorkspaceTask(c: any, input: TaskWorkspaceRenameInput): Promise<void> {
|
||||
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
|
||||
await task.send(taskWorkflowQueueName("task.command.workspace.rename_task"), { value: input.value }, { wait: false });
|
||||
await task.renameTask({ value: input.value });
|
||||
},
|
||||
|
||||
async createWorkspaceSession(c: any, input: TaskWorkspaceSelectInput & { model?: string }): Promise<{ sessionId: string }> {
|
||||
|
|
@ -173,54 +173,34 @@ export const organizationTaskActions = {
|
|||
|
||||
async renameWorkspaceSession(c: any, input: TaskWorkspaceRenameSessionInput): Promise<void> {
|
||||
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
|
||||
await task.send(
|
||||
taskWorkflowQueueName("task.command.workspace.rename_session"),
|
||||
{ sessionId: input.sessionId, title: input.title, authSessionId: input.authSessionId },
|
||||
{ wait: false },
|
||||
);
|
||||
await task.renameSession({ sessionId: input.sessionId, title: input.title });
|
||||
},
|
||||
|
||||
async selectWorkspaceSession(c: any, input: TaskWorkspaceSessionInput): Promise<void> {
|
||||
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
|
||||
await task.send(
|
||||
taskWorkflowQueueName("task.command.workspace.select_session"),
|
||||
{ sessionId: input.sessionId, authSessionId: input.authSessionId },
|
||||
{ wait: false },
|
||||
);
|
||||
await task.selectSession({ sessionId: input.sessionId, authSessionId: input.authSessionId });
|
||||
},
|
||||
|
||||
async setWorkspaceSessionUnread(c: any, input: TaskWorkspaceSetSessionUnreadInput): Promise<void> {
|
||||
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
|
||||
await task.send(
|
||||
taskWorkflowQueueName("task.command.workspace.set_session_unread"),
|
||||
{ sessionId: input.sessionId, unread: input.unread, authSessionId: input.authSessionId },
|
||||
{ wait: false },
|
||||
);
|
||||
await task.setSessionUnread({ sessionId: input.sessionId, unread: input.unread, authSessionId: input.authSessionId });
|
||||
},
|
||||
|
||||
async updateWorkspaceDraft(c: any, input: TaskWorkspaceUpdateDraftInput): Promise<void> {
|
||||
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
|
||||
void task
|
||||
.send(
|
||||
taskWorkflowQueueName("task.command.workspace.update_draft"),
|
||||
{
|
||||
sessionId: input.sessionId,
|
||||
text: input.text,
|
||||
attachments: input.attachments,
|
||||
authSessionId: input.authSessionId,
|
||||
},
|
||||
{ wait: false },
|
||||
)
|
||||
.updateDraft({
|
||||
sessionId: input.sessionId,
|
||||
text: input.text,
|
||||
attachments: input.attachments,
|
||||
authSessionId: input.authSessionId,
|
||||
})
|
||||
.catch(() => {});
|
||||
},
|
||||
|
||||
async changeWorkspaceModel(c: any, input: TaskWorkspaceChangeModelInput): Promise<void> {
|
||||
const task = await requireWorkspaceTask(c, input.repoId, input.taskId);
|
||||
await task.send(
|
||||
taskWorkflowQueueName("task.command.workspace.change_model"),
|
||||
{ sessionId: input.sessionId, model: input.model, authSessionId: input.authSessionId },
|
||||
{ wait: false },
|
||||
);
|
||||
await task.changeModel({ sessionId: input.sessionId, model: input.model, authSessionId: input.authSessionId });
|
||||
},
|
||||
|
||||
async sendWorkspaceMessage(c: any, input: TaskWorkspaceSendMessageInput): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import { getActorRuntimeContext } from "../context.js";
|
|||
import { getOrCreateGithubData, getOrCreateOrganization, selfOrganization } from "../handles.js";
|
||||
import { GitHubAppError } from "../../services/app-github.js";
|
||||
import { getBetterAuthService } from "../../services/better-auth.js";
|
||||
import { repoIdFromRemote, repoLabelFromRemote } from "../../services/repo.js";
|
||||
import { repoLabelFromRemote } from "../../services/repo.js";
|
||||
import { logger } from "../../logging.js";
|
||||
import { githubDataWorkflowQueueName } from "../github-data/index.js";
|
||||
import { organizationWorkflowQueueName } from "./queues.js";
|
||||
|
|
@ -685,10 +685,7 @@ async function applySubscriptionState(
|
|||
): Promise<void> {
|
||||
await organization.send(
|
||||
organizationWorkflowQueueName("organization.command.billing.stripe_subscription.apply"),
|
||||
{
|
||||
subscription,
|
||||
fallbackPlanId,
|
||||
},
|
||||
{ subscription, fallbackPlanId },
|
||||
{ wait: true, timeout: 10_000 },
|
||||
);
|
||||
}
|
||||
|
|
@ -705,9 +702,7 @@ export const organizationAppActions = {
|
|||
if (input.planId === "free") {
|
||||
await organizationHandle.send(
|
||||
organizationWorkflowQueueName("organization.command.billing.free_plan.apply"),
|
||||
{
|
||||
clearSubscription: false,
|
||||
},
|
||||
{ clearSubscription: false },
|
||||
{ wait: true, timeout: 10_000 },
|
||||
);
|
||||
return {
|
||||
|
|
@ -730,9 +725,7 @@ export const organizationAppActions = {
|
|||
).id;
|
||||
await organizationHandle.send(
|
||||
organizationWorkflowQueueName("organization.command.billing.stripe_customer.apply"),
|
||||
{
|
||||
customerId,
|
||||
},
|
||||
{ customerId },
|
||||
{ wait: true, timeout: 10_000 },
|
||||
);
|
||||
await upsertStripeLookupEntries(c, input.organizationId, customerId, null);
|
||||
|
|
@ -764,9 +757,7 @@ export const organizationAppActions = {
|
|||
if (completion.customerId) {
|
||||
await organizationHandle.send(
|
||||
organizationWorkflowQueueName("organization.command.billing.stripe_customer.apply"),
|
||||
{
|
||||
customerId: completion.customerId,
|
||||
},
|
||||
{ customerId: completion.customerId },
|
||||
{ wait: true, timeout: 10_000 },
|
||||
);
|
||||
}
|
||||
|
|
@ -780,9 +771,7 @@ export const organizationAppActions = {
|
|||
if (completion.paymentMethodLabel) {
|
||||
await organizationHandle.send(
|
||||
organizationWorkflowQueueName("organization.command.billing.payment_method.set"),
|
||||
{
|
||||
label: completion.paymentMethodLabel,
|
||||
},
|
||||
{ label: completion.paymentMethodLabel },
|
||||
{ wait: true, timeout: 10_000 },
|
||||
);
|
||||
}
|
||||
|
|
@ -824,9 +813,7 @@ export const organizationAppActions = {
|
|||
} else {
|
||||
await organizationHandle.send(
|
||||
organizationWorkflowQueueName("organization.command.billing.status.set"),
|
||||
{
|
||||
status: "scheduled_cancel",
|
||||
},
|
||||
{ status: "scheduled_cancel" },
|
||||
{ wait: true, timeout: 10_000 },
|
||||
);
|
||||
}
|
||||
|
|
@ -849,9 +836,7 @@ export const organizationAppActions = {
|
|||
} else {
|
||||
await organizationHandle.send(
|
||||
organizationWorkflowQueueName("organization.command.billing.status.set"),
|
||||
{
|
||||
status: "active",
|
||||
},
|
||||
{ status: "active" },
|
||||
{ wait: true, timeout: 10_000 },
|
||||
);
|
||||
}
|
||||
|
|
@ -866,9 +851,7 @@ export const organizationAppActions = {
|
|||
const organization = await getOrCreateOrganization(c, input.organizationId);
|
||||
await organization.send(
|
||||
organizationWorkflowQueueName("organization.command.billing.seat_usage.record"),
|
||||
{
|
||||
email: session.currentUserEmail,
|
||||
},
|
||||
{ email: session.currentUserEmail },
|
||||
{ wait: true, timeout: 10_000 },
|
||||
);
|
||||
return await buildAppSnapshot(c, input.sessionId);
|
||||
|
|
@ -893,9 +876,7 @@ export const organizationAppActions = {
|
|||
if (typeof object.customer === "string") {
|
||||
await organization.send(
|
||||
organizationWorkflowQueueName("organization.command.billing.stripe_customer.apply"),
|
||||
{
|
||||
customerId: object.customer,
|
||||
},
|
||||
{ customerId: object.customer },
|
||||
{ wait: true, timeout: 10_000 },
|
||||
);
|
||||
}
|
||||
|
|
@ -932,9 +913,7 @@ export const organizationAppActions = {
|
|||
const organization = await getOrCreateOrganization(c, organizationId);
|
||||
await organization.send(
|
||||
organizationWorkflowQueueName("organization.command.billing.free_plan.apply"),
|
||||
{
|
||||
clearSubscription: true,
|
||||
},
|
||||
{ clearSubscription: true },
|
||||
{ wait: true, timeout: 10_000 },
|
||||
);
|
||||
}
|
||||
|
|
@ -990,12 +969,7 @@ export const organizationAppActions = {
|
|||
const organization = await getOrCreateOrganization(c, organizationId);
|
||||
await organization.send(
|
||||
organizationWorkflowQueueName("organization.command.github.webhook_receipt.record"),
|
||||
{
|
||||
organizationId: organizationId,
|
||||
event,
|
||||
action: body.action ?? null,
|
||||
receivedAt,
|
||||
},
|
||||
{ organizationId, event, action: body.action ?? null, receivedAt },
|
||||
{ wait: false },
|
||||
);
|
||||
const githubData = await getOrCreateGithubData(c, organizationId);
|
||||
|
|
@ -1013,12 +987,7 @@ export const organizationAppActions = {
|
|||
if (body.action === "deleted") {
|
||||
await githubData.send(
|
||||
githubDataWorkflowQueueName("githubData.command.clearState"),
|
||||
{
|
||||
connectedAccount: accountLogin,
|
||||
installationStatus: "install_required",
|
||||
installationId: null,
|
||||
label: "GitHub App installation removed",
|
||||
},
|
||||
{ connectedAccount: accountLogin, installationStatus: "install_required", installationId: null, label: "GitHub App installation removed" },
|
||||
{ wait: false },
|
||||
);
|
||||
} else if (body.action === "created") {
|
||||
|
|
@ -1147,13 +1116,6 @@ export const organizationAppActions = {
|
|||
{ wait: false },
|
||||
);
|
||||
}
|
||||
if ((event === "push" || event === "create" || event === "delete") && body.repository?.clone_url) {
|
||||
const repoId = repoIdFromRemote(body.repository.clone_url);
|
||||
const knownRepository = await githubData.getRepository({ repoId });
|
||||
if (knownRepository) {
|
||||
await githubData.send(githubDataWorkflowQueueName("githubData.command.reloadRepository"), { repoId }, { wait: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,14 @@
|
|||
export const ORGANIZATION_QUEUE_NAMES = [
|
||||
"organization.command.createTask",
|
||||
"organization.command.materializeTask",
|
||||
"organization.command.registerTaskBranch",
|
||||
"organization.command.applyTaskSummaryUpdate",
|
||||
"organization.command.removeTaskSummary",
|
||||
"organization.command.refreshTaskSummaryForBranch",
|
||||
"organization.command.snapshot.broadcast",
|
||||
"organization.command.syncGithubSession",
|
||||
"organization.command.github.organization_shell.sync_from_github",
|
||||
"organization.command.github.sync_progress.apply",
|
||||
"organization.command.github.webhook_receipt.record",
|
||||
"organization.command.github.organization_shell.sync_from_github",
|
||||
"organization.command.shell.profile.update",
|
||||
"organization.command.shell.sync_started.mark",
|
||||
"organization.command.billing.stripe_customer.apply",
|
||||
"organization.command.billing.stripe_subscription.apply",
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ import {
|
|||
applyTaskSummaryUpdateMutation,
|
||||
createTaskMutation,
|
||||
refreshTaskSummaryForBranchMutation,
|
||||
registerTaskBranchMutation,
|
||||
removeTaskSummaryMutation,
|
||||
} from "./actions/task-mutations.js";
|
||||
import {
|
||||
|
|
@ -29,7 +28,6 @@ import {
|
|||
setOrganizationBillingPaymentMethodMutation,
|
||||
setOrganizationBillingStatusMutation,
|
||||
syncOrganizationShellFromGithubMutation,
|
||||
updateOrganizationShellProfileMutation,
|
||||
upsertOrganizationInvoiceMutation,
|
||||
} from "./app-shell.js";
|
||||
|
||||
|
|
@ -49,7 +47,6 @@ const COMMAND_HANDLERS: Record<OrganizationQueueName, WorkflowHandler> = {
|
|||
// Task mutations
|
||||
"organization.command.createTask": async (c, body) => createTaskMutation(c, body),
|
||||
"organization.command.materializeTask": async (c, body) => createTaskMutation(c, body),
|
||||
"organization.command.registerTaskBranch": async (c, body) => registerTaskBranchMutation(c, body),
|
||||
"organization.command.applyTaskSummaryUpdate": async (c, body) => {
|
||||
await applyTaskSummaryUpdateMutation(c, body);
|
||||
return { ok: true };
|
||||
|
|
@ -72,7 +69,10 @@ const COMMAND_HANDLERS: Record<OrganizationQueueName, WorkflowHandler> = {
|
|||
return { ok: true };
|
||||
},
|
||||
|
||||
// GitHub sync mutations
|
||||
// GitHub organization shell sync (stays on queue)
|
||||
"organization.command.github.organization_shell.sync_from_github": async (c, body) => syncOrganizationShellFromGithubMutation(c, body),
|
||||
|
||||
// GitHub sync progress + webhook receipt
|
||||
"organization.command.github.sync_progress.apply": async (c, body) => {
|
||||
await applyGithubSyncProgressMutation(c, body);
|
||||
return { ok: true };
|
||||
|
|
@ -81,13 +81,6 @@ const COMMAND_HANDLERS: Record<OrganizationQueueName, WorkflowHandler> = {
|
|||
await recordGithubWebhookReceiptMutation(c, body);
|
||||
return { ok: true };
|
||||
},
|
||||
"organization.command.github.organization_shell.sync_from_github": async (c, body) => syncOrganizationShellFromGithubMutation(c, body),
|
||||
|
||||
// Shell/profile mutations
|
||||
"organization.command.shell.profile.update": async (c, body) => {
|
||||
await updateOrganizationShellProfileMutation(c, body);
|
||||
return { ok: true };
|
||||
},
|
||||
"organization.command.shell.sync_started.mark": async (c, body) => {
|
||||
await markOrganizationSyncStartedMutation(c, body);
|
||||
return { ok: true };
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@ import { logActorWarning, resolveErrorMessage } from "../logging.js";
|
|||
import { expectQueueResponse } from "../../services/queue.js";
|
||||
import { resolveSandboxProviderId } from "../../sandbox-config.js";
|
||||
|
||||
const SANDBOX_REPO_CWD = "/home/user/repo";
|
||||
const DEFAULT_LOCAL_SANDBOX_IMAGE = "rivetdev/sandbox-agent:full";
|
||||
const SANDBOX_REPO_CWD = "/home/sandbox/repo";
|
||||
const DEFAULT_LOCAL_SANDBOX_IMAGE = "rivetdev/sandbox-agent:foundry-base-latest";
|
||||
const DEFAULT_LOCAL_SANDBOX_PORT = 2468;
|
||||
const dockerClient = new Dockerode({ socketPath: "/var/run/docker.sock" });
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,22 @@ import { workflow } from "rivetkit/workflow";
|
|||
import type { TaskRecord } from "@sandbox-agent/foundry-shared";
|
||||
import { taskDb } from "./db/db.js";
|
||||
import { getCurrentRecord } from "./workflow/common.js";
|
||||
import { getSessionDetail, getTaskDetail, getTaskSummary } from "./workspace.js";
|
||||
import {
|
||||
changeWorkspaceModel,
|
||||
getSessionDetail,
|
||||
getTaskDetail,
|
||||
getTaskSummary,
|
||||
markWorkspaceUnread,
|
||||
refreshWorkspaceDerivedState,
|
||||
refreshWorkspaceSessionTranscript,
|
||||
renameWorkspaceSession,
|
||||
renameWorkspaceTask,
|
||||
selectWorkspaceSession,
|
||||
setWorkspaceSessionUnread,
|
||||
syncTaskPullRequest,
|
||||
syncWorkspaceSessionStatus,
|
||||
updateWorkspaceDraft,
|
||||
} from "./workspace.js";
|
||||
import { runTaskWorkflow } from "./workflow/index.js";
|
||||
import { TASK_QUEUE_NAMES } from "./workflow/queue.js";
|
||||
|
||||
|
|
@ -42,6 +57,41 @@ export const task = actor({
|
|||
async getSessionDetail(c, input: { sessionId: string; authSessionId?: string }) {
|
||||
return await getSessionDetail(c, input.sessionId, input.authSessionId);
|
||||
},
|
||||
|
||||
// Direct actions migrated from queue:
|
||||
async markUnread(c, input: { authSessionId?: string }) {
|
||||
await markWorkspaceUnread(c, input?.authSessionId);
|
||||
},
|
||||
async renameTask(c, input: { value: string }) {
|
||||
await renameWorkspaceTask(c, input.value);
|
||||
},
|
||||
async renameSession(c, input: { sessionId: string; title: string }) {
|
||||
await renameWorkspaceSession(c, input.sessionId, input.title);
|
||||
},
|
||||
async selectSession(c, input: { sessionId: string; authSessionId?: string }) {
|
||||
await selectWorkspaceSession(c, input.sessionId, input?.authSessionId);
|
||||
},
|
||||
async setSessionUnread(c, input: { sessionId: string; unread: boolean; authSessionId?: string }) {
|
||||
await setWorkspaceSessionUnread(c, input.sessionId, input.unread, input?.authSessionId);
|
||||
},
|
||||
async updateDraft(c, input: { sessionId: string; text: string; attachments: any[]; authSessionId?: string }) {
|
||||
await updateWorkspaceDraft(c, input.sessionId, input.text, input.attachments, input?.authSessionId);
|
||||
},
|
||||
async changeModel(c, input: { sessionId: string; model: string; authSessionId?: string }) {
|
||||
await changeWorkspaceModel(c, input.sessionId, input.model, input?.authSessionId);
|
||||
},
|
||||
async refreshSessionTranscript(c, input: { sessionId: string }) {
|
||||
await refreshWorkspaceSessionTranscript(c, input.sessionId);
|
||||
},
|
||||
async refreshDerived(c) {
|
||||
await refreshWorkspaceDerivedState(c);
|
||||
},
|
||||
async syncSessionStatus(c, input: { sessionId: string; status: "running" | "idle" | "error"; at: number }) {
|
||||
await syncWorkspaceSessionStatus(c, input.sessionId, input.status, input.at);
|
||||
},
|
||||
async syncPullRequest(c, input: { pullRequest: any }) {
|
||||
await syncTaskPullRequest(c, input?.pullRequest ?? null);
|
||||
},
|
||||
},
|
||||
run: workflow(runTaskWorkflow),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ import { initBootstrapDbActivity, initCompleteActivity, initEnqueueProvisionActi
|
|||
import {
|
||||
handleArchiveActivity,
|
||||
handleAttachActivity,
|
||||
handleGetActivity,
|
||||
handlePushActivity,
|
||||
handleSimpleCommandActivity,
|
||||
handleSwitchActivity,
|
||||
|
|
@ -25,24 +24,13 @@ import {
|
|||
} from "./commands.js";
|
||||
import {
|
||||
changeTaskOwnerManually,
|
||||
changeWorkspaceModel,
|
||||
closeWorkspaceSession,
|
||||
createWorkspaceSession,
|
||||
ensureWorkspaceSession,
|
||||
refreshWorkspaceDerivedState,
|
||||
refreshWorkspaceSessionTranscript,
|
||||
markWorkspaceUnread,
|
||||
publishWorkspacePr,
|
||||
renameWorkspaceTask,
|
||||
renameWorkspaceSession,
|
||||
selectWorkspaceSession,
|
||||
revertWorkspaceFile,
|
||||
sendWorkspaceMessage,
|
||||
setWorkspaceSessionUnread,
|
||||
stopWorkspaceSession,
|
||||
syncTaskPullRequest,
|
||||
syncWorkspaceSessionStatus,
|
||||
updateWorkspaceDraft,
|
||||
} from "../workspace.js";
|
||||
|
||||
export { taskWorkflowQueueName } from "./queue.js";
|
||||
|
|
@ -100,25 +88,6 @@ const COMMAND_HANDLERS: Record<TaskQueueName, WorkflowHandler> = {
|
|||
await killWriteDbActivity(loopCtx, msg);
|
||||
},
|
||||
|
||||
"task.command.get": async (loopCtx, msg) => {
|
||||
await handleGetActivity(loopCtx, msg);
|
||||
},
|
||||
|
||||
"task.command.pull_request.sync": async (loopCtx, msg) => {
|
||||
await syncTaskPullRequest(loopCtx, msg.body?.pullRequest ?? null);
|
||||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
"task.command.workspace.mark_unread": async (loopCtx, msg) => {
|
||||
await markWorkspaceUnread(loopCtx, msg.body?.authSessionId);
|
||||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
"task.command.workspace.rename_task": async (loopCtx, msg) => {
|
||||
await renameWorkspaceTask(loopCtx, msg.body.value);
|
||||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
"task.command.workspace.create_session": async (loopCtx, msg) => {
|
||||
const result = await createWorkspaceSession(loopCtx, msg.body?.model, msg.body?.authSessionId);
|
||||
await msg.complete(result);
|
||||
|
|
@ -141,31 +110,6 @@ const COMMAND_HANDLERS: Record<TaskQueueName, WorkflowHandler> = {
|
|||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
"task.command.workspace.rename_session": async (loopCtx, msg) => {
|
||||
await renameWorkspaceSession(loopCtx, msg.body.sessionId, msg.body.title);
|
||||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
"task.command.workspace.select_session": async (loopCtx, msg) => {
|
||||
await selectWorkspaceSession(loopCtx, msg.body.sessionId, msg.body?.authSessionId);
|
||||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
"task.command.workspace.set_session_unread": async (loopCtx, msg) => {
|
||||
await setWorkspaceSessionUnread(loopCtx, msg.body.sessionId, msg.body.unread, msg.body?.authSessionId);
|
||||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
"task.command.workspace.update_draft": async (loopCtx, msg) => {
|
||||
await updateWorkspaceDraft(loopCtx, msg.body.sessionId, msg.body.text, msg.body.attachments, msg.body?.authSessionId);
|
||||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
"task.command.workspace.change_model": async (loopCtx, msg) => {
|
||||
await changeWorkspaceModel(loopCtx, msg.body.sessionId, msg.body.model, msg.body?.authSessionId);
|
||||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
"task.command.workspace.send_message": async (loopCtx, msg) => {
|
||||
await sendWorkspaceMessage(loopCtx, msg.body.sessionId, msg.body.text, msg.body.attachments, msg.body?.authSessionId);
|
||||
await msg.complete({ ok: true });
|
||||
|
|
@ -176,21 +120,6 @@ const COMMAND_HANDLERS: Record<TaskQueueName, WorkflowHandler> = {
|
|||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
"task.command.workspace.sync_session_status": async (loopCtx, msg) => {
|
||||
await syncWorkspaceSessionStatus(loopCtx, msg.body.sessionId, msg.body.status, msg.body.at);
|
||||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
"task.command.workspace.refresh_derived": async (loopCtx, msg) => {
|
||||
await refreshWorkspaceDerivedState(loopCtx);
|
||||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
"task.command.workspace.refresh_session_transcript": async (loopCtx, msg) => {
|
||||
await refreshWorkspaceSessionTranscript(loopCtx, msg.body.sessionId);
|
||||
await msg.complete({ ok: true });
|
||||
},
|
||||
|
||||
"task.command.workspace.close_session": async (loopCtx, msg) => {
|
||||
await closeWorkspaceSession(loopCtx, msg.body.sessionId, msg.body?.authSessionId);
|
||||
await msg.complete({ ok: true });
|
||||
|
|
|
|||
|
|
@ -8,23 +8,11 @@ export const TASK_QUEUE_NAMES = [
|
|||
"task.command.merge",
|
||||
"task.command.archive",
|
||||
"task.command.kill",
|
||||
"task.command.get",
|
||||
"task.command.pull_request.sync",
|
||||
"task.command.workspace.mark_unread",
|
||||
"task.command.workspace.rename_task",
|
||||
"task.command.workspace.create_session",
|
||||
"task.command.workspace.create_session_and_send",
|
||||
"task.command.workspace.ensure_session",
|
||||
"task.command.workspace.rename_session",
|
||||
"task.command.workspace.select_session",
|
||||
"task.command.workspace.set_session_unread",
|
||||
"task.command.workspace.update_draft",
|
||||
"task.command.workspace.change_model",
|
||||
"task.command.workspace.send_message",
|
||||
"task.command.workspace.stop_session",
|
||||
"task.command.workspace.sync_session_status",
|
||||
"task.command.workspace.refresh_derived",
|
||||
"task.command.workspace.refresh_session_transcript",
|
||||
"task.command.workspace.close_session",
|
||||
"task.command.workspace.publish_pr",
|
||||
"task.command.workspace.revert_file",
|
||||
|
|
|
|||
|
|
@ -17,8 +17,6 @@ import { getBetterAuthService } from "../../services/better-auth.js";
|
|||
import { resolveOrganizationGithubAuth } from "../../services/github-auth.js";
|
||||
import { githubRepoFullNameFromRemote } from "../../services/repo.js";
|
||||
import { taskWorkflowQueueName } from "./workflow/queue.js";
|
||||
import { expectQueueResponse } from "../../services/queue.js";
|
||||
import { userWorkflowQueueName } from "../user/workflow.js";
|
||||
import { organizationWorkflowQueueName } from "../organization/queues.js";
|
||||
|
||||
import { task as taskTable, taskOwner, taskRuntime, taskSandboxes, taskWorkspaceSessions } from "./db/schema.js";
|
||||
|
|
@ -185,9 +183,9 @@ async function injectGitCredentials(sandbox: any, login: string, email: string,
|
|||
"set -euo pipefail",
|
||||
`git config --global user.name ${JSON.stringify(login)}`,
|
||||
`git config --global user.email ${JSON.stringify(email)}`,
|
||||
`git config --global credential.helper 'store --file=/home/user/.git-token'`,
|
||||
`printf '%s\\n' ${JSON.stringify(`https://${login}:${token}@github.com`)} > /home/user/.git-token`,
|
||||
`chmod 600 /home/user/.git-token`,
|
||||
`git config --global credential.helper 'store --file=/home/sandbox/.git-token'`,
|
||||
`printf '%s\\n' ${JSON.stringify(`https://${login}:${token}@github.com`)} > /home/sandbox/.git-token`,
|
||||
`chmod 600 /home/sandbox/.git-token`,
|
||||
];
|
||||
const result = await sandbox.runProcess({
|
||||
command: "bash",
|
||||
|
|
@ -427,17 +425,11 @@ async function upsertUserTaskState(c: any, authSessionId: string | null | undefi
|
|||
}
|
||||
|
||||
const user = await getOrCreateUser(c, userId);
|
||||
expectQueueResponse(
|
||||
await user.send(
|
||||
userWorkflowQueueName("user.command.task_state.upsert"),
|
||||
{
|
||||
taskId: c.state.taskId,
|
||||
sessionId,
|
||||
patch,
|
||||
},
|
||||
{ wait: true, timeout: 10_000 },
|
||||
),
|
||||
);
|
||||
await user.upsertTaskState({
|
||||
taskId: c.state.taskId,
|
||||
sessionId,
|
||||
patch,
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteUserTaskState(c: any, authSessionId: string | null | undefined, sessionId: string): Promise<void> {
|
||||
|
|
@ -452,14 +444,10 @@ async function deleteUserTaskState(c: any, authSessionId: string | null | undefi
|
|||
}
|
||||
|
||||
const user = await getOrCreateUser(c, userId);
|
||||
await user.send(
|
||||
userWorkflowQueueName("user.command.task_state.delete"),
|
||||
{
|
||||
taskId: c.state.taskId,
|
||||
sessionId,
|
||||
},
|
||||
{ wait: true, timeout: 10_000 },
|
||||
);
|
||||
await user.deleteTaskState({
|
||||
taskId: c.state.taskId,
|
||||
sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveDefaultModel(c: any, authSessionId?: string | null): Promise<string> {
|
||||
|
|
@ -937,13 +925,14 @@ async function writeSessionTranscript(c: any, sessionId: string, transcript: Arr
|
|||
});
|
||||
}
|
||||
|
||||
async function enqueueWorkspaceRefresh(
|
||||
c: any,
|
||||
command: "task.command.workspace.refresh_derived" | "task.command.workspace.refresh_session_transcript",
|
||||
body: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
function fireRefreshDerived(c: any): void {
|
||||
const self = selfTask(c);
|
||||
await self.send(taskWorkflowQueueName(command as any), body, { wait: false });
|
||||
void self.refreshDerived({}).catch(() => {});
|
||||
}
|
||||
|
||||
function fireRefreshSessionTranscript(c: any, sessionId: string): void {
|
||||
const self = selfTask(c);
|
||||
void self.refreshSessionTranscript({ sessionId }).catch(() => {});
|
||||
}
|
||||
|
||||
async function enqueueWorkspaceEnsureSession(c: any, sessionId: string): Promise<void> {
|
||||
|
|
@ -958,16 +947,14 @@ function pendingWorkspaceSessionStatus(record: any): "pending_provision" | "pend
|
|||
async function maybeScheduleWorkspaceRefreshes(c: any, record: any, sessions: Array<any>): Promise<void> {
|
||||
const gitState = await readCachedGitState(c);
|
||||
if (record.activeSandboxId && !gitState.updatedAt) {
|
||||
await enqueueWorkspaceRefresh(c, "task.command.workspace.refresh_derived", {});
|
||||
fireRefreshDerived(c);
|
||||
}
|
||||
|
||||
for (const session of sessions) {
|
||||
if (session.closed || session.status !== "ready" || !session.sandboxSessionId || session.transcriptUpdatedAt) {
|
||||
continue;
|
||||
}
|
||||
await enqueueWorkspaceRefresh(c, "task.command.workspace.refresh_session_transcript", {
|
||||
sessionId: session.sandboxSessionId,
|
||||
});
|
||||
fireRefreshSessionTranscript(c, session.sandboxSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1097,11 +1084,28 @@ export async function buildTaskDetail(c: any, authSessionId?: string | null): Pr
|
|||
diffs: gitState.diffs,
|
||||
fileTree: gitState.fileTree,
|
||||
minutesUsed: 0,
|
||||
sandboxes: (record.sandboxes ?? []).map((sandbox: any) => ({
|
||||
sandboxProviderId: sandbox.sandboxProviderId,
|
||||
sandboxId: sandbox.sandboxId,
|
||||
cwd: sandbox.cwd ?? null,
|
||||
})),
|
||||
sandboxes: await Promise.all(
|
||||
(record.sandboxes ?? []).map(async (sandbox: any) => {
|
||||
let url: string | null = null;
|
||||
if (sandbox.sandboxId) {
|
||||
try {
|
||||
const handle = getTaskSandbox(c, c.state.organizationId, sandbox.sandboxId);
|
||||
const conn = await handle.sandboxAgentConnection();
|
||||
if (conn?.endpoint && !conn.endpoint.startsWith("mock://")) {
|
||||
url = conn.endpoint;
|
||||
}
|
||||
} catch {
|
||||
// Sandbox may not be running
|
||||
}
|
||||
}
|
||||
return {
|
||||
sandboxProviderId: sandbox.sandboxProviderId,
|
||||
sandboxId: sandbox.sandboxId,
|
||||
cwd: sandbox.cwd ?? null,
|
||||
url,
|
||||
};
|
||||
}),
|
||||
),
|
||||
activeSandboxId: record.activeSandboxId ?? null,
|
||||
};
|
||||
}
|
||||
|
|
@ -1267,9 +1271,7 @@ export async function ensureWorkspaceSession(c: any, sessionId: string, model?:
|
|||
|
||||
const record = await ensureWorkspaceSeeded(c);
|
||||
if (meta.sandboxSessionId && meta.status === "ready") {
|
||||
await enqueueWorkspaceRefresh(c, "task.command.workspace.refresh_session_transcript", {
|
||||
sessionId: meta.sandboxSessionId,
|
||||
});
|
||||
fireRefreshSessionTranscript(c, meta.sandboxSessionId);
|
||||
await broadcastTaskUpdate(c, { sessionId: sessionId });
|
||||
return;
|
||||
}
|
||||
|
|
@ -1299,9 +1301,7 @@ export async function ensureWorkspaceSession(c: any, sessionId: string, model?:
|
|||
status: "ready",
|
||||
errorMessage: null,
|
||||
});
|
||||
await enqueueWorkspaceRefresh(c, "task.command.workspace.refresh_session_transcript", {
|
||||
sessionId: meta.sandboxSessionId ?? sessionId,
|
||||
});
|
||||
fireRefreshSessionTranscript(c, meta.sandboxSessionId ?? sessionId);
|
||||
} catch (error) {
|
||||
await updateSessionMeta(c, sessionId, {
|
||||
status: "error",
|
||||
|
|
@ -1506,11 +1506,9 @@ export async function syncWorkspaceSessionStatus(c: any, sessionId: string, stat
|
|||
})
|
||||
.where(eq(taskTable.id, 1))
|
||||
.run();
|
||||
await enqueueWorkspaceRefresh(c, "task.command.workspace.refresh_session_transcript", {
|
||||
sessionId,
|
||||
});
|
||||
fireRefreshSessionTranscript(c, sessionId);
|
||||
if (status !== "running") {
|
||||
await enqueueWorkspaceRefresh(c, "task.command.workspace.refresh_derived", {});
|
||||
fireRefreshDerived(c);
|
||||
}
|
||||
await broadcastTaskUpdate(c, { sessionId: meta.sessionId });
|
||||
}
|
||||
|
|
@ -1608,6 +1606,6 @@ export async function revertWorkspaceFile(c: any, path: string): Promise<void> {
|
|||
if (result.exitCode !== 0) {
|
||||
throw new Error(`file revert failed (${result.exitCode}): ${result.result}`);
|
||||
}
|
||||
await enqueueWorkspaceRefresh(c, "task.command.workspace.refresh_derived", {});
|
||||
fireRefreshDerived(c);
|
||||
await broadcastTaskUpdate(c);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,53 +1,71 @@
|
|||
import { asc, count as sqlCount, desc } from "drizzle-orm";
|
||||
import { applyJoinToRow, applyJoinToRows, buildWhere, columnFor, tableFor } from "../query-helpers.js";
|
||||
import {
|
||||
createAuthRecordMutation,
|
||||
updateAuthRecordMutation,
|
||||
updateManyAuthRecordsMutation,
|
||||
deleteAuthRecordMutation,
|
||||
deleteManyAuthRecordsMutation,
|
||||
} from "../workflow.js";
|
||||
import { applyJoinToRow, applyJoinToRows, buildWhere, columnFor, materializeRow, persistInput, persistPatch, tableFor } from "../query-helpers.js";
|
||||
|
||||
/**
|
||||
* Better Auth adapter actions — exposed as actions (not queue commands) so they
|
||||
* execute immediately without competing in the user workflow queue.
|
||||
*
|
||||
* The user actor's workflow queue is shared with profile upserts, session state,
|
||||
* and task state operations. When the queue is busy, auth operations would time
|
||||
* out (10s), causing Better Auth's parseState to throw a non-StateError which
|
||||
* redirects to ?error=please_restart_the_process.
|
||||
*
|
||||
* Auth operations are safe to run as actions because they are simple SQLite
|
||||
* reads/writes scoped to this actor instance with no cross-actor side effects.
|
||||
*/
|
||||
// Exception to the CLAUDE.md queue-for-mutations rule: Better Auth adapter operations
|
||||
// use direct actions even for mutations. Better Auth runs during OAuth callbacks on the
|
||||
// HTTP request path, not through the normal organization lifecycle. Routing through the
|
||||
// queue adds multiple sequential round-trips (each with actor wake-up + step overhead)
|
||||
// that cause 30-second OAuth callbacks and proxy retry storms. These mutations are simple
|
||||
// SQLite upserts/deletes with no cross-actor coordination or broadcast side effects.
|
||||
export const betterAuthActions = {
|
||||
// --- Mutation actions (formerly queue commands) ---
|
||||
|
||||
async betterAuthCreateRecord(c: any, input: { model: string; data: Record<string, unknown> }) {
|
||||
return await createAuthRecordMutation(c, input);
|
||||
// --- Mutation actions ---
|
||||
async betterAuthCreateRecord(c, input: { model: string; data: Record<string, unknown> }) {
|
||||
const table = tableFor(input.model);
|
||||
const persisted = persistInput(input.model, input.data);
|
||||
await c.db
|
||||
.insert(table)
|
||||
.values(persisted as any)
|
||||
.run();
|
||||
const row = await c.db
|
||||
.select()
|
||||
.from(table)
|
||||
.where(buildWhere(table, [{ field: "id", value: input.data.id }])!)
|
||||
.get();
|
||||
return materializeRow(input.model, row);
|
||||
},
|
||||
|
||||
async betterAuthUpdateRecord(c: any, input: { model: string; where: any[]; update: Record<string, unknown> }) {
|
||||
return await updateAuthRecordMutation(c, input);
|
||||
async betterAuthUpdateRecord(c, input: { model: string; where: any[]; update: Record<string, unknown> }) {
|
||||
const table = tableFor(input.model);
|
||||
const predicate = buildWhere(table, input.where);
|
||||
if (!predicate) throw new Error("betterAuthUpdateRecord requires a where clause");
|
||||
await c.db
|
||||
.update(table)
|
||||
.set(persistPatch(input.model, input.update) as any)
|
||||
.where(predicate)
|
||||
.run();
|
||||
return materializeRow(input.model, await c.db.select().from(table).where(predicate).get());
|
||||
},
|
||||
|
||||
async betterAuthUpdateManyRecords(c: any, input: { model: string; where: any[]; update: Record<string, unknown> }) {
|
||||
return await updateManyAuthRecordsMutation(c, input);
|
||||
async betterAuthUpdateManyRecords(c, input: { model: string; where: any[]; update: Record<string, unknown> }) {
|
||||
const table = tableFor(input.model);
|
||||
const predicate = buildWhere(table, input.where);
|
||||
if (!predicate) throw new Error("betterAuthUpdateManyRecords requires a where clause");
|
||||
await c.db
|
||||
.update(table)
|
||||
.set(persistPatch(input.model, input.update) as any)
|
||||
.where(predicate)
|
||||
.run();
|
||||
const row = await c.db.select({ value: sqlCount() }).from(table).where(predicate).get();
|
||||
return row?.value ?? 0;
|
||||
},
|
||||
|
||||
async betterAuthDeleteRecord(c: any, input: { model: string; where: any[] }) {
|
||||
await deleteAuthRecordMutation(c, input);
|
||||
return { ok: true };
|
||||
async betterAuthDeleteRecord(c, input: { model: string; where: any[] }) {
|
||||
const table = tableFor(input.model);
|
||||
const predicate = buildWhere(table, input.where);
|
||||
if (!predicate) throw new Error("betterAuthDeleteRecord requires a where clause");
|
||||
await c.db.delete(table).where(predicate).run();
|
||||
},
|
||||
|
||||
async betterAuthDeleteManyRecords(c: any, input: { model: string; where: any[] }) {
|
||||
return await deleteManyAuthRecordsMutation(c, input);
|
||||
async betterAuthDeleteManyRecords(c, input: { model: string; where: any[] }) {
|
||||
const table = tableFor(input.model);
|
||||
const predicate = buildWhere(table, input.where);
|
||||
if (!predicate) throw new Error("betterAuthDeleteManyRecords requires a where clause");
|
||||
const rows = await c.db.select().from(table).where(predicate).all();
|
||||
await c.db.delete(table).where(predicate).run();
|
||||
return rows.length;
|
||||
},
|
||||
|
||||
// --- Read actions ---
|
||||
|
||||
// Better Auth adapter action — called by the Better Auth adapter in better-auth.ts.
|
||||
// Schema and behavior are constrained by Better Auth.
|
||||
async betterAuthFindOneRecord(c, input: { model: string; where: any[]; join?: any }) {
|
||||
const table = tableFor(input.model);
|
||||
const predicate = buildWhere(table, input.where);
|
||||
|
|
@ -55,8 +73,6 @@ export const betterAuthActions = {
|
|||
return await applyJoinToRow(c, input.model, row ?? null, input.join);
|
||||
},
|
||||
|
||||
// Better Auth adapter action — called by the Better Auth adapter in better-auth.ts.
|
||||
// Schema and behavior are constrained by Better Auth.
|
||||
async betterAuthFindManyRecords(c, input: { model: string; where?: any[]; limit?: number; offset?: number; sortBy?: any; join?: any }) {
|
||||
const table = tableFor(input.model);
|
||||
const predicate = buildWhere(table, input.where);
|
||||
|
|
@ -78,8 +94,6 @@ export const betterAuthActions = {
|
|||
return await applyJoinToRows(c, input.model, rows, input.join);
|
||||
},
|
||||
|
||||
// Better Auth adapter action — called by the Better Auth adapter in better-auth.ts.
|
||||
// Schema and behavior are constrained by Better Auth.
|
||||
async betterAuthCountRecords(c, input: { model: string; where?: any[] }) {
|
||||
const table = tableFor(input.model);
|
||||
const predicate = buildWhere(table, input.where);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { eq } from "drizzle-orm";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import { DEFAULT_WORKSPACE_MODEL_ID } from "@sandbox-agent/foundry-shared";
|
||||
import { authAccounts, authSessions, authUsers, sessionState, userProfiles, userTaskState } from "../db/schema.js";
|
||||
import { materializeRow } from "../query-helpers.js";
|
||||
|
||||
|
|
@ -41,4 +42,147 @@ export const userActions = {
|
|||
})),
|
||||
};
|
||||
},
|
||||
|
||||
// --- Mutation actions (migrated from queue) ---
|
||||
|
||||
async upsertProfile(
|
||||
c,
|
||||
input: {
|
||||
userId: string;
|
||||
patch: {
|
||||
githubAccountId?: string | null;
|
||||
githubLogin?: string | null;
|
||||
roleLabel?: string;
|
||||
defaultModel?: string;
|
||||
eligibleOrganizationIdsJson?: string;
|
||||
starterRepoStatus?: string;
|
||||
starterRepoStarredAt?: number | null;
|
||||
starterRepoSkippedAt?: number | null;
|
||||
};
|
||||
},
|
||||
) {
|
||||
const now = Date.now();
|
||||
await c.db
|
||||
.insert(userProfiles)
|
||||
.values({
|
||||
id: 1,
|
||||
userId: input.userId,
|
||||
githubAccountId: input.patch.githubAccountId ?? null,
|
||||
githubLogin: input.patch.githubLogin ?? null,
|
||||
roleLabel: input.patch.roleLabel ?? "GitHub user",
|
||||
defaultModel: input.patch.defaultModel ?? DEFAULT_WORKSPACE_MODEL_ID,
|
||||
eligibleOrganizationIdsJson: input.patch.eligibleOrganizationIdsJson ?? "[]",
|
||||
starterRepoStatus: input.patch.starterRepoStatus ?? "pending",
|
||||
starterRepoStarredAt: input.patch.starterRepoStarredAt ?? null,
|
||||
starterRepoSkippedAt: input.patch.starterRepoSkippedAt ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: userProfiles.userId,
|
||||
set: {
|
||||
...(input.patch.githubAccountId !== undefined ? { githubAccountId: input.patch.githubAccountId } : {}),
|
||||
...(input.patch.githubLogin !== undefined ? { githubLogin: input.patch.githubLogin } : {}),
|
||||
...(input.patch.roleLabel !== undefined ? { roleLabel: input.patch.roleLabel } : {}),
|
||||
...(input.patch.defaultModel !== undefined ? { defaultModel: input.patch.defaultModel } : {}),
|
||||
...(input.patch.eligibleOrganizationIdsJson !== undefined ? { eligibleOrganizationIdsJson: input.patch.eligibleOrganizationIdsJson } : {}),
|
||||
...(input.patch.starterRepoStatus !== undefined ? { starterRepoStatus: input.patch.starterRepoStatus } : {}),
|
||||
...(input.patch.starterRepoStarredAt !== undefined ? { starterRepoStarredAt: input.patch.starterRepoStarredAt } : {}),
|
||||
...(input.patch.starterRepoSkippedAt !== undefined ? { starterRepoSkippedAt: input.patch.starterRepoSkippedAt } : {}),
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
return await c.db.select().from(userProfiles).where(eq(userProfiles.userId, input.userId)).get();
|
||||
},
|
||||
|
||||
async upsertSessionState(c, input: { sessionId: string; activeOrganizationId: string | null }) {
|
||||
const now = Date.now();
|
||||
await c.db
|
||||
.insert(sessionState)
|
||||
.values({
|
||||
sessionId: input.sessionId,
|
||||
activeOrganizationId: input.activeOrganizationId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: sessionState.sessionId,
|
||||
set: { activeOrganizationId: input.activeOrganizationId, updatedAt: now },
|
||||
})
|
||||
.run();
|
||||
return await c.db.select().from(sessionState).where(eq(sessionState.sessionId, input.sessionId)).get();
|
||||
},
|
||||
|
||||
async upsertTaskState(
|
||||
c,
|
||||
input: {
|
||||
taskId: string;
|
||||
sessionId: string;
|
||||
patch: {
|
||||
activeSessionId?: string | null;
|
||||
unread?: boolean;
|
||||
draftText?: string;
|
||||
draftAttachmentsJson?: string;
|
||||
draftUpdatedAt?: number | null;
|
||||
};
|
||||
},
|
||||
) {
|
||||
const now = Date.now();
|
||||
const existing = await c.db
|
||||
.select()
|
||||
.from(userTaskState)
|
||||
.where(and(eq(userTaskState.taskId, input.taskId), eq(userTaskState.sessionId, input.sessionId)))
|
||||
.get();
|
||||
|
||||
if (input.patch.activeSessionId !== undefined) {
|
||||
await c.db
|
||||
.update(userTaskState)
|
||||
.set({ activeSessionId: input.patch.activeSessionId, updatedAt: now })
|
||||
.where(eq(userTaskState.taskId, input.taskId))
|
||||
.run();
|
||||
}
|
||||
|
||||
await c.db
|
||||
.insert(userTaskState)
|
||||
.values({
|
||||
taskId: input.taskId,
|
||||
sessionId: input.sessionId,
|
||||
activeSessionId: input.patch.activeSessionId ?? existing?.activeSessionId ?? null,
|
||||
unread: input.patch.unread !== undefined ? (input.patch.unread ? 1 : 0) : (existing?.unread ?? 0),
|
||||
draftText: input.patch.draftText ?? existing?.draftText ?? "",
|
||||
draftAttachmentsJson: input.patch.draftAttachmentsJson ?? existing?.draftAttachmentsJson ?? "[]",
|
||||
draftUpdatedAt: input.patch.draftUpdatedAt === undefined ? (existing?.draftUpdatedAt ?? null) : input.patch.draftUpdatedAt,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [userTaskState.taskId, userTaskState.sessionId],
|
||||
set: {
|
||||
...(input.patch.activeSessionId !== undefined ? { activeSessionId: input.patch.activeSessionId } : {}),
|
||||
...(input.patch.unread !== undefined ? { unread: input.patch.unread ? 1 : 0 } : {}),
|
||||
...(input.patch.draftText !== undefined ? { draftText: input.patch.draftText } : {}),
|
||||
...(input.patch.draftAttachmentsJson !== undefined ? { draftAttachmentsJson: input.patch.draftAttachmentsJson } : {}),
|
||||
...(input.patch.draftUpdatedAt !== undefined ? { draftUpdatedAt: input.patch.draftUpdatedAt } : {}),
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
|
||||
return await c.db
|
||||
.select()
|
||||
.from(userTaskState)
|
||||
.where(and(eq(userTaskState.taskId, input.taskId), eq(userTaskState.sessionId, input.sessionId)))
|
||||
.get();
|
||||
},
|
||||
|
||||
async deleteTaskState(c, input: { taskId: string; sessionId?: string }) {
|
||||
if (input.sessionId) {
|
||||
await c.db
|
||||
.delete(userTaskState)
|
||||
.where(and(eq(userTaskState.taskId, input.taskId), eq(userTaskState.sessionId, input.sessionId)))
|
||||
.run();
|
||||
return;
|
||||
}
|
||||
await c.db.delete(userTaskState).where(eq(userTaskState.taskId, input.taskId)).run();
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,13 +1,10 @@
|
|||
import { actor, queue } from "rivetkit";
|
||||
import { workflow } from "rivetkit/workflow";
|
||||
import { actor } from "rivetkit";
|
||||
import { userDb } from "./db/db.js";
|
||||
import { betterAuthActions } from "./actions/better-auth.js";
|
||||
import { userActions } from "./actions/user.js";
|
||||
import { USER_QUEUE_NAMES, runUserWorkflow } from "./workflow.js";
|
||||
|
||||
export const user = actor({
|
||||
db: userDb,
|
||||
queues: Object.fromEntries(USER_QUEUE_NAMES.map((name) => [name, queue()])),
|
||||
options: {
|
||||
name: "User",
|
||||
icon: "shield",
|
||||
|
|
@ -20,5 +17,4 @@ export const user = actor({
|
|||
...betterAuthActions,
|
||||
...userActions,
|
||||
},
|
||||
run: workflow(runUserWorkflow),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,284 +0,0 @@
|
|||
// @ts-nocheck
|
||||
/**
|
||||
* User workflow — queue-based command loop.
|
||||
*
|
||||
* Auth mutation commands are dispatched through named queues and processed
|
||||
* inside the workflow command loop for observability and replay semantics.
|
||||
*/
|
||||
import { eq, count as sqlCount, and } from "drizzle-orm";
|
||||
import { Loop } from "rivetkit/workflow";
|
||||
import { DEFAULT_WORKSPACE_MODEL_ID } from "@sandbox-agent/foundry-shared";
|
||||
import { logActorWarning, resolveErrorMessage } from "../logging.js";
|
||||
import { selfUser } from "../handles.js";
|
||||
import { expectQueueResponse } from "../../services/queue.js";
|
||||
import { authUsers, sessionState, userProfiles, userTaskState } from "./db/schema.js";
|
||||
import { buildWhere, columnFor, materializeRow, persistInput, persistPatch, tableFor } from "./query-helpers.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Queue names
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const USER_QUEUE_NAMES = [
|
||||
"user.command.profile.upsert",
|
||||
"user.command.session_state.upsert",
|
||||
"user.command.task_state.upsert",
|
||||
"user.command.task_state.delete",
|
||||
] as const;
|
||||
|
||||
export type UserQueueName = (typeof USER_QUEUE_NAMES)[number];
|
||||
|
||||
export function userWorkflowQueueName(name: UserQueueName): UserQueueName {
|
||||
return name;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mutation functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function createAuthRecordMutation(c: any, input: { model: string; data: Record<string, unknown> }) {
|
||||
const table = tableFor(input.model);
|
||||
const persisted = persistInput(input.model, input.data);
|
||||
await c.db
|
||||
.insert(table)
|
||||
.values(persisted as any)
|
||||
.run();
|
||||
const row = await c.db
|
||||
.select()
|
||||
.from(table)
|
||||
.where(eq(columnFor(input.model, table, "id"), input.data.id as any))
|
||||
.get();
|
||||
return materializeRow(input.model, row);
|
||||
}
|
||||
|
||||
export async function updateAuthRecordMutation(c: any, input: { model: string; where: any[]; update: Record<string, unknown> }) {
|
||||
const table = tableFor(input.model);
|
||||
const predicate = buildWhere(table, input.where);
|
||||
if (!predicate) throw new Error("updateAuthRecord requires a where clause");
|
||||
await c.db
|
||||
.update(table)
|
||||
.set(persistPatch(input.model, input.update) as any)
|
||||
.where(predicate)
|
||||
.run();
|
||||
return materializeRow(input.model, await c.db.select().from(table).where(predicate).get());
|
||||
}
|
||||
|
||||
export async function updateManyAuthRecordsMutation(c: any, input: { model: string; where: any[]; update: Record<string, unknown> }) {
|
||||
const table = tableFor(input.model);
|
||||
const predicate = buildWhere(table, input.where);
|
||||
if (!predicate) throw new Error("updateManyAuthRecords requires a where clause");
|
||||
await c.db
|
||||
.update(table)
|
||||
.set(persistPatch(input.model, input.update) as any)
|
||||
.where(predicate)
|
||||
.run();
|
||||
const row = await c.db.select({ value: sqlCount() }).from(table).where(predicate).get();
|
||||
return row?.value ?? 0;
|
||||
}
|
||||
|
||||
export async function deleteAuthRecordMutation(c: any, input: { model: string; where: any[] }) {
|
||||
const table = tableFor(input.model);
|
||||
const predicate = buildWhere(table, input.where);
|
||||
if (!predicate) throw new Error("deleteAuthRecord requires a where clause");
|
||||
await c.db.delete(table).where(predicate).run();
|
||||
}
|
||||
|
||||
export async function deleteManyAuthRecordsMutation(c: any, input: { model: string; where: any[] }) {
|
||||
const table = tableFor(input.model);
|
||||
const predicate = buildWhere(table, input.where);
|
||||
if (!predicate) throw new Error("deleteManyAuthRecords requires a where clause");
|
||||
const rows = await c.db.select().from(table).where(predicate).all();
|
||||
await c.db.delete(table).where(predicate).run();
|
||||
return rows.length;
|
||||
}
|
||||
|
||||
export async function upsertUserProfileMutation(
|
||||
c: any,
|
||||
input: {
|
||||
userId: string;
|
||||
patch: {
|
||||
githubAccountId?: string | null;
|
||||
githubLogin?: string | null;
|
||||
roleLabel?: string;
|
||||
defaultModel?: string;
|
||||
eligibleOrganizationIdsJson?: string;
|
||||
starterRepoStatus?: string;
|
||||
starterRepoStarredAt?: number | null;
|
||||
starterRepoSkippedAt?: number | null;
|
||||
};
|
||||
},
|
||||
) {
|
||||
const now = Date.now();
|
||||
await c.db
|
||||
.insert(userProfiles)
|
||||
.values({
|
||||
id: 1,
|
||||
userId: input.userId,
|
||||
githubAccountId: input.patch.githubAccountId ?? null,
|
||||
githubLogin: input.patch.githubLogin ?? null,
|
||||
roleLabel: input.patch.roleLabel ?? "GitHub user",
|
||||
defaultModel: input.patch.defaultModel ?? DEFAULT_WORKSPACE_MODEL_ID,
|
||||
eligibleOrganizationIdsJson: input.patch.eligibleOrganizationIdsJson ?? "[]",
|
||||
starterRepoStatus: input.patch.starterRepoStatus ?? "pending",
|
||||
starterRepoStarredAt: input.patch.starterRepoStarredAt ?? null,
|
||||
starterRepoSkippedAt: input.patch.starterRepoSkippedAt ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: userProfiles.userId,
|
||||
set: {
|
||||
...(input.patch.githubAccountId !== undefined ? { githubAccountId: input.patch.githubAccountId } : {}),
|
||||
...(input.patch.githubLogin !== undefined ? { githubLogin: input.patch.githubLogin } : {}),
|
||||
...(input.patch.roleLabel !== undefined ? { roleLabel: input.patch.roleLabel } : {}),
|
||||
...(input.patch.defaultModel !== undefined ? { defaultModel: input.patch.defaultModel } : {}),
|
||||
...(input.patch.eligibleOrganizationIdsJson !== undefined ? { eligibleOrganizationIdsJson: input.patch.eligibleOrganizationIdsJson } : {}),
|
||||
...(input.patch.starterRepoStatus !== undefined ? { starterRepoStatus: input.patch.starterRepoStatus } : {}),
|
||||
...(input.patch.starterRepoStarredAt !== undefined ? { starterRepoStarredAt: input.patch.starterRepoStarredAt } : {}),
|
||||
...(input.patch.starterRepoSkippedAt !== undefined ? { starterRepoSkippedAt: input.patch.starterRepoSkippedAt } : {}),
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
return await c.db.select().from(userProfiles).where(eq(userProfiles.userId, input.userId)).get();
|
||||
}
|
||||
|
||||
export async function upsertSessionStateMutation(c: any, input: { sessionId: string; activeOrganizationId: string | null }) {
|
||||
const now = Date.now();
|
||||
await c.db
|
||||
.insert(sessionState)
|
||||
.values({
|
||||
sessionId: input.sessionId,
|
||||
activeOrganizationId: input.activeOrganizationId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: sessionState.sessionId,
|
||||
set: { activeOrganizationId: input.activeOrganizationId, updatedAt: now },
|
||||
})
|
||||
.run();
|
||||
return await c.db.select().from(sessionState).where(eq(sessionState.sessionId, input.sessionId)).get();
|
||||
}
|
||||
|
||||
export async function upsertTaskStateMutation(
|
||||
c: any,
|
||||
input: {
|
||||
taskId: string;
|
||||
sessionId: string;
|
||||
patch: {
|
||||
activeSessionId?: string | null;
|
||||
unread?: boolean;
|
||||
draftText?: string;
|
||||
draftAttachmentsJson?: string;
|
||||
draftUpdatedAt?: number | null;
|
||||
};
|
||||
},
|
||||
) {
|
||||
const now = Date.now();
|
||||
const existing = await c.db
|
||||
.select()
|
||||
.from(userTaskState)
|
||||
.where(and(eq(userTaskState.taskId, input.taskId), eq(userTaskState.sessionId, input.sessionId)))
|
||||
.get();
|
||||
|
||||
if (input.patch.activeSessionId !== undefined) {
|
||||
await c.db.update(userTaskState).set({ activeSessionId: input.patch.activeSessionId, updatedAt: now }).where(eq(userTaskState.taskId, input.taskId)).run();
|
||||
}
|
||||
|
||||
await c.db
|
||||
.insert(userTaskState)
|
||||
.values({
|
||||
taskId: input.taskId,
|
||||
sessionId: input.sessionId,
|
||||
activeSessionId: input.patch.activeSessionId ?? existing?.activeSessionId ?? null,
|
||||
unread: input.patch.unread !== undefined ? (input.patch.unread ? 1 : 0) : (existing?.unread ?? 0),
|
||||
draftText: input.patch.draftText ?? existing?.draftText ?? "",
|
||||
draftAttachmentsJson: input.patch.draftAttachmentsJson ?? existing?.draftAttachmentsJson ?? "[]",
|
||||
draftUpdatedAt: input.patch.draftUpdatedAt === undefined ? (existing?.draftUpdatedAt ?? null) : input.patch.draftUpdatedAt,
|
||||
updatedAt: now,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [userTaskState.taskId, userTaskState.sessionId],
|
||||
set: {
|
||||
...(input.patch.activeSessionId !== undefined ? { activeSessionId: input.patch.activeSessionId } : {}),
|
||||
...(input.patch.unread !== undefined ? { unread: input.patch.unread ? 1 : 0 } : {}),
|
||||
...(input.patch.draftText !== undefined ? { draftText: input.patch.draftText } : {}),
|
||||
...(input.patch.draftAttachmentsJson !== undefined ? { draftAttachmentsJson: input.patch.draftAttachmentsJson } : {}),
|
||||
...(input.patch.draftUpdatedAt !== undefined ? { draftUpdatedAt: input.patch.draftUpdatedAt } : {}),
|
||||
updatedAt: now,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
|
||||
return await c.db
|
||||
.select()
|
||||
.from(userTaskState)
|
||||
.where(and(eq(userTaskState.taskId, input.taskId), eq(userTaskState.sessionId, input.sessionId)))
|
||||
.get();
|
||||
}
|
||||
|
||||
export async function deleteTaskStateMutation(c: any, input: { taskId: string; sessionId?: string }) {
|
||||
if (input.sessionId) {
|
||||
await c.db
|
||||
.delete(userTaskState)
|
||||
.where(and(eq(userTaskState.taskId, input.taskId), eq(userTaskState.sessionId, input.sessionId)))
|
||||
.run();
|
||||
return;
|
||||
}
|
||||
await c.db.delete(userTaskState).where(eq(userTaskState.taskId, input.taskId)).run();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Workflow command loop
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type WorkflowHandler = (loopCtx: any, body: any) => Promise<any>;
|
||||
|
||||
const COMMAND_HANDLERS: Record<UserQueueName, WorkflowHandler> = {
|
||||
"user.command.profile.upsert": async (c, body) => upsertUserProfileMutation(c, body),
|
||||
"user.command.session_state.upsert": async (c, body) => upsertSessionStateMutation(c, body),
|
||||
"user.command.task_state.upsert": async (c, body) => upsertTaskStateMutation(c, body),
|
||||
"user.command.task_state.delete": async (c, body) => {
|
||||
await deleteTaskStateMutation(c, body);
|
||||
return { ok: true };
|
||||
},
|
||||
};
|
||||
|
||||
export async function runUserWorkflow(ctx: any): Promise<void> {
|
||||
await ctx.loop("user-command-loop", async (loopCtx: any) => {
|
||||
const msg = await loopCtx.queue.next("next-user-command", {
|
||||
names: [...USER_QUEUE_NAMES],
|
||||
completable: true,
|
||||
});
|
||||
|
||||
if (!msg) {
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
|
||||
const handler = COMMAND_HANDLERS[msg.name as UserQueueName];
|
||||
if (!handler) {
|
||||
logActorWarning("user", "unknown user command", { command: msg.name });
|
||||
await msg.complete({ error: `Unknown command: ${msg.name}` }).catch(() => {});
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
|
||||
try {
|
||||
// Wrap in a step so c.state and c.db are accessible inside mutation functions.
|
||||
const result = await loopCtx.step({
|
||||
name: msg.name,
|
||||
timeout: 60_000,
|
||||
run: async () => handler(loopCtx, msg.body),
|
||||
});
|
||||
await msg.complete(result);
|
||||
} catch (error) {
|
||||
const message = resolveErrorMessage(error);
|
||||
logActorWarning("user", "user workflow command failed", {
|
||||
command: msg.name,
|
||||
error: message,
|
||||
});
|
||||
await msg.complete({ error: message }).catch(() => {});
|
||||
}
|
||||
|
||||
return Loop.continue(undefined);
|
||||
});
|
||||
}
|
||||
|
|
@ -41,11 +41,6 @@ export interface GitHubRepositoryRecord {
|
|||
defaultBranch: string;
|
||||
}
|
||||
|
||||
export interface GitHubBranchRecord {
|
||||
name: string;
|
||||
commitSha: string;
|
||||
}
|
||||
|
||||
export interface GitHubMemberRecord {
|
||||
id: string;
|
||||
login: string;
|
||||
|
|
@ -402,15 +397,6 @@ export class GitHubAppClient {
|
|||
return await this.getUserRepository(accessToken, fullName);
|
||||
}
|
||||
|
||||
async listUserRepositoryBranches(accessToken: string, fullName: string): Promise<GitHubBranchRecord[]> {
|
||||
return await this.listRepositoryBranches(accessToken, fullName);
|
||||
}
|
||||
|
||||
async listInstallationRepositoryBranches(installationId: number, fullName: string): Promise<GitHubBranchRecord[]> {
|
||||
const accessToken = await this.createInstallationAccessToken(installationId);
|
||||
return await this.listRepositoryBranches(accessToken, fullName);
|
||||
}
|
||||
|
||||
async listOrganizationMembers(accessToken: string, organizationLogin: string): Promise<GitHubMemberRecord[]> {
|
||||
const members = await this.paginate<{
|
||||
id: number;
|
||||
|
|
@ -708,20 +694,6 @@ export class GitHubAppClient {
|
|||
nextUrl: parseNextLink(response.headers.get("link")),
|
||||
};
|
||||
}
|
||||
|
||||
private async listRepositoryBranches(accessToken: string, fullName: string): Promise<GitHubBranchRecord[]> {
|
||||
const branches = await this.paginate<{
|
||||
name: string;
|
||||
commit?: { sha?: string | null } | null;
|
||||
}>(`/repos/${fullName}/branches?per_page=100`, accessToken);
|
||||
|
||||
return branches
|
||||
.map((branch) => ({
|
||||
name: branch.name?.trim() ?? "",
|
||||
commitSha: branch.commit?.sha?.trim() ?? "",
|
||||
}))
|
||||
.filter((branch) => branch.name.length > 0 && branch.commitSha.length > 0);
|
||||
}
|
||||
}
|
||||
|
||||
function parseNextLink(linkHeader: string | null): string | null {
|
||||
|
|
|
|||
|
|
@ -3,8 +3,6 @@ import { createAdapterFactory } from "better-auth/adapters";
|
|||
import { APP_SHELL_ORGANIZATION_ID } from "../actors/organization/constants.js";
|
||||
import { organizationKey, userKey } from "../actors/keys.js";
|
||||
import { logger } from "../logging.js";
|
||||
import { expectQueueResponse } from "./queue.js";
|
||||
import { userWorkflowQueueName } from "../actors/user/workflow.js";
|
||||
|
||||
const AUTH_BASE_PATH = "/v1/auth";
|
||||
const SESSION_COOKIE = "better-auth.session_token";
|
||||
|
|
@ -79,33 +77,17 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
|
|||
// getOrCreate is intentional here: the adapter runs during Better Auth callbacks
|
||||
// which can fire before any explicit create path. The app organization and user
|
||||
// actors must exist by the time the adapter needs them.
|
||||
//
|
||||
// Handles are cached to avoid redundant getOrCreate RPCs during a single OAuth
|
||||
// callback (which calls the adapter 5-10+ times). The RivetKit handle is a
|
||||
// lightweight proxy; caching it just avoids repeated gateway round-trips.
|
||||
let cachedAppOrganization: any = null;
|
||||
const appOrganization = async () => {
|
||||
if (!cachedAppOrganization) {
|
||||
cachedAppOrganization = await actorClient.organization.getOrCreate(organizationKey(APP_SHELL_ORGANIZATION_ID), {
|
||||
createWithInput: APP_SHELL_ORGANIZATION_ID,
|
||||
});
|
||||
}
|
||||
return cachedAppOrganization;
|
||||
};
|
||||
const appOrganization = () =>
|
||||
actorClient.organization.getOrCreate(organizationKey(APP_SHELL_ORGANIZATION_ID), {
|
||||
createWithInput: APP_SHELL_ORGANIZATION_ID,
|
||||
});
|
||||
|
||||
// getOrCreate is intentional: Better Auth creates user records during OAuth
|
||||
// callbacks, so the user actor must be lazily provisioned on first access.
|
||||
const userHandleCache = new Map<string, any>();
|
||||
const getUser = async (userId: string) => {
|
||||
let handle = userHandleCache.get(userId);
|
||||
if (!handle) {
|
||||
handle = await actorClient.user.getOrCreate(userKey(userId), {
|
||||
createWithInput: { userId },
|
||||
});
|
||||
userHandleCache.set(userId, handle);
|
||||
}
|
||||
return handle;
|
||||
};
|
||||
const getUser = async (userId: string) =>
|
||||
await actorClient.user.getOrCreate(userKey(userId), {
|
||||
createWithInput: { userId },
|
||||
});
|
||||
|
||||
const adapter = createAdapterFactory({
|
||||
config: {
|
||||
|
|
@ -183,91 +165,51 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
|
|||
create: async ({ model, data }) => {
|
||||
const transformed = await transformInput(data, model, "create", true);
|
||||
if (model === "verification") {
|
||||
const start = performance.now();
|
||||
try {
|
||||
const organization = await appOrganization();
|
||||
const result = await organization.betterAuthCreateVerification({ data: transformed });
|
||||
logger.info(
|
||||
{ model, identifier: transformed.identifier, durationMs: Math.round((performance.now() - start) * 100) / 100 },
|
||||
"auth_adapter_create_verification",
|
||||
);
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ model, identifier: transformed.identifier, durationMs: Math.round((performance.now() - start) * 100) / 100, error: String(error) },
|
||||
"auth_adapter_create_verification_error",
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
const organization = await appOrganization();
|
||||
return await organization.betterAuthCreateVerification({ data: transformed });
|
||||
}
|
||||
|
||||
const createStart = performance.now();
|
||||
const userId = await resolveUserIdForQuery(model, undefined, transformed);
|
||||
if (!userId) {
|
||||
throw new Error(`Unable to resolve auth actor for create(${model})`);
|
||||
}
|
||||
|
||||
try {
|
||||
const userActor = await getUser(userId);
|
||||
const created = await userActor.betterAuthCreateRecord({ model, data: transformed });
|
||||
const organization = await appOrganization();
|
||||
const userActor = await getUser(userId);
|
||||
const created = await userActor.betterAuthCreateRecord({ model, data: transformed });
|
||||
const organization = await appOrganization();
|
||||
|
||||
if (model === "user" && typeof transformed.email === "string" && transformed.email.length > 0) {
|
||||
await organization.betterAuthUpsertEmailIndex({
|
||||
email: transformed.email.toLowerCase(),
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
if (model === "session") {
|
||||
await organization.betterAuthUpsertSessionIndex({
|
||||
sessionId: String(created.id),
|
||||
sessionToken: String(created.token),
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
if (model === "account") {
|
||||
await organization.betterAuthUpsertAccountIndex({
|
||||
id: String(created.id),
|
||||
providerId: String(created.providerId),
|
||||
accountId: String(created.accountId),
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
logger.info({ model, userId, durationMs: Math.round((performance.now() - createStart) * 100) / 100 }, "auth_adapter_create_record");
|
||||
return (await transformOutput(created, model)) as any;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ model, userId, durationMs: Math.round((performance.now() - createStart) * 100) / 100, error: String(error) },
|
||||
"auth_adapter_create_record_error",
|
||||
);
|
||||
throw error;
|
||||
if (model === "user" && typeof transformed.email === "string" && transformed.email.length > 0) {
|
||||
await organization.betterAuthUpsertEmailIndex({
|
||||
email: transformed.email.toLowerCase(),
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
if (model === "session") {
|
||||
await organization.betterAuthUpsertSessionIndex({
|
||||
sessionId: String(created.id),
|
||||
sessionToken: String(created.token),
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
if (model === "account") {
|
||||
await organization.betterAuthUpsertAccountIndex({
|
||||
id: String(created.id),
|
||||
providerId: String(created.providerId),
|
||||
accountId: String(created.accountId),
|
||||
userId,
|
||||
});
|
||||
}
|
||||
|
||||
return (await transformOutput(created, model)) as any;
|
||||
},
|
||||
|
||||
findOne: async ({ model, where, join }) => {
|
||||
const transformedWhere = transformWhereClause({ model, where, action: "findOne" });
|
||||
if (model === "verification") {
|
||||
const start = performance.now();
|
||||
try {
|
||||
const organization = await appOrganization();
|
||||
const result = await organization.betterAuthFindOneVerification({ where: transformedWhere, join });
|
||||
const identifier = transformedWhere?.find((entry: any) => entry.field === "identifier")?.value;
|
||||
logger.info(
|
||||
{ model, identifier, found: !!result, durationMs: Math.round((performance.now() - start) * 100) / 100 },
|
||||
"auth_adapter_find_verification",
|
||||
);
|
||||
return result;
|
||||
} catch (error) {
|
||||
const identifier = transformedWhere?.find((entry: any) => entry.field === "identifier")?.value;
|
||||
logger.error(
|
||||
{ model, identifier, durationMs: Math.round((performance.now() - start) * 100) / 100, error: String(error) },
|
||||
"auth_adapter_find_verification_error",
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
const organization = await appOrganization();
|
||||
return await organization.betterAuthFindOneVerification({ where: transformedWhere, join });
|
||||
}
|
||||
|
||||
const userId = await resolveUserIdForQuery(model, transformedWhere);
|
||||
|
|
@ -429,8 +371,6 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
|
|||
delete: async ({ model, where }) => {
|
||||
const transformedWhere = transformWhereClause({ model, where, action: "delete" });
|
||||
if (model === "verification") {
|
||||
const identifier = transformedWhere?.find((entry: any) => entry.field === "identifier")?.value;
|
||||
logger.info({ model, identifier }, "auth_adapter_delete_verification");
|
||||
const organization = await appOrganization();
|
||||
await organization.betterAuthDeleteVerification({ where: transformedWhere });
|
||||
return;
|
||||
|
|
@ -527,15 +467,6 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
|
|||
secret: requireEnv("BETTER_AUTH_SECRET"),
|
||||
database: adapter,
|
||||
trustedOrigins: [stripTrailingSlash(options.appUrl), stripTrailingSlash(options.apiUrl)],
|
||||
account: {
|
||||
// Store OAuth state in an encrypted cookie instead of a DB verification record.
|
||||
// The production proxy chain (Cloudflare -> Fastly -> Railway) retries the OAuth
|
||||
// callback when it takes >10s, causing a duplicate request. With the "database"
|
||||
// strategy the first request deletes the verification record, so the retry fails
|
||||
// with "verification not found" -> ?error=please_restart_the_process.
|
||||
// Cookie strategy avoids this because the state lives in the request itself.
|
||||
storeStateStrategy: "cookie",
|
||||
},
|
||||
session: {
|
||||
cookieCache: {
|
||||
enabled: true,
|
||||
|
|
@ -582,9 +513,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
|
|||
|
||||
async upsertUserProfile(userId: string, patch: Record<string, unknown>) {
|
||||
const userActor = await getUser(userId);
|
||||
return expectQueueResponse(
|
||||
await userActor.send(userWorkflowQueueName("user.command.profile.upsert"), { userId, patch }, { wait: true, timeout: 10_000 }),
|
||||
);
|
||||
return await userActor.upsertProfile({ userId, patch });
|
||||
},
|
||||
|
||||
async setActiveOrganization(sessionId: string, activeOrganizationId: string | null) {
|
||||
|
|
@ -593,9 +522,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin
|
|||
throw new Error(`Unknown auth session ${sessionId}`);
|
||||
}
|
||||
const userActor = await getUser(authState.user.id);
|
||||
return expectQueueResponse(
|
||||
await userActor.send(userWorkflowQueueName("user.command.session_state.upsert"), { sessionId, activeOrganizationId }, { wait: true, timeout: 10_000 }),
|
||||
);
|
||||
return await userActor.upsertSessionState({ sessionId, activeOrganizationId });
|
||||
},
|
||||
|
||||
async getAccessTokenForSession(sessionId: string) {
|
||||
|
|
|
|||
|
|
@ -204,6 +204,7 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back
|
|||
sandboxProviderId: "local",
|
||||
sandboxId: task.id,
|
||||
cwd: mockCwd(task.repoName, task.id),
|
||||
url: null,
|
||||
},
|
||||
],
|
||||
activeSandboxId: task.id,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
FilePlus,
|
||||
FileX,
|
||||
FolderOpen,
|
||||
ExternalLink,
|
||||
GitBranch,
|
||||
GitPullRequest,
|
||||
PanelRight,
|
||||
|
|
@ -128,7 +129,7 @@ export const RightSidebar = memo(function RightSidebar({
|
|||
}) {
|
||||
const [css] = useStyletron();
|
||||
const t = useFoundryTokens();
|
||||
const [rightTab, setRightTab] = useState<"overview" | "changes" | "files">("changes");
|
||||
const [rightTab, setRightTab] = useState<"overview" | "changes" | "files">("overview");
|
||||
const contextMenu = useContextMenu();
|
||||
const changedPaths = useMemo(() => new Set(task.fileChanges.map((file) => file.path)), [task.fileChanges]);
|
||||
const isTerminal = task.status === "archived";
|
||||
|
|
@ -645,6 +646,39 @@ export const RightSidebar = memo(function RightSidebar({
|
|||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{task.sandboxes?.find((s) => s.sandboxId === task.activeSandboxId)?.url ? (
|
||||
<div className={css({ display: "flex", flexDirection: "column", gap: "8px" })}>
|
||||
<LabelXSmall color={t.textTertiary} $style={{ textTransform: "uppercase", letterSpacing: "0.5px", fontWeight: 600 }}>
|
||||
Sandbox
|
||||
</LabelXSmall>
|
||||
<a
|
||||
href={task.sandboxes.find((s) => s.sandboxId === task.activeSandboxId)!.url!}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={css({
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: "8px",
|
||||
color: t.textSecondary,
|
||||
textDecoration: "none",
|
||||
borderRadius: "6px",
|
||||
paddingTop: "4px",
|
||||
paddingRight: "8px",
|
||||
paddingBottom: "4px",
|
||||
paddingLeft: "4px",
|
||||
":hover": { backgroundColor: t.interactiveHover, color: t.textPrimary },
|
||||
})}
|
||||
>
|
||||
<ExternalLink size={14} color={t.textTertiary} style={{ flexShrink: 0 }} />
|
||||
<LabelSmall
|
||||
color="inherit"
|
||||
$style={{ fontFamily: '"IBM Plex Mono", monospace', overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
|
||||
>
|
||||
{task.sandboxes.find((s) => s.sandboxId === task.activeSandboxId)!.url!}
|
||||
</LabelSmall>
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : rightTab === "changes" ? (
|
||||
<div className={css({ padding: "10px 14px", display: "flex", flexDirection: "column", gap: "2px" })}>
|
||||
|
|
|
|||
|
|
@ -135,7 +135,7 @@ export type ListTasksInput = z.infer<typeof ListTasksInputSchema>;
|
|||
|
||||
export const RepoBranchRecordSchema = z.object({
|
||||
branchName: z.string().min(1),
|
||||
commitSha: z.string().min(1),
|
||||
commitSha: z.string(),
|
||||
taskId: z.string().nullable(),
|
||||
taskTitle: z.string().nullable(),
|
||||
taskStatus: TaskStatusSchema.nullable(),
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@ export interface WorkspaceSandboxSummary {
|
|||
sandboxProviderId: SandboxProviderId;
|
||||
sandboxId: string;
|
||||
cwd: string | null;
|
||||
url: string | null;
|
||||
}
|
||||
|
||||
/** Sidebar-level task data. Materialized in the organization actor's SQLite. */
|
||||
|
|
@ -221,6 +222,7 @@ export interface WorkspaceTask {
|
|||
diffs: Record<string, string>;
|
||||
fileTree: WorkspaceFileTreeNode[];
|
||||
minutesUsed: number;
|
||||
sandboxes?: WorkspaceSandboxSummary[];
|
||||
activeSandboxId?: string | null;
|
||||
/** GitHub login of the current primary user (task owner). */
|
||||
primaryUserLogin?: string | null;
|
||||
|
|
|
|||
65
foundry/scripts/publish-foundry-base.sh
Executable file
65
foundry/scripts/publish-foundry-base.sh
Executable file
|
|
@ -0,0 +1,65 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Build and push the Foundry base sandbox image to Docker Hub.
|
||||
#
|
||||
# Usage:
|
||||
# ./foundry/scripts/publish-foundry-base.sh # build + push
|
||||
# ./foundry/scripts/publish-foundry-base.sh --dry-run # build only, no push
|
||||
#
|
||||
# Prerequisites:
|
||||
# - docker login to Docker Hub (rivetdev org)
|
||||
# - Docker buildx available (ships with Docker Desktop / modern Docker)
|
||||
#
|
||||
# The image is tagged:
|
||||
# rivetdev/sandbox-agent:foundry-base-<YYYYMMDD>T<HHMMSS>Z
|
||||
# rivetdev/sandbox-agent:foundry-base-latest
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||
IMAGE="rivetdev/sandbox-agent"
|
||||
TIMESTAMP="$(date -u '+%Y%m%dT%H%M%SZ')"
|
||||
TAG_DATED="${IMAGE}:foundry-base-${TIMESTAMP}"
|
||||
TAG_LATEST="${IMAGE}:foundry-base-latest"
|
||||
DRY_RUN=false
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--dry-run) DRY_RUN=true ;;
|
||||
*) echo "Unknown argument: $arg" >&2; exit 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "==> Building ${TAG_DATED}"
|
||||
echo " (also tagged ${TAG_LATEST})"
|
||||
echo " Platform: linux/amd64"
|
||||
echo ""
|
||||
|
||||
docker build \
|
||||
--platform linux/amd64 \
|
||||
-f "${REPO_ROOT}/foundry/docker/foundry-base.Dockerfile" \
|
||||
-t "${TAG_DATED}" \
|
||||
-t "${TAG_LATEST}" \
|
||||
"${REPO_ROOT}"
|
||||
|
||||
echo ""
|
||||
echo "==> Build complete"
|
||||
echo " ${TAG_DATED}"
|
||||
echo " ${TAG_LATEST}"
|
||||
|
||||
if [ "$DRY_RUN" = true ]; then
|
||||
echo ""
|
||||
echo "==> Dry run — skipping push"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "==> Pushing ${TAG_DATED}"
|
||||
docker push "${TAG_DATED}"
|
||||
|
||||
echo "==> Pushing ${TAG_LATEST}"
|
||||
docker push "${TAG_LATEST}"
|
||||
|
||||
echo ""
|
||||
echo "==> Done"
|
||||
echo " ${TAG_DATED}"
|
||||
Loading…
Add table
Add a link
Reference in a new issue