mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 06:04:43 +00:00
Refactor Foundry GitHub state and sandbox runtime (#247)
* Move Foundry HTTP APIs out of /api/rivet
* Move Foundry HTTP APIs onto /v1
* Fix Foundry Rivet base path and frontend endpoint fallback
* Configure Foundry Rivet runner pool for /v1
* Remove Foundry Rivet runner override
* Serve Foundry Rivet routes directly from Bun
* Log Foundry RivetKit deployment friction
* Add actor display metadata
* Tighten actor schema constraints
* Reset actor persistence baseline
* Remove temporary actor key version prefix
Railway has no persistent volumes so stale actors are wiped on
each deploy. The v2 key rotation is no longer needed.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Cache app workspace actor handle across requests
Every request was calling getOrCreate on the Rivet engine API
to resolve the workspace actor, even though it's always the same
actor. Cache the handle and invalidate on error so retries
re-resolve. This eliminates redundant cross-region round-trips
to api.rivet.dev on every request.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Add temporary debug logging to GitHub OAuth exchange
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Make squashed baseline migrations idempotent
Use CREATE TABLE IF NOT EXISTS and CREATE UNIQUE INDEX IF NOT
EXISTS so the squashed baseline can run against actors that
already have tables from the pre-squash migration sequence.
This fixes the "table already exists" error when org workspace
actors wake up with stale migration journals.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Revert "Make squashed baseline migrations idempotent"
This reverts commit 356c146035.
* Fix GitHub OAuth callback by removing retry wrapper
OAuth authorization codes are single-use. The appWorkspaceAction wrapper
retries failed calls up to 20 times, but if the code exchange succeeds
and a later step fails, every retry sends the already-consumed code,
producing "bad_verification_code" from GitHub.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Add runner versioning to RivetKit registry
Uses Date.now() so each process start gets a unique version.
This ensures Rivet Cloud migrates actors to the new runner on
deploy instead of routing requests to stale runners.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Add backend request and workspace logging
* Log callback request headers
* Make GitHub OAuth callback idempotent against duplicate requests
Clear oauthState before exchangeCode so duplicate callback requests
fail the state check instead of hitting GitHub with a consumed code.
Marked as HACK — root cause of duplicate HTTP requests is unknown.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Add temporary header dump on GitHub OAuth callback
Log all request headers on the callback endpoint to diagnose
the source of duplicate requests (Railway proxy, Cloudflare, browser).
Remove once root cause is identified.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* Defer slow GitHub org sync to workflow queue for fast OAuth callback
Split syncGithubSessionFromToken into a fast path (initGithubSession:
exchange code, get viewer, store token+identity) and a slow path
(syncGithubOrganizations: list orgs/installations, sync workspaces).
completeAppGithubAuth now returns the 302 redirect in ~2s instead of
~18s by enqueuing the org sync to the workspace workflow queue
(fire-and-forget). This eliminates the proxy timeout window that was
causing duplicate callback requests.
bootstrapAppGithubSession (dev-only) still calls the full synchronous
sync since proxy timeouts are not a concern and it needs the session
fully populated before returning.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* foundry: async app repo import on org select
* foundry: parallelize app snapshot org reads
* repo: push all current workspace changes
* foundry: update runner version and snapshot logging
* Refactor Foundry GitHub state and sandbox runtime
Refactors Foundry around organization/repository ownership and adds an organization-scoped GitHub state actor plus a user-scoped GitHub auth actor, removing the old project PR/branch sync actors and repo PR cache.
Updates sandbox provisioning to rely on sandbox-agent for in-sandbox work, hardens Daytona startup and image-build behavior, and surfaces runtime and task-startup errors more clearly in the UI.
Extends workbench and GitHub state handling to track merged PR state, adds runtime-issue tracking, refreshes client/test/config wiring, and documents the main live Foundry test flow plus actor coordination rules.
Also updates the remaining Sandbox Agent install-version references in docs/examples to the current pinned minor channel.
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
436eb4a3a3
commit
ae191d1ae1
102 changed files with 3490 additions and 2003 deletions
|
|
@ -111,6 +111,7 @@
|
|||
|
||||
## Change Tracking
|
||||
|
||||
- If the user asks to "push" changes, treat that as permission to commit and push all current workspace changes, not a hand-picked subset, unless the user explicitly scopes the push.
|
||||
- Keep CLI subcommands and HTTP endpoints in sync.
|
||||
- Update `docs/cli.mdx` when CLI behavior changes.
|
||||
- Regenerate `docs/openapi.json` when HTTP contracts change.
|
||||
|
|
|
|||
|
|
@ -36,9 +36,9 @@ That recipe sets `NODE_ENV=development`, which enables the dotenv loader.
|
|||
These values can be safely defaulted for local development:
|
||||
|
||||
- `APP_URL=http://localhost:4173`
|
||||
- `BETTER_AUTH_URL=http://localhost:4173`
|
||||
- `BETTER_AUTH_URL=http://localhost:7741`
|
||||
- `BETTER_AUTH_SECRET=sandbox-agent-foundry-development-only-change-me`
|
||||
- `GITHUB_REDIRECT_URI=http://localhost:4173/api/rivet/app/auth/github/callback`
|
||||
- `GITHUB_REDIRECT_URI=http://localhost:7741/v1/auth/github/callback`
|
||||
|
||||
These should be treated as development-only values.
|
||||
|
||||
|
|
@ -90,7 +90,7 @@ Recommended GitHub App permissions:
|
|||
- Repository `Checks: Read`
|
||||
- Repository `Commit statuses: Read`
|
||||
|
||||
Set the webhook URL to `https://<your-backend-host>/api/rivet/app/webhooks/github` and generate a webhook secret. Store the secret as `GITHUB_WEBHOOK_SECRET`.
|
||||
Set the webhook URL to `https://<your-backend-host>/v1/webhooks/github` and generate a webhook secret. Store the secret as `GITHUB_WEBHOOK_SECRET`.
|
||||
|
||||
Recommended webhook subscriptions:
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,5 @@
|
|||
# Project Instructions
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
Do not preserve legacy compatibility. Implement the best current architecture, even if breaking.
|
||||
|
||||
## Language Policy
|
||||
|
||||
Use TypeScript for all source code.
|
||||
|
|
@ -48,16 +44,18 @@ Use `pnpm` workspaces and Turborepo.
|
|||
## Railway Logs
|
||||
|
||||
- Production Foundry Railway logs can be read from a linked workspace with `railway logs --deployment --lines 200` or `railway logs <deployment-id> --deployment --lines 200`.
|
||||
- Production deploys should go through `git push` to the deployment branch/workflow. Do not use `railway up` for Foundry deploys.
|
||||
- If Railway logs fail because the workspace is not linked to the correct project/service/environment, run:
|
||||
`railway link --project 33e3e2df-32c5-41c5-a4af-dca8654acb1d --environment cf387142-61fd-4668-8cf7-b3559e0983cb --service 91c7e450-d6d2-481a-b2a4-0a916f4160fc`
|
||||
- That links this directory to the `sandbox-agent` project, `production` environment, and `foundry-api` service.
|
||||
- Production proxy chain: `api.sandboxagent.dev` routes through Cloudflare → Fastly/Varnish → Railway. When debugging request duplication, timeouts, or retry behavior, check headers like `cf-ray`, `x-varnish`, `x-railway-edge`, and `cdn-loop` to identify which layer is involved.
|
||||
|
||||
## Frontend + Client Boundary
|
||||
|
||||
- Keep a browser-friendly GUI implementation aligned with the TUI interaction model wherever possible.
|
||||
- Do not import `rivetkit` directly in CLI or GUI packages. RivetKit client access must stay isolated inside `packages/client`.
|
||||
- All backend interaction (actor calls, metadata/health checks, backend HTTP endpoint access) must go through the dedicated client library in `packages/client`.
|
||||
- Outside `packages/client`, do not call backend endpoints directly (for example `fetch(.../api/rivet...)`), except in black-box E2E tests that intentionally exercise raw transport behavior.
|
||||
- Outside `packages/client`, do not call backend endpoints directly (for example `fetch(.../v1/rivet...)`), except in black-box E2E tests that intentionally exercise raw transport behavior.
|
||||
- GUI state should update in realtime (no manual refresh buttons). Prefer RivetKit push reactivity and actor-driven events; do not add polling/refetch for normal product flows.
|
||||
- Keep the mock workbench types and mock client in `packages/shared` + `packages/client` up to date with the frontend contract. The mock is the UI testing reference implementation while backend functionality catches up.
|
||||
- Keep frontend route/state coverage current in code and tests; there is no separate page-inventory doc to maintain.
|
||||
|
|
@ -105,9 +103,9 @@ For all Rivet/RivetKit implementation:
|
|||
|
||||
## Rivet Routing
|
||||
|
||||
- Mount RivetKit directly on `/api/rivet` via `registry.handler(c.req.raw)`.
|
||||
- Mount RivetKit directly on `/v1/rivet` via `registry.handler(c.req.raw)`.
|
||||
- Do not add an extra proxy or manager-specific route layer in the backend.
|
||||
- Let RivetKit own metadata/public endpoint behavior for `/api/rivet`.
|
||||
- Let RivetKit own metadata/public endpoint behavior for `/v1/rivet`.
|
||||
|
||||
## Workspace + Actor Rules
|
||||
|
||||
|
|
@ -121,6 +119,14 @@ For all Rivet/RivetKit implementation:
|
|||
- Keep strict single-writer ownership: each table/row has exactly one actor writer.
|
||||
- Parent actors (`workspace`, `project`, `task`, `history`, `sandbox-instance`) use command-only loops with no timeout.
|
||||
- Periodic syncing lives in dedicated child actors with one timeout cadence each.
|
||||
- Do not build blocking flows that wait on external systems to become ready or complete. Prefer push-based progression driven by actor messages, events, webhooks, or queue/workflow state changes.
|
||||
- Use workflows/background commands for any repo sync, sandbox provisioning, agent install, branch restack/rebase, or other multi-step external work. Do not keep user-facing actions/requests open while that work runs.
|
||||
- `send` policy: always `await` the `send(...)` call itself so enqueue failures surface immediately, but default to `wait: false`.
|
||||
- Only use `send(..., { wait: true })` for short, bounded mutations that should finish quickly and do not depend on external readiness, polling actors, provider setup, repo/network I/O, or long-running queue drains.
|
||||
- Request/action contract: wait only until the minimum resource needed for the client's next step exists. Example: task creation may wait for task actor creation/identity, but not for sandbox provisioning or session bootstrap.
|
||||
- Read paths must not force refresh/sync work inline. Serve the latest cached projection, mark staleness explicitly, and trigger background refresh separately when needed.
|
||||
- If a workflow needs to resume after some external work completes, model that as workflow state plus follow-up messages/events instead of holding the original request open.
|
||||
- Do not rely on retries for correctness or normal control flow. If a queue/workflow/external dependency is not ready yet, model that explicitly and resume from a push/event, instead of polling or retry loops.
|
||||
- Actor handle policy:
|
||||
- Prefer explicit `get` or explicit `create` based on workflow intent; do not default to `getOrCreate`.
|
||||
- Use `get`/`getForId` when the actor is expected to already exist; if missing, surface an explicit `Actor not found` error with recovery context.
|
||||
|
|
@ -142,7 +148,7 @@ For all Rivet/RivetKit implementation:
|
|||
- All external service calls (git CLI, GitHub CLI, sandbox-agent HTTP, tmux) must go through the `BackendDriver` interface on the runtime context.
|
||||
- Integration tests use `setupTest()` from `rivetkit/test` and are gated behind `HF_ENABLE_ACTOR_INTEGRATION_TESTS=1`.
|
||||
- End-to-end testing must run against the dev backend started via `docker compose -f compose.dev.yaml up` (host -> container). Do not run E2E against an in-process test runtime.
|
||||
- E2E tests should talk to the backend over HTTP (default `http://127.0.0.1:7741/api/rivet`) and use real GitHub repos/PRs.
|
||||
- E2E tests should talk to the backend over HTTP (default `http://127.0.0.1:7741/v1/rivet`) and use real GitHub repos/PRs.
|
||||
- For Foundry live verification, use `rivet-dev/sandbox-agent-testing` as the default testing repo unless the task explicitly says otherwise.
|
||||
- Secrets (e.g. `OPENAI_API_KEY`, `GITHUB_TOKEN`/`GH_TOKEN`) must be provided via environment variables, never hardcoded in the repo.
|
||||
- `~/misc/env.txt` and `~/misc/the-foundry.env` contain the expected local OpenAI + GitHub OAuth/App config for dev.
|
||||
|
|
|
|||
|
|
@ -5,9 +5,20 @@ escape_js() {
|
|||
printf '%s' "${1:-}" | sed 's/\\/\\\\/g; s/"/\\"/g'
|
||||
}
|
||||
|
||||
normalize_backend_endpoint() {
|
||||
case "${1:-}" in
|
||||
*/api/rivet)
|
||||
printf '%s/v1/rivet' "${1%/api/rivet}"
|
||||
;;
|
||||
*)
|
||||
printf '%s' "${1:-}"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
cat > /srv/__foundry_runtime_config.js <<EOF
|
||||
window.__FOUNDRY_RUNTIME_CONFIG__ = {
|
||||
backendEndpoint: "$(escape_js "${VITE_HF_BACKEND_ENDPOINT:-}")",
|
||||
backendEndpoint: "$(escape_js "$(normalize_backend_endpoint "${VITE_HF_BACKEND_ENDPOINT:-}")")",
|
||||
defaultWorkspaceId: "$(escape_js "${VITE_HF_WORKSPACE:-}")",
|
||||
frontendClientMode: "$(escape_js "${FOUNDRY_FRONTEND_CLIENT_MODE:-remote}")"
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ server {
|
|||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location /api/rivet/ {
|
||||
proxy_pass http://backend:7841/api/rivet/;
|
||||
location /v1/ {
|
||||
proxy_pass http://backend:7841/v1/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
|
@ -15,8 +15,8 @@ server {
|
|||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
|
||||
location = /api/rivet {
|
||||
proxy_pass http://backend:7841/api/rivet;
|
||||
location = /v1 {
|
||||
proxy_pass http://backend:7841/v1;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ WorkspaceActor
|
|||
- Branch rename is a real git operation, not just metadata.
|
||||
- `SandboxInstanceActor` stays separate from `TaskActor`; tasks/sessions reference it by identity.
|
||||
- Sync actors are polling workers only. They feed parent actors and should not become the source of truth.
|
||||
- When a backend request path must aggregate multiple independent actor calls or reads, prefer bounded parallelism over sequential fan-out when correctness permits. Do not serialize independent work by default.
|
||||
|
||||
## Maintenance
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { createErrorContext, createFoundryLogger } from "@sandbox-agent/foundry-shared";
|
||||
|
||||
type Journal = {
|
||||
entries?: Array<{
|
||||
|
|
@ -11,6 +12,10 @@ type Journal = {
|
|||
}>;
|
||||
};
|
||||
|
||||
const logger = createFoundryLogger({
|
||||
service: "foundry-backend-migrations",
|
||||
});
|
||||
|
||||
function padMigrationKey(idx: number): string {
|
||||
return `m${String(idx).padStart(4, "0")}`;
|
||||
}
|
||||
|
|
@ -128,8 +133,6 @@ async function main(): Promise<void> {
|
|||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
const message = error instanceof Error ? (error.stack ?? error.message) : String(error);
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(message);
|
||||
logger.error(createErrorContext(error), "generate_actor_migrations_failed");
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "9d9ebe3c-8341-449c-bd14-2b6fd62853a1",
|
||||
"id": "e592c829-141f-4740-88b7-09cf957a4405",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"tables": {
|
||||
"events": {
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@
|
|||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1770924375133,
|
||||
"tag": "0000_watery_bushwacker",
|
||||
"when": 1773376223815,
|
||||
"tag": "0000_fluffy_kid_colt",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ const journal = {
|
|||
entries: [
|
||||
{
|
||||
idx: 0,
|
||||
when: 1770924375133,
|
||||
tag: "0000_watery_bushwacker",
|
||||
when: 1773376223815,
|
||||
tag: "0000_fluffy_kid_colt",
|
||||
breakpoints: true,
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ export const events = sqliteTable("events", {
|
|||
taskId: text("task_id"),
|
||||
branchName: text("branch_name"),
|
||||
kind: text("kind").notNull(),
|
||||
// Structured by the history event kind definitions in application code.
|
||||
payloadJson: text("payload_json").notNull(),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -65,6 +65,10 @@ export const history = actor({
|
|||
queues: {
|
||||
"history.command.append": queue(),
|
||||
},
|
||||
options: {
|
||||
name: "History",
|
||||
icon: "database",
|
||||
},
|
||||
createState: (_c, input: HistoryInput) => ({
|
||||
workspaceId: input.workspaceId,
|
||||
repoId: input.repoId,
|
||||
|
|
|
|||
|
|
@ -7,8 +7,20 @@ import { projectPrSync } from "./project-pr-sync/index.js";
|
|||
import { project } from "./project/index.js";
|
||||
import { sandboxInstance } from "./sandbox-instance/index.js";
|
||||
import { workspace } from "./workspace/index.js";
|
||||
import { logger } from "../logging.js";
|
||||
|
||||
const RUNNER_VERSION = Math.floor(Date.now() / 1000);
|
||||
|
||||
export const registry = setup({
|
||||
serverless: {
|
||||
basePath: "/v1/rivet",
|
||||
},
|
||||
runner: {
|
||||
version: RUNNER_VERSION,
|
||||
},
|
||||
logging: {
|
||||
baseLogger: logger,
|
||||
},
|
||||
use: {
|
||||
workspace,
|
||||
project,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { logger } from "../logging.js";
|
||||
|
||||
export function resolveErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
|
|
@ -17,11 +19,11 @@ export function resolveErrorStack(error: unknown): string | undefined {
|
|||
}
|
||||
|
||||
export function logActorWarning(scope: string, message: string, context?: Record<string, unknown>): void {
|
||||
const payload = {
|
||||
scope,
|
||||
logger.warn(
|
||||
{
|
||||
scope,
|
||||
...(context ?? {}),
|
||||
},
|
||||
message,
|
||||
...(context ?? {}),
|
||||
};
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("[foundry][actor:warn]", payload);
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -126,6 +126,8 @@ export const projectBranchSync = actor({
|
|||
[CONTROL.force]: queue(),
|
||||
},
|
||||
options: {
|
||||
name: "Project Branch Sync",
|
||||
icon: "code-branch",
|
||||
// Polling actors rely on timer-based wakeups; sleeping would pause the timer and stop polling.
|
||||
noSleep: true,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -46,6 +46,8 @@ export const projectPrSync = actor({
|
|||
[CONTROL.force]: queue(),
|
||||
},
|
||||
options: {
|
||||
name: "Project PR Sync",
|
||||
icon: "code-merge",
|
||||
// Polling actors rely on timer-based wakeups; sleeping would pause the timer and stop polling.
|
||||
noSleep: true,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
CREATE TABLE `branches` (
|
||||
`branch_name` text PRIMARY KEY NOT NULL,
|
||||
`commit_sha` text NOT NULL,
|
||||
`worktree_path` text,
|
||||
`parent_branch` text,
|
||||
`tracked_in_stack` integer DEFAULT 0 NOT NULL,
|
||||
`diff_stat` text,
|
||||
`has_unpushed` integer,
|
||||
`conflicts_with_main` integer,
|
||||
`has_unpushed` integer DEFAULT 0 NOT NULL,
|
||||
`conflicts_with_main` integer DEFAULT 0 NOT NULL,
|
||||
`first_seen_at` integer,
|
||||
`last_seen_at` integer,
|
||||
`updated_at` integer NOT NULL
|
||||
|
|
@ -18,10 +18,23 @@ CREATE TABLE `pr_cache` (
|
|||
`title` text NOT NULL,
|
||||
`pr_url` text,
|
||||
`pr_author` text,
|
||||
`is_draft` integer,
|
||||
`is_draft` integer DEFAULT 0 NOT NULL,
|
||||
`ci_status` text,
|
||||
`review_status` text,
|
||||
`reviewer` text,
|
||||
`fetched_at` integer,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `repo_meta` (
|
||||
`id` integer PRIMARY KEY NOT NULL,
|
||||
`remote_url` text NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `task_index` (
|
||||
`task_id` text PRIMARY KEY NOT NULL,
|
||||
`branch_name` text,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
CREATE TABLE `repo_meta` (
|
||||
`id` integer PRIMARY KEY NOT NULL,
|
||||
`remote_url` text NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `branches` DROP COLUMN `worktree_path`;
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
CREATE TABLE `task_index` (
|
||||
`task_id` text PRIMARY KEY NOT NULL,
|
||||
`branch_name` text,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
|
|
@ -1 +0,0 @@
|
|||
ALTER TABLE `branches` ADD `tracked_in_stack` integer;
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "03d97613-0108-4197-8660-5f2af5409fe6",
|
||||
"id": "6ffd6acb-e737-46ee-a8fe-fcfddcdd6ea9",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"tables": {
|
||||
"branches": {
|
||||
|
|
@ -21,13 +21,6 @@
|
|||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"worktree_path": {
|
||||
"name": "worktree_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"parent_branch": {
|
||||
"name": "parent_branch",
|
||||
"type": "text",
|
||||
|
|
@ -35,6 +28,14 @@
|
|||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"tracked_in_stack": {
|
||||
"name": "tracked_in_stack",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"diff_stat": {
|
||||
"name": "diff_stat",
|
||||
"type": "text",
|
||||
|
|
@ -46,15 +47,17 @@
|
|||
"name": "has_unpushed",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"conflicts_with_main": {
|
||||
"name": "conflicts_with_main",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"first_seen_at": {
|
||||
"name": "first_seen_at",
|
||||
|
|
@ -133,8 +136,9 @@
|
|||
"name": "is_draft",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"ci_status": {
|
||||
"name": "ci_status",
|
||||
|
|
@ -177,6 +181,75 @@
|
|||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"repo_meta": {
|
||||
"name": "repo_meta",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"remote_url": {
|
||||
"name": "remote_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_index": {
|
||||
"name": "task_index",
|
||||
"columns": {
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"branch_name": {
|
||||
"name": "branch_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
|
|
|
|||
|
|
@ -1,216 +0,0 @@
|
|||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "e6d294b6-27ce-424b-a3b3-c100b42e628b",
|
||||
"prevId": "03d97613-0108-4197-8660-5f2af5409fe6",
|
||||
"tables": {
|
||||
"branches": {
|
||||
"name": "branches",
|
||||
"columns": {
|
||||
"branch_name": {
|
||||
"name": "branch_name",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"commit_sha": {
|
||||
"name": "commit_sha",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"parent_branch": {
|
||||
"name": "parent_branch",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"diff_stat": {
|
||||
"name": "diff_stat",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"has_unpushed": {
|
||||
"name": "has_unpushed",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"conflicts_with_main": {
|
||||
"name": "conflicts_with_main",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"first_seen_at": {
|
||||
"name": "first_seen_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_seen_at": {
|
||||
"name": "last_seen_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"pr_cache": {
|
||||
"name": "pr_cache",
|
||||
"columns": {
|
||||
"branch_name": {
|
||||
"name": "branch_name",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"pr_number": {
|
||||
"name": "pr_number",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"state": {
|
||||
"name": "state",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"pr_url": {
|
||||
"name": "pr_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"pr_author": {
|
||||
"name": "pr_author",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_draft": {
|
||||
"name": "is_draft",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ci_status": {
|
||||
"name": "ci_status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"review_status": {
|
||||
"name": "review_status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"reviewer": {
|
||||
"name": "reviewer",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"fetched_at": {
|
||||
"name": "fetched_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"repo_meta": {
|
||||
"name": "repo_meta",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"remote_url": {
|
||||
"name": "remote_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,254 +0,0 @@
|
|||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "ac89870f-1630-4a16-9606-7b1225f6da8a",
|
||||
"prevId": "e6d294b6-27ce-424b-a3b3-c100b42e628b",
|
||||
"tables": {
|
||||
"branches": {
|
||||
"name": "branches",
|
||||
"columns": {
|
||||
"branch_name": {
|
||||
"name": "branch_name",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"commit_sha": {
|
||||
"name": "commit_sha",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"parent_branch": {
|
||||
"name": "parent_branch",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"diff_stat": {
|
||||
"name": "diff_stat",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"has_unpushed": {
|
||||
"name": "has_unpushed",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"conflicts_with_main": {
|
||||
"name": "conflicts_with_main",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"first_seen_at": {
|
||||
"name": "first_seen_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_seen_at": {
|
||||
"name": "last_seen_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_index": {
|
||||
"name": "task_index",
|
||||
"columns": {
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"branch_name": {
|
||||
"name": "branch_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"pr_cache": {
|
||||
"name": "pr_cache",
|
||||
"columns": {
|
||||
"branch_name": {
|
||||
"name": "branch_name",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"pr_number": {
|
||||
"name": "pr_number",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"state": {
|
||||
"name": "state",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"pr_url": {
|
||||
"name": "pr_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"pr_author": {
|
||||
"name": "pr_author",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_draft": {
|
||||
"name": "is_draft",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ci_status": {
|
||||
"name": "ci_status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"review_status": {
|
||||
"name": "review_status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"reviewer": {
|
||||
"name": "reviewer",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"fetched_at": {
|
||||
"name": "fetched_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"repo_meta": {
|
||||
"name": "repo_meta",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"remote_url": {
|
||||
"name": "remote_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,29 +5,8 @@
|
|||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1770924376062,
|
||||
"tag": "0000_stormy_the_hunter",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1770947252449,
|
||||
"tag": "0001_wild_carlie_cooper",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "6",
|
||||
"when": 1771276338465,
|
||||
"tag": "0002_far_war_machine",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "6",
|
||||
"when": 1771369000000,
|
||||
"tag": "0003_busy_legacy",
|
||||
"when": 1773376221848,
|
||||
"tag": "0000_useful_la_nuit",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -6,26 +6,8 @@ const journal = {
|
|||
entries: [
|
||||
{
|
||||
idx: 0,
|
||||
when: 1770924376062,
|
||||
tag: "0000_stormy_the_hunter",
|
||||
breakpoints: true,
|
||||
},
|
||||
{
|
||||
idx: 1,
|
||||
when: 1770947252449,
|
||||
tag: "0001_wild_carlie_cooper",
|
||||
breakpoints: true,
|
||||
},
|
||||
{
|
||||
idx: 2,
|
||||
when: 1771276338465,
|
||||
tag: "0002_far_war_machine",
|
||||
breakpoints: true,
|
||||
},
|
||||
{
|
||||
idx: 3,
|
||||
when: 1771369000000,
|
||||
tag: "0003_busy_legacy",
|
||||
when: 1773376221848,
|
||||
tag: "0000_useful_la_nuit",
|
||||
breakpoints: true,
|
||||
},
|
||||
],
|
||||
|
|
@ -37,11 +19,11 @@ export default {
|
|||
m0000: `CREATE TABLE \`branches\` (
|
||||
\`branch_name\` text PRIMARY KEY NOT NULL,
|
||||
\`commit_sha\` text NOT NULL,
|
||||
\`worktree_path\` text,
|
||||
\`parent_branch\` text,
|
||||
\`tracked_in_stack\` integer DEFAULT 0 NOT NULL,
|
||||
\`diff_stat\` text,
|
||||
\`has_unpushed\` integer,
|
||||
\`conflicts_with_main\` integer,
|
||||
\`has_unpushed\` integer DEFAULT 0 NOT NULL,
|
||||
\`conflicts_with_main\` integer DEFAULT 0 NOT NULL,
|
||||
\`first_seen_at\` integer,
|
||||
\`last_seen_at\` integer,
|
||||
\`updated_at\` integer NOT NULL
|
||||
|
|
@ -54,28 +36,26 @@ CREATE TABLE \`pr_cache\` (
|
|||
\`title\` text NOT NULL,
|
||||
\`pr_url\` text,
|
||||
\`pr_author\` text,
|
||||
\`is_draft\` integer,
|
||||
\`is_draft\` integer DEFAULT 0 NOT NULL,
|
||||
\`ci_status\` text,
|
||||
\`review_status\` text,
|
||||
\`reviewer\` text,
|
||||
\`fetched_at\` integer,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
`,
|
||||
m0001: `CREATE TABLE \`repo_meta\` (
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE \`repo_meta\` (
|
||||
\`id\` integer PRIMARY KEY NOT NULL,
|
||||
\`remote_url\` text NOT NULL,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE \`branches\` DROP COLUMN \`worktree_path\`;`,
|
||||
m0002: `CREATE TABLE \`task_index\` (
|
||||
CREATE TABLE \`task_index\` (
|
||||
\`task_id\` text PRIMARY KEY NOT NULL,
|
||||
\`branch_name\` text,
|
||||
\`created_at\` integer NOT NULL,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
`,
|
||||
m0003: `ALTER TABLE \`branches\` ADD \`tracked_in_stack\` integer;`,
|
||||
} as const,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,10 +6,10 @@ export const branches = sqliteTable("branches", {
|
|||
branchName: text("branch_name").notNull().primaryKey(),
|
||||
commitSha: text("commit_sha").notNull(),
|
||||
parentBranch: text("parent_branch"),
|
||||
trackedInStack: integer("tracked_in_stack"),
|
||||
trackedInStack: integer("tracked_in_stack").notNull().default(0),
|
||||
diffStat: text("diff_stat"),
|
||||
hasUnpushed: integer("has_unpushed"),
|
||||
conflictsWithMain: integer("conflicts_with_main"),
|
||||
hasUnpushed: integer("has_unpushed").notNull().default(0),
|
||||
conflictsWithMain: integer("conflicts_with_main").notNull().default(0),
|
||||
firstSeenAt: integer("first_seen_at"),
|
||||
lastSeenAt: integer("last_seen_at"),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
|
|
@ -28,7 +28,7 @@ export const prCache = sqliteTable("pr_cache", {
|
|||
title: text("title").notNull(),
|
||||
prUrl: text("pr_url"),
|
||||
prAuthor: text("pr_author"),
|
||||
isDraft: integer("is_draft"),
|
||||
isDraft: integer("is_draft").notNull().default(0),
|
||||
ciStatus: text("ci_status"),
|
||||
reviewStatus: text("review_status"),
|
||||
reviewer: text("reviewer"),
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ export const project = actor({
|
|||
db: projectDb,
|
||||
queues: Object.fromEntries(PROJECT_QUEUE_NAMES.map((name) => [name, queue()])),
|
||||
options: {
|
||||
name: "Project",
|
||||
icon: "folder",
|
||||
actionTimeout: 5 * 60_000,
|
||||
},
|
||||
createState: (_c, input: ProjectInput) => ({
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
CREATE TABLE `sandbox_instance` (
|
||||
`id` integer PRIMARY KEY NOT NULL,
|
||||
`metadata_json` text NOT NULL,
|
||||
`status` text NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
|
|
@ -1,14 +1,10 @@
|
|||
CREATE TABLE `sandbox_sessions` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`agent` text NOT NULL,
|
||||
`agent_session_id` text NOT NULL,
|
||||
`last_connection_id` text NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`destroyed_at` integer,
|
||||
`session_init_json` text
|
||||
CREATE TABLE `sandbox_instance` (
|
||||
`id` integer PRIMARY KEY NOT NULL,
|
||||
`metadata_json` text NOT NULL,
|
||||
`status` text NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
|
||||
CREATE TABLE `sandbox_session_events` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`session_id` text NOT NULL,
|
||||
|
|
@ -19,9 +15,13 @@ CREATE TABLE `sandbox_session_events` (
|
|||
`payload_json` text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
|
||||
CREATE INDEX `sandbox_sessions_created_at_idx` ON `sandbox_sessions` (`created_at`);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `sandbox_session_events_session_id_event_index_idx` ON `sandbox_session_events` (`session_id`,`event_index`);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX `sandbox_session_events_session_id_created_at_idx` ON `sandbox_session_events` (`session_id`,`created_at`);
|
||||
CREATE UNIQUE INDEX `sandbox_session_events_session_id_event_index_unique` ON `sandbox_session_events` (`session_id`,`event_index`);--> statement-breakpoint
|
||||
CREATE TABLE `sandbox_sessions` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`agent` text NOT NULL,
|
||||
`agent_session_id` text NOT NULL,
|
||||
`last_connection_id` text NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`destroyed_at` integer,
|
||||
`session_init_json` text
|
||||
);
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "ef8a919c-64f0-46d9-b8ed-a15f039e6ba7",
|
||||
"id": "130486c5-6208-4d00-b367-e02b9def953a",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"tables": {
|
||||
"sandbox_instance": {
|
||||
|
|
@ -41,6 +41,130 @@
|
|||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"sandbox_session_events": {
|
||||
"name": "sandbox_session_events",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"session_id": {
|
||||
"name": "session_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"event_index": {
|
||||
"name": "event_index",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"connection_id": {
|
||||
"name": "connection_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sender": {
|
||||
"name": "sender",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"payload_json": {
|
||||
"name": "payload_json",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"sandbox_session_events_session_id_event_index_unique": {
|
||||
"name": "sandbox_session_events_session_id_event_index_unique",
|
||||
"columns": ["session_id", "event_index"],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"sandbox_sessions": {
|
||||
"name": "sandbox_sessions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent": {
|
||||
"name": "agent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent_session_id": {
|
||||
"name": "agent_session_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"last_connection_id": {
|
||||
"name": "last_connection_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"destroyed_at": {
|
||||
"name": "destroyed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"session_init_json": {
|
||||
"name": "session_init_json",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
|
|
|
|||
|
|
@ -5,15 +5,8 @@
|
|||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1770924375604,
|
||||
"tag": "0000_broad_tyrannus",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1776482400000,
|
||||
"tag": "0001_sandbox_sessions",
|
||||
"when": 1773376224446,
|
||||
"tag": "0000_smooth_sauron",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -6,14 +6,8 @@ const journal = {
|
|||
entries: [
|
||||
{
|
||||
idx: 0,
|
||||
when: 1770924375604,
|
||||
tag: "0000_broad_tyrannus",
|
||||
breakpoints: true,
|
||||
},
|
||||
{
|
||||
idx: 1,
|
||||
when: 1776482400000,
|
||||
tag: "0001_sandbox_sessions",
|
||||
when: 1773376224446,
|
||||
tag: "0000_smooth_sauron",
|
||||
breakpoints: true,
|
||||
},
|
||||
],
|
||||
|
|
@ -28,18 +22,7 @@ export default {
|
|||
\`status\` text NOT NULL,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
`,
|
||||
m0001: `CREATE TABLE \`sandbox_sessions\` (
|
||||
\`id\` text PRIMARY KEY NOT NULL,
|
||||
\`agent\` text NOT NULL,
|
||||
\`agent_session_id\` text NOT NULL,
|
||||
\`last_connection_id\` text NOT NULL,
|
||||
\`created_at\` integer NOT NULL,
|
||||
\`destroyed_at\` integer,
|
||||
\`session_init_json\` text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
|
||||
CREATE TABLE \`sandbox_session_events\` (
|
||||
\`id\` text PRIMARY KEY NOT NULL,
|
||||
\`session_id\` text NOT NULL,
|
||||
|
|
@ -50,12 +33,16 @@ CREATE TABLE \`sandbox_session_events\` (
|
|||
\`payload_json\` text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
|
||||
CREATE INDEX \`sandbox_sessions_created_at_idx\` ON \`sandbox_sessions\` (\`created_at\`);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX \`sandbox_session_events_session_id_event_index_idx\` ON \`sandbox_session_events\` (\`session_id\`,\`event_index\`);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX \`sandbox_session_events_session_id_created_at_idx\` ON \`sandbox_session_events\` (\`session_id\`,\`created_at\`);
|
||||
CREATE UNIQUE INDEX \`sandbox_session_events_session_id_event_index_unique\` ON \`sandbox_session_events\` (\`session_id\`,\`event_index\`);--> statement-breakpoint
|
||||
CREATE TABLE \`sandbox_sessions\` (
|
||||
\`id\` text PRIMARY KEY NOT NULL,
|
||||
\`agent\` text NOT NULL,
|
||||
\`agent_session_id\` text NOT NULL,
|
||||
\`last_connection_id\` text NOT NULL,
|
||||
\`created_at\` integer NOT NULL,
|
||||
\`destroyed_at\` integer,
|
||||
\`session_init_json\` text
|
||||
);
|
||||
`,
|
||||
} as const,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { integer, sqliteTable, text } from "rivetkit/db/drizzle";
|
||||
import { integer, sqliteTable, text, uniqueIndex } from "rivetkit/db/drizzle";
|
||||
|
||||
// SQLite is per sandbox-instance actor instance.
|
||||
export const sandboxInstance = sqliteTable("sandbox_instance", {
|
||||
id: integer("id").primaryKey(),
|
||||
// Structured by the provider/runtime metadata serializer for this actor.
|
||||
metadataJson: text("metadata_json").notNull(),
|
||||
status: text("status").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
|
|
@ -17,15 +18,21 @@ export const sandboxSessions = sqliteTable("sandbox_sessions", {
|
|||
lastConnectionId: text("last_connection_id").notNull(),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
destroyedAt: integer("destroyed_at"),
|
||||
// Structured by the sandbox-agent ACP session bootstrap payload.
|
||||
sessionInitJson: text("session_init_json"),
|
||||
});
|
||||
|
||||
export const sandboxSessionEvents = sqliteTable("sandbox_session_events", {
|
||||
id: text("id").notNull().primaryKey(),
|
||||
sessionId: text("session_id").notNull(),
|
||||
eventIndex: integer("event_index").notNull(),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
connectionId: text("connection_id").notNull(),
|
||||
sender: text("sender").notNull(),
|
||||
payloadJson: text("payload_json").notNull(),
|
||||
});
|
||||
export const sandboxSessionEvents = sqliteTable(
|
||||
"sandbox_session_events",
|
||||
{
|
||||
id: text("id").notNull().primaryKey(),
|
||||
sessionId: text("session_id").notNull(),
|
||||
eventIndex: integer("event_index").notNull(),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
connectionId: text("connection_id").notNull(),
|
||||
sender: text("sender").notNull(),
|
||||
// Structured by the sandbox-agent session event envelope.
|
||||
payloadJson: text("payload_json").notNull(),
|
||||
},
|
||||
(table) => [uniqueIndex("sandbox_session_events_session_id_event_index_unique").on(table.sessionId, table.eventIndex)],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -458,6 +458,8 @@ export const sandboxInstance = actor({
|
|||
db: sandboxInstanceDb,
|
||||
queues: Object.fromEntries(SANDBOX_INSTANCE_QUEUE_NAMES.map((name) => [name, queue()])),
|
||||
options: {
|
||||
name: "Sandbox Instance",
|
||||
icon: "box",
|
||||
actionTimeout: 5 * 60_000,
|
||||
},
|
||||
createState: (_c, input: SandboxInstanceInput) => ({
|
||||
|
|
|
|||
|
|
@ -55,6 +55,8 @@ export const taskStatusSync = actor({
|
|||
[CONTROL.force]: queue(),
|
||||
},
|
||||
options: {
|
||||
name: "Task Status Sync",
|
||||
icon: "signal",
|
||||
// Polling actors rely on timer-based wakeups; sleeping would pause the timer and stop polling.
|
||||
noSleep: true,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
CREATE TABLE `task` (
|
||||
`id` integer PRIMARY KEY NOT NULL,
|
||||
`branch_name` text,
|
||||
`title` text,
|
||||
`task` text NOT NULL,
|
||||
`provider_id` text NOT NULL,
|
||||
`status` text NOT NULL,
|
||||
`agent_type` text DEFAULT 'claude',
|
||||
`pr_submitted` integer DEFAULT 0,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL,
|
||||
CONSTRAINT "task_singleton_id_check" CHECK("task"."id" = 1)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `task_runtime` (
|
||||
`id` integer PRIMARY KEY NOT NULL,
|
||||
`active_sandbox_id` text,
|
||||
`active_session_id` text,
|
||||
`active_switch_target` text,
|
||||
`active_cwd` text,
|
||||
`status_message` text,
|
||||
`updated_at` integer NOT NULL,
|
||||
CONSTRAINT "task_runtime_singleton_id_check" CHECK("task_runtime"."id" = 1)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `task_sandboxes` (
|
||||
`sandbox_id` text PRIMARY KEY NOT NULL,
|
||||
`provider_id` text NOT NULL,
|
||||
`sandbox_actor_id` text,
|
||||
`switch_target` text NOT NULL,
|
||||
`cwd` text,
|
||||
`status_message` text,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `task_workbench_sessions` (
|
||||
`session_id` text PRIMARY KEY NOT NULL,
|
||||
`session_name` text NOT NULL,
|
||||
`model` text NOT NULL,
|
||||
`unread` integer DEFAULT 0 NOT NULL,
|
||||
`draft_text` text DEFAULT '' NOT NULL,
|
||||
`draft_attachments_json` text DEFAULT '[]' NOT NULL,
|
||||
`draft_updated_at` integer,
|
||||
`created` integer DEFAULT 1 NOT NULL,
|
||||
`closed` integer DEFAULT 0 NOT NULL,
|
||||
`thinking_since_ms` integer,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
CREATE TABLE `task` (
|
||||
`id` integer PRIMARY KEY NOT NULL,
|
||||
`branch_name` text NOT NULL,
|
||||
`title` text NOT NULL,
|
||||
`task` text NOT NULL,
|
||||
`provider_id` text NOT NULL,
|
||||
`status` text NOT NULL,
|
||||
`agent_type` text DEFAULT 'claude',
|
||||
`auto_committed` integer DEFAULT 0,
|
||||
`pushed` integer DEFAULT 0,
|
||||
`pr_submitted` integer DEFAULT 0,
|
||||
`needs_push` integer DEFAULT 0,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `task_runtime` (
|
||||
`id` integer PRIMARY KEY NOT NULL,
|
||||
`sandbox_id` text,
|
||||
`session_id` text,
|
||||
`switch_target` text,
|
||||
`status_message` text,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
ALTER TABLE `task` DROP COLUMN `auto_committed`;--> statement-breakpoint
|
||||
ALTER TABLE `task` DROP COLUMN `pushed`;--> statement-breakpoint
|
||||
ALTER TABLE `task` DROP COLUMN `needs_push`;
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
ALTER TABLE `task_runtime` RENAME COLUMN "sandbox_id" TO "active_sandbox_id";--> statement-breakpoint
|
||||
ALTER TABLE `task_runtime` RENAME COLUMN "session_id" TO "active_session_id";--> statement-breakpoint
|
||||
ALTER TABLE `task_runtime` RENAME COLUMN "switch_target" TO "active_switch_target";--> statement-breakpoint
|
||||
CREATE TABLE `task_sandboxes` (
|
||||
`sandbox_id` text PRIMARY KEY NOT NULL,
|
||||
`provider_id` text NOT NULL,
|
||||
`switch_target` text NOT NULL,
|
||||
`cwd` text,
|
||||
`status_message` text,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `task_runtime` ADD `active_cwd` text;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO `task_sandboxes` (
|
||||
`sandbox_id`,
|
||||
`provider_id`,
|
||||
`switch_target`,
|
||||
`cwd`,
|
||||
`status_message`,
|
||||
`created_at`,
|
||||
`updated_at`
|
||||
)
|
||||
SELECT
|
||||
r.`active_sandbox_id`,
|
||||
(SELECT h.`provider_id` FROM `task` h WHERE h.`id` = 1),
|
||||
r.`active_switch_target`,
|
||||
r.`active_cwd`,
|
||||
r.`status_message`,
|
||||
COALESCE((SELECT h.`created_at` FROM `task` h WHERE h.`id` = 1), r.`updated_at`),
|
||||
r.`updated_at`
|
||||
FROM `task_runtime` r
|
||||
WHERE
|
||||
r.`id` = 1
|
||||
AND r.`active_sandbox_id` IS NOT NULL
|
||||
AND r.`active_switch_target` IS NOT NULL
|
||||
ON CONFLICT(`sandbox_id`) DO NOTHING;
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
-- Allow tasks to exist before their branch/title are determined.
|
||||
-- Drizzle doesn't support altering column nullability in SQLite directly, so rebuild the table.
|
||||
|
||||
PRAGMA foreign_keys=off;
|
||||
|
||||
CREATE TABLE `task__new` (
|
||||
`id` integer PRIMARY KEY NOT NULL,
|
||||
`branch_name` text,
|
||||
`title` text,
|
||||
`task` text NOT NULL,
|
||||
`provider_id` text NOT NULL,
|
||||
`status` text NOT NULL,
|
||||
`agent_type` text DEFAULT 'claude',
|
||||
`pr_submitted` integer DEFAULT 0,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO `task__new` (
|
||||
`id`,
|
||||
`branch_name`,
|
||||
`title`,
|
||||
`task`,
|
||||
`provider_id`,
|
||||
`status`,
|
||||
`agent_type`,
|
||||
`pr_submitted`,
|
||||
`created_at`,
|
||||
`updated_at`
|
||||
)
|
||||
SELECT
|
||||
`id`,
|
||||
`branch_name`,
|
||||
`title`,
|
||||
`task`,
|
||||
`provider_id`,
|
||||
`status`,
|
||||
`agent_type`,
|
||||
`pr_submitted`,
|
||||
`created_at`,
|
||||
`updated_at`
|
||||
FROM `task`;
|
||||
|
||||
DROP TABLE `task`;
|
||||
ALTER TABLE `task__new` RENAME TO `task`;
|
||||
|
||||
PRAGMA foreign_keys=on;
|
||||
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
-- Fix: make branch_name/title nullable during initial "naming" stage.
|
||||
-- 0003 was missing statement breakpoints, so drizzle's migrator marked it applied without executing all statements.
|
||||
-- Rebuild the table again with proper statement breakpoints.
|
||||
|
||||
PRAGMA foreign_keys=off;
|
||||
--> statement-breakpoint
|
||||
|
||||
DROP TABLE IF EXISTS `task__new`;
|
||||
--> statement-breakpoint
|
||||
|
||||
CREATE TABLE `task__new` (
|
||||
`id` integer PRIMARY KEY NOT NULL,
|
||||
`branch_name` text,
|
||||
`title` text,
|
||||
`task` text NOT NULL,
|
||||
`provider_id` text NOT NULL,
|
||||
`status` text NOT NULL,
|
||||
`agent_type` text DEFAULT 'claude',
|
||||
`pr_submitted` integer DEFAULT 0,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
|
||||
INSERT INTO `task__new` (
|
||||
`id`,
|
||||
`branch_name`,
|
||||
`title`,
|
||||
`task`,
|
||||
`provider_id`,
|
||||
`status`,
|
||||
`agent_type`,
|
||||
`pr_submitted`,
|
||||
`created_at`,
|
||||
`updated_at`
|
||||
)
|
||||
SELECT
|
||||
`id`,
|
||||
`branch_name`,
|
||||
`title`,
|
||||
`task`,
|
||||
`provider_id`,
|
||||
`status`,
|
||||
`agent_type`,
|
||||
`pr_submitted`,
|
||||
`created_at`,
|
||||
`updated_at`
|
||||
FROM `task`;
|
||||
--> statement-breakpoint
|
||||
|
||||
DROP TABLE `task`;
|
||||
--> statement-breakpoint
|
||||
|
||||
ALTER TABLE `task__new` RENAME TO `task`;
|
||||
--> statement-breakpoint
|
||||
|
||||
PRAGMA foreign_keys=on;
|
||||
|
|
@ -1 +0,0 @@
|
|||
ALTER TABLE `task_sandboxes` ADD `sandbox_actor_id` text;
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
CREATE TABLE `task_workbench_sessions` (
|
||||
`session_id` text PRIMARY KEY NOT NULL,
|
||||
`session_name` text NOT NULL,
|
||||
`model` text NOT NULL,
|
||||
`unread` integer DEFAULT 0 NOT NULL,
|
||||
`draft_text` text DEFAULT '' NOT NULL,
|
||||
`draft_attachments_json` text DEFAULT '[]' NOT NULL,
|
||||
`draft_updated_at` integer,
|
||||
`created` integer DEFAULT 1 NOT NULL,
|
||||
`closed` integer DEFAULT 0 NOT NULL,
|
||||
`thinking_since_ms` integer,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "9b004d3b-0722-4bb5-a410-d47635db7df3",
|
||||
"id": "6daaa6d5-3280-46fe-9261-40cabeba1b49",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"tables": {
|
||||
"task": {
|
||||
|
|
@ -18,14 +18,14 @@
|
|||
"name": "branch_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"task": {
|
||||
|
|
@ -57,22 +57,6 @@
|
|||
"autoincrement": false,
|
||||
"default": "'claude'"
|
||||
},
|
||||
"auto_committed": {
|
||||
"name": "auto_committed",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"pushed": {
|
||||
"name": "pushed",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"pr_submitted": {
|
||||
"name": "pr_submitted",
|
||||
"type": "integer",
|
||||
|
|
@ -81,14 +65,6 @@
|
|||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"needs_push": {
|
||||
"name": "needs_push",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
|
|
@ -108,7 +84,12 @@
|
|||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
"checkConstraints": {
|
||||
"task_singleton_id_check": {
|
||||
"name": "task_singleton_id_check",
|
||||
"value": "\"task\".\"id\" = 1"
|
||||
}
|
||||
}
|
||||
},
|
||||
"task_runtime": {
|
||||
"name": "task_runtime",
|
||||
|
|
@ -120,22 +101,29 @@
|
|||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sandbox_id": {
|
||||
"name": "sandbox_id",
|
||||
"active_sandbox_id": {
|
||||
"name": "active_sandbox_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"session_id": {
|
||||
"name": "session_id",
|
||||
"active_session_id": {
|
||||
"name": "active_session_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"switch_target": {
|
||||
"name": "switch_target",
|
||||
"active_switch_target": {
|
||||
"name": "active_switch_target",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"active_cwd": {
|
||||
"name": "active_cwd",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
|
|
@ -160,6 +148,176 @@
|
|||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {
|
||||
"task_runtime_singleton_id_check": {
|
||||
"name": "task_runtime_singleton_id_check",
|
||||
"value": "\"task_runtime\".\"id\" = 1"
|
||||
}
|
||||
}
|
||||
},
|
||||
"task_sandboxes": {
|
||||
"name": "task_sandboxes",
|
||||
"columns": {
|
||||
"sandbox_id": {
|
||||
"name": "sandbox_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sandbox_actor_id": {
|
||||
"name": "sandbox_actor_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"switch_target": {
|
||||
"name": "switch_target",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"cwd": {
|
||||
"name": "cwd",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status_message": {
|
||||
"name": "status_message",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_workbench_sessions": {
|
||||
"name": "task_workbench_sessions",
|
||||
"columns": {
|
||||
"session_id": {
|
||||
"name": "session_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"session_name": {
|
||||
"name": "session_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"model": {
|
||||
"name": "model",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"unread": {
|
||||
"name": "unread",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"draft_text": {
|
||||
"name": "draft_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
},
|
||||
"draft_attachments_json": {
|
||||
"name": "draft_attachments_json",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'[]'"
|
||||
},
|
||||
"draft_updated_at": {
|
||||
"name": "draft_updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created": {
|
||||
"name": "created",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 1
|
||||
},
|
||||
"closed": {
|
||||
"name": "closed",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"thinking_since_ms": {
|
||||
"name": "thinking_since_ms",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,152 +0,0 @@
|
|||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "0fca0f14-69df-4fca-bc52-29e902247909",
|
||||
"prevId": "9b004d3b-0722-4bb5-a410-d47635db7df3",
|
||||
"tables": {
|
||||
"task": {
|
||||
"name": "task",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"branch_name": {
|
||||
"name": "branch_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"task": {
|
||||
"name": "task",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent_type": {
|
||||
"name": "agent_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'claude'"
|
||||
},
|
||||
"pr_submitted": {
|
||||
"name": "pr_submitted",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_runtime": {
|
||||
"name": "task_runtime",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"sandbox_id": {
|
||||
"name": "sandbox_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"session_id": {
|
||||
"name": "session_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"switch_target": {
|
||||
"name": "switch_target",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status_message": {
|
||||
"name": "status_message",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,222 +0,0 @@
|
|||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "72cef919-e545-48be-a7c0-7ac74cfcf9e6",
|
||||
"prevId": "0fca0f14-69df-4fca-bc52-29e902247909",
|
||||
"tables": {
|
||||
"task": {
|
||||
"name": "task",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"branch_name": {
|
||||
"name": "branch_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"task": {
|
||||
"name": "task",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent_type": {
|
||||
"name": "agent_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'claude'"
|
||||
},
|
||||
"pr_submitted": {
|
||||
"name": "pr_submitted",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_runtime": {
|
||||
"name": "task_runtime",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "integer",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"active_sandbox_id": {
|
||||
"name": "active_sandbox_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"active_session_id": {
|
||||
"name": "active_session_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"active_switch_target": {
|
||||
"name": "active_switch_target",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"active_cwd": {
|
||||
"name": "active_cwd",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status_message": {
|
||||
"name": "status_message",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_sandboxes": {
|
||||
"name": "task_sandboxes",
|
||||
"columns": {
|
||||
"sandbox_id": {
|
||||
"name": "sandbox_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"switch_target": {
|
||||
"name": "switch_target",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"cwd": {
|
||||
"name": "cwd",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status_message": {
|
||||
"name": "status_message",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {
|
||||
"\"task_runtime\".\"sandbox_id\"": "\"task_runtime\".\"active_sandbox_id\"",
|
||||
"\"task_runtime\".\"session_id\"": "\"task_runtime\".\"active_session_id\"",
|
||||
"\"task_runtime\".\"switch_target\"": "\"task_runtime\".\"active_switch_target\""
|
||||
}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,43 +5,8 @@
|
|||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1770924374665,
|
||||
"tag": "0000_condemned_maria_hill",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1770947251055,
|
||||
"tag": "0001_rapid_eddie_brock",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "6",
|
||||
"when": 1770948428907,
|
||||
"tag": "0002_lazy_moira_mactaggert",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "6",
|
||||
"when": 1771027535276,
|
||||
"tag": "0003_plucky_bran",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 4,
|
||||
"version": "6",
|
||||
"when": 1771097651912,
|
||||
"tag": "0004_focused_shuri",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "6",
|
||||
"when": 1771370000000,
|
||||
"tag": "0005_sandbox_actor_id",
|
||||
"when": 1773376222525,
|
||||
"tag": "0000_charming_maestro",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -6,44 +6,8 @@ const journal = {
|
|||
entries: [
|
||||
{
|
||||
idx: 0,
|
||||
when: 1770924374665,
|
||||
tag: "0000_condemned_maria_hill",
|
||||
breakpoints: true,
|
||||
},
|
||||
{
|
||||
idx: 1,
|
||||
when: 1770947251055,
|
||||
tag: "0001_rapid_eddie_brock",
|
||||
breakpoints: true,
|
||||
},
|
||||
{
|
||||
idx: 2,
|
||||
when: 1770948428907,
|
||||
tag: "0002_lazy_moira_mactaggert",
|
||||
breakpoints: true,
|
||||
},
|
||||
{
|
||||
idx: 3,
|
||||
when: 1771027535276,
|
||||
tag: "0003_plucky_bran",
|
||||
breakpoints: true,
|
||||
},
|
||||
{
|
||||
idx: 4,
|
||||
when: 1771097651912,
|
||||
tag: "0004_focused_shuri",
|
||||
breakpoints: true,
|
||||
},
|
||||
{
|
||||
idx: 5,
|
||||
when: 1771370000000,
|
||||
tag: "0005_sandbox_actor_id",
|
||||
breakpoints: true,
|
||||
},
|
||||
{
|
||||
idx: 6,
|
||||
when: 1773020000000,
|
||||
tag: "0006_workbench_sessions",
|
||||
when: 1773376222525,
|
||||
tag: "0000_charming_maestro",
|
||||
breakpoints: true,
|
||||
},
|
||||
],
|
||||
|
|
@ -53,78 +17,6 @@ export default {
|
|||
journal,
|
||||
migrations: {
|
||||
m0000: `CREATE TABLE \`task\` (
|
||||
\`id\` integer PRIMARY KEY NOT NULL,
|
||||
\`branch_name\` text NOT NULL,
|
||||
\`title\` text NOT NULL,
|
||||
\`task\` text NOT NULL,
|
||||
\`provider_id\` text NOT NULL,
|
||||
\`status\` text NOT NULL,
|
||||
\`agent_type\` text DEFAULT 'claude',
|
||||
\`auto_committed\` integer DEFAULT 0,
|
||||
\`pushed\` integer DEFAULT 0,
|
||||
\`pr_submitted\` integer DEFAULT 0,
|
||||
\`needs_push\` integer DEFAULT 0,
|
||||
\`created_at\` integer NOT NULL,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE \`task_runtime\` (
|
||||
\`id\` integer PRIMARY KEY NOT NULL,
|
||||
\`sandbox_id\` text,
|
||||
\`session_id\` text,
|
||||
\`switch_target\` text,
|
||||
\`status_message\` text,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
`,
|
||||
m0001: `ALTER TABLE \`task\` DROP COLUMN \`auto_committed\`;--> statement-breakpoint
|
||||
ALTER TABLE \`task\` DROP COLUMN \`pushed\`;--> statement-breakpoint
|
||||
ALTER TABLE \`task\` DROP COLUMN \`needs_push\`;`,
|
||||
m0002: `ALTER TABLE \`task_runtime\` RENAME COLUMN "sandbox_id" TO "active_sandbox_id";--> statement-breakpoint
|
||||
ALTER TABLE \`task_runtime\` RENAME COLUMN "session_id" TO "active_session_id";--> statement-breakpoint
|
||||
ALTER TABLE \`task_runtime\` RENAME COLUMN "switch_target" TO "active_switch_target";--> statement-breakpoint
|
||||
CREATE TABLE \`task_sandboxes\` (
|
||||
\`sandbox_id\` text PRIMARY KEY NOT NULL,
|
||||
\`provider_id\` text NOT NULL,
|
||||
\`switch_target\` text NOT NULL,
|
||||
\`cwd\` text,
|
||||
\`status_message\` text,
|
||||
\`created_at\` integer NOT NULL,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE \`task_runtime\` ADD \`active_cwd\` text;
|
||||
--> statement-breakpoint
|
||||
INSERT INTO \`task_sandboxes\` (
|
||||
\`sandbox_id\`,
|
||||
\`provider_id\`,
|
||||
\`switch_target\`,
|
||||
\`cwd\`,
|
||||
\`status_message\`,
|
||||
\`created_at\`,
|
||||
\`updated_at\`
|
||||
)
|
||||
SELECT
|
||||
r.\`active_sandbox_id\`,
|
||||
(SELECT h.\`provider_id\` FROM \`task\` h WHERE h.\`id\` = 1),
|
||||
r.\`active_switch_target\`,
|
||||
r.\`active_cwd\`,
|
||||
r.\`status_message\`,
|
||||
COALESCE((SELECT h.\`created_at\` FROM \`task\` h WHERE h.\`id\` = 1), r.\`updated_at\`),
|
||||
r.\`updated_at\`
|
||||
FROM \`task_runtime\` r
|
||||
WHERE
|
||||
r.\`id\` = 1
|
||||
AND r.\`active_sandbox_id\` IS NOT NULL
|
||||
AND r.\`active_switch_target\` IS NOT NULL
|
||||
ON CONFLICT(\`sandbox_id\`) DO NOTHING;
|
||||
`,
|
||||
m0003: `-- Allow tasks to exist before their branch/title are determined.
|
||||
-- Drizzle doesn't support altering column nullability in SQLite directly, so rebuild the table.
|
||||
|
||||
PRAGMA foreign_keys=off;
|
||||
|
||||
CREATE TABLE \`task__new\` (
|
||||
\`id\` integer PRIMARY KEY NOT NULL,
|
||||
\`branch_name\` text,
|
||||
\`title\` text,
|
||||
|
|
@ -134,100 +26,33 @@ CREATE TABLE \`task__new\` (
|
|||
\`agent_type\` text DEFAULT 'claude',
|
||||
\`pr_submitted\` integer DEFAULT 0,
|
||||
\`created_at\` integer NOT NULL,
|
||||
\`updated_at\` integer NOT NULL,
|
||||
CONSTRAINT "task_singleton_id_check" CHECK("task"."id" = 1)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE \`task_runtime\` (
|
||||
\`id\` integer PRIMARY KEY NOT NULL,
|
||||
\`active_sandbox_id\` text,
|
||||
\`active_session_id\` text,
|
||||
\`active_switch_target\` text,
|
||||
\`active_cwd\` text,
|
||||
\`status_message\` text,
|
||||
\`updated_at\` integer NOT NULL,
|
||||
CONSTRAINT "task_runtime_singleton_id_check" CHECK("task_runtime"."id" = 1)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE \`task_sandboxes\` (
|
||||
\`sandbox_id\` text PRIMARY KEY NOT NULL,
|
||||
\`provider_id\` text NOT NULL,
|
||||
\`sandbox_actor_id\` text,
|
||||
\`switch_target\` text NOT NULL,
|
||||
\`cwd\` text,
|
||||
\`status_message\` text,
|
||||
\`created_at\` integer NOT NULL,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO \`task__new\` (
|
||||
\`id\`,
|
||||
\`branch_name\`,
|
||||
\`title\`,
|
||||
\`task\`,
|
||||
\`provider_id\`,
|
||||
\`status\`,
|
||||
\`agent_type\`,
|
||||
\`pr_submitted\`,
|
||||
\`created_at\`,
|
||||
\`updated_at\`
|
||||
)
|
||||
SELECT
|
||||
\`id\`,
|
||||
\`branch_name\`,
|
||||
\`title\`,
|
||||
\`task\`,
|
||||
\`provider_id\`,
|
||||
\`status\`,
|
||||
\`agent_type\`,
|
||||
\`pr_submitted\`,
|
||||
\`created_at\`,
|
||||
\`updated_at\`
|
||||
FROM \`task\`;
|
||||
|
||||
DROP TABLE \`task\`;
|
||||
ALTER TABLE \`task__new\` RENAME TO \`task\`;
|
||||
|
||||
PRAGMA foreign_keys=on;
|
||||
|
||||
`,
|
||||
m0004: `-- Fix: make branch_name/title nullable during initial "naming" stage.
|
||||
-- 0003 was missing statement breakpoints, so drizzle's migrator marked it applied without executing all statements.
|
||||
-- Rebuild the table again with proper statement breakpoints.
|
||||
|
||||
PRAGMA foreign_keys=off;
|
||||
--> statement-breakpoint
|
||||
|
||||
DROP TABLE IF EXISTS \`task__new\`;
|
||||
--> statement-breakpoint
|
||||
|
||||
CREATE TABLE \`task__new\` (
|
||||
\`id\` integer PRIMARY KEY NOT NULL,
|
||||
\`branch_name\` text,
|
||||
\`title\` text,
|
||||
\`task\` text NOT NULL,
|
||||
\`provider_id\` text NOT NULL,
|
||||
\`status\` text NOT NULL,
|
||||
\`agent_type\` text DEFAULT 'claude',
|
||||
\`pr_submitted\` integer DEFAULT 0,
|
||||
\`created_at\` integer NOT NULL,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
|
||||
INSERT INTO \`task__new\` (
|
||||
\`id\`,
|
||||
\`branch_name\`,
|
||||
\`title\`,
|
||||
\`task\`,
|
||||
\`provider_id\`,
|
||||
\`status\`,
|
||||
\`agent_type\`,
|
||||
\`pr_submitted\`,
|
||||
\`created_at\`,
|
||||
\`updated_at\`
|
||||
)
|
||||
SELECT
|
||||
\`id\`,
|
||||
\`branch_name\`,
|
||||
\`title\`,
|
||||
\`task\`,
|
||||
\`provider_id\`,
|
||||
\`status\`,
|
||||
\`agent_type\`,
|
||||
\`pr_submitted\`,
|
||||
\`created_at\`,
|
||||
\`updated_at\`
|
||||
FROM \`task\`;
|
||||
--> statement-breakpoint
|
||||
|
||||
DROP TABLE \`task\`;
|
||||
--> statement-breakpoint
|
||||
|
||||
ALTER TABLE \`task__new\` RENAME TO \`task\`;
|
||||
--> statement-breakpoint
|
||||
|
||||
PRAGMA foreign_keys=on;
|
||||
`,
|
||||
m0005: `ALTER TABLE \`task_sandboxes\` ADD \`sandbox_actor_id\` text;`,
|
||||
m0006: `CREATE TABLE \`task_workbench_sessions\` (
|
||||
CREATE TABLE \`task_workbench_sessions\` (
|
||||
\`session_id\` text PRIMARY KEY NOT NULL,
|
||||
\`session_name\` text NOT NULL,
|
||||
\`model\` text NOT NULL,
|
||||
|
|
@ -240,6 +65,7 @@ PRAGMA foreign_keys=on;
|
|||
\`thinking_since_ms\` integer,
|
||||
\`created_at\` integer NOT NULL,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);`,
|
||||
);
|
||||
`,
|
||||
} as const,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,28 +1,37 @@
|
|||
import { integer, sqliteTable, text } from "rivetkit/db/drizzle";
|
||||
import { check, integer, sqliteTable, text } from "rivetkit/db/drizzle";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
// SQLite is per task actor instance, so these tables only ever store one row (id=1).
|
||||
export const task = sqliteTable("task", {
|
||||
id: integer("id").primaryKey(),
|
||||
branchName: text("branch_name"),
|
||||
title: text("title"),
|
||||
task: text("task").notNull(),
|
||||
providerId: text("provider_id").notNull(),
|
||||
status: text("status").notNull(),
|
||||
agentType: text("agent_type").default("claude"),
|
||||
prSubmitted: integer("pr_submitted").default(0),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
export const task = sqliteTable(
|
||||
"task",
|
||||
{
|
||||
id: integer("id").primaryKey(),
|
||||
branchName: text("branch_name"),
|
||||
title: text("title"),
|
||||
task: text("task").notNull(),
|
||||
providerId: text("provider_id").notNull(),
|
||||
status: text("status").notNull(),
|
||||
agentType: text("agent_type").default("claude"),
|
||||
prSubmitted: integer("pr_submitted").default(0),
|
||||
createdAt: integer("created_at").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
},
|
||||
(table) => [check("task_singleton_id_check", sql`${table.id} = 1`)],
|
||||
);
|
||||
|
||||
export const taskRuntime = sqliteTable("task_runtime", {
|
||||
id: integer("id").primaryKey(),
|
||||
activeSandboxId: text("active_sandbox_id"),
|
||||
activeSessionId: text("active_session_id"),
|
||||
activeSwitchTarget: text("active_switch_target"),
|
||||
activeCwd: text("active_cwd"),
|
||||
statusMessage: text("status_message"),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
export const taskRuntime = sqliteTable(
|
||||
"task_runtime",
|
||||
{
|
||||
id: integer("id").primaryKey(),
|
||||
activeSandboxId: text("active_sandbox_id"),
|
||||
activeSessionId: text("active_session_id"),
|
||||
activeSwitchTarget: text("active_switch_target"),
|
||||
activeCwd: text("active_cwd"),
|
||||
statusMessage: text("status_message"),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
},
|
||||
(table) => [check("task_runtime_singleton_id_check", sql`${table.id} = 1`)],
|
||||
);
|
||||
|
||||
export const taskSandboxes = sqliteTable("task_sandboxes", {
|
||||
sandboxId: text("sandbox_id").notNull().primaryKey(),
|
||||
|
|
@ -41,6 +50,7 @@ export const taskWorkbenchSessions = sqliteTable("task_workbench_sessions", {
|
|||
model: text("model").notNull(),
|
||||
unread: integer("unread").notNull().default(0),
|
||||
draftText: text("draft_text").notNull().default(""),
|
||||
// Structured by the workbench composer attachment payload format.
|
||||
draftAttachmentsJson: text("draft_attachments_json").notNull().default("[]"),
|
||||
draftUpdatedAt: integer("draft_updated_at"),
|
||||
created: integer("created").notNull().default(1),
|
||||
|
|
|
|||
|
|
@ -111,6 +111,8 @@ export const task = actor({
|
|||
db: taskDb,
|
||||
queues: Object.fromEntries(TASK_QUEUE_NAMES.map((name) => [name, queue()])),
|
||||
options: {
|
||||
name: "Task",
|
||||
icon: "wrench",
|
||||
actionTimeout: 5 * 60_000,
|
||||
},
|
||||
createState: (_c, input: TaskInput) => ({
|
||||
|
|
|
|||
|
|
@ -62,7 +62,13 @@ interface RepoOverviewInput {
|
|||
repoId: string;
|
||||
}
|
||||
|
||||
const WORKSPACE_QUEUE_NAMES = ["workspace.command.addRepo", "workspace.command.createTask", "workspace.command.refreshProviderProfiles"] as const;
|
||||
const WORKSPACE_QUEUE_NAMES = [
|
||||
"workspace.command.addRepo",
|
||||
"workspace.command.createTask",
|
||||
"workspace.command.refreshProviderProfiles",
|
||||
"workspace.command.syncGithubOrganizationRepos",
|
||||
"workspace.command.syncGithubSession",
|
||||
] as const;
|
||||
const SANDBOX_AGENT_REPO = "rivet-dev/sandbox-agent";
|
||||
|
||||
type WorkspaceQueueName = (typeof WORKSPACE_QUEUE_NAMES)[number];
|
||||
|
|
@ -366,6 +372,33 @@ export async function runWorkspaceWorkflow(ctx: any): Promise<void> {
|
|||
refreshProviderProfilesMutation(loopCtx, msg.body as RefreshProviderProfilesCommand),
|
||||
);
|
||||
await msg.complete({ ok: true });
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
|
||||
if (msg.name === "workspace.command.syncGithubSession") {
|
||||
await loopCtx.step({
|
||||
name: "workspace-sync-github-session",
|
||||
timeout: 60_000,
|
||||
run: async () => {
|
||||
const { syncGithubOrganizations } = await import("./app-shell.js");
|
||||
await syncGithubOrganizations(loopCtx, msg.body as { sessionId: string; accessToken: string });
|
||||
},
|
||||
});
|
||||
await msg.complete({ ok: true });
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
|
||||
if (msg.name === "workspace.command.syncGithubOrganizationRepos") {
|
||||
await loopCtx.step({
|
||||
name: "workspace-sync-github-organization-repos",
|
||||
timeout: 60_000,
|
||||
run: async () => {
|
||||
const { syncGithubOrganizationRepos } = await import("./app-shell.js");
|
||||
await syncGithubOrganizationRepos(loopCtx, msg.body as { sessionId: string; organizationId: string });
|
||||
},
|
||||
});
|
||||
await msg.complete({ ok: true });
|
||||
return Loop.continue(undefined);
|
||||
}
|
||||
|
||||
return Loop.continue(undefined);
|
||||
|
|
|
|||
|
|
@ -11,16 +11,25 @@ import type {
|
|||
UpdateFoundryOrganizationProfileInput,
|
||||
} from "@sandbox-agent/foundry-shared";
|
||||
import { getActorRuntimeContext } from "../context.js";
|
||||
import { getOrCreateWorkspace } from "../handles.js";
|
||||
import { getOrCreateWorkspace, selfWorkspace } from "../handles.js";
|
||||
import { GitHubAppError } from "../../services/app-github.js";
|
||||
import { repoIdFromRemote, repoLabelFromRemote } from "../../services/repo.js";
|
||||
import { logger } from "../../logging.js";
|
||||
import { appSessions, invoices, organizationMembers, organizationProfile, repos, seatAssignments, stripeLookup } from "./db/schema.js";
|
||||
|
||||
export const APP_SHELL_WORKSPACE_ID = "app";
|
||||
|
||||
const githubWebhookLogger = logger.child({
|
||||
scope: "github-webhook",
|
||||
});
|
||||
|
||||
const PROFILE_ROW_ID = "profile";
|
||||
const OAUTH_TTL_MS = 10 * 60_000;
|
||||
|
||||
function roundDurationMs(start: number): number {
|
||||
return Math.round((performance.now() - start) * 100) / 100;
|
||||
}
|
||||
|
||||
function assertAppWorkspace(c: any): void {
|
||||
if (c.state.workspaceId !== APP_SHELL_WORKSPACE_ID) {
|
||||
throw new Error(`App shell action requires workspace ${APP_SHELL_WORKSPACE_ID}, got ${c.state.workspaceId}`);
|
||||
|
|
@ -222,22 +231,67 @@ async function getOrganizationState(workspace: any) {
|
|||
|
||||
async function buildAppSnapshot(c: any, sessionId: string): Promise<FoundryAppSnapshot> {
|
||||
assertAppWorkspace(c);
|
||||
const startedAt = performance.now();
|
||||
const session = await requireAppSessionRow(c, sessionId);
|
||||
const eligibleOrganizationIds = parseEligibleOrganizationIds(session.eligibleOrganizationIdsJson);
|
||||
|
||||
const organizations: FoundryOrganization[] = [];
|
||||
for (const organizationId of eligibleOrganizationIds) {
|
||||
try {
|
||||
const workspace = await getOrCreateWorkspace(c, organizationId);
|
||||
const organizationState = await getOrganizationState(workspace);
|
||||
organizations.push(organizationState.snapshot);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (!message.includes("Actor not found")) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
logger.info(
|
||||
{
|
||||
sessionId,
|
||||
workspaceId: c.state.workspaceId,
|
||||
eligibleOrganizationCount: eligibleOrganizationIds.length,
|
||||
eligibleOrganizationIds,
|
||||
},
|
||||
"build_app_snapshot_started",
|
||||
);
|
||||
|
||||
const organizations = (
|
||||
await Promise.all(
|
||||
eligibleOrganizationIds.map(async (organizationId) => {
|
||||
const organizationStartedAt = performance.now();
|
||||
try {
|
||||
const workspace = await getOrCreateWorkspace(c, organizationId);
|
||||
const organizationState = await getOrganizationState(workspace);
|
||||
logger.info(
|
||||
{
|
||||
sessionId,
|
||||
workspaceId: c.state.workspaceId,
|
||||
organizationId,
|
||||
durationMs: roundDurationMs(organizationStartedAt),
|
||||
},
|
||||
"build_app_snapshot_organization_completed",
|
||||
);
|
||||
return organizationState.snapshot;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (!message.includes("Actor not found")) {
|
||||
logger.error(
|
||||
{
|
||||
sessionId,
|
||||
workspaceId: c.state.workspaceId,
|
||||
organizationId,
|
||||
durationMs: roundDurationMs(organizationStartedAt),
|
||||
errorMessage: message,
|
||||
errorStack: error instanceof Error ? error.stack : undefined,
|
||||
},
|
||||
"build_app_snapshot_organization_failed",
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
logger.info(
|
||||
{
|
||||
sessionId,
|
||||
workspaceId: c.state.workspaceId,
|
||||
organizationId,
|
||||
durationMs: roundDurationMs(organizationStartedAt),
|
||||
},
|
||||
"build_app_snapshot_organization_missing",
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
)
|
||||
).filter((organization): organization is FoundryOrganization => organization !== null);
|
||||
|
||||
const currentUser: FoundryUser | null = session.currentUserId
|
||||
? {
|
||||
|
|
@ -257,7 +311,7 @@ async function buildAppSnapshot(c: any, sessionId: string): Promise<FoundryAppSn
|
|||
? (organizations[0]?.id ?? null)
|
||||
: null;
|
||||
|
||||
return {
|
||||
const snapshot = {
|
||||
auth: {
|
||||
status: currentUser ? "signed_in" : "signed_out",
|
||||
currentUserId: currentUser?.id ?? null,
|
||||
|
|
@ -275,6 +329,19 @@ async function buildAppSnapshot(c: any, sessionId: string): Promise<FoundryAppSn
|
|||
users: currentUser ? [currentUser] : [],
|
||||
organizations,
|
||||
};
|
||||
|
||||
logger.info(
|
||||
{
|
||||
sessionId,
|
||||
workspaceId: c.state.workspaceId,
|
||||
eligibleOrganizationCount: eligibleOrganizationIds.length,
|
||||
organizationCount: organizations.length,
|
||||
durationMs: roundDurationMs(startedAt),
|
||||
},
|
||||
"build_app_snapshot_completed",
|
||||
);
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async function requireSignedInSession(c: any, sessionId: string) {
|
||||
|
|
@ -364,11 +431,50 @@ async function safeListInstallations(accessToken: string): Promise<any[]> {
|
|||
}
|
||||
}
|
||||
|
||||
async function syncGithubSessionFromToken(c: any, sessionId: string, accessToken: string): Promise<{ sessionId: string; redirectTo: string }> {
|
||||
/**
|
||||
* Fast path: resolve viewer identity, store user + token in the session,
|
||||
* and return the redirect URL. Does NOT sync organizations — that work is
|
||||
* deferred to `syncGithubOrganizations` via the workflow queue so the HTTP
|
||||
* callback can respond before any proxy timeout triggers a retry.
|
||||
*/
|
||||
async function initGithubSession(c: any, sessionId: string, accessToken: string, scopes: string[]): Promise<{ sessionId: string; redirectTo: string }> {
|
||||
assertAppWorkspace(c);
|
||||
const { appShell } = getActorRuntimeContext();
|
||||
const viewer = await appShell.github.getViewer(accessToken);
|
||||
const userId = `user-${slugify(viewer.login)}`;
|
||||
|
||||
await updateAppSession(c, sessionId, {
|
||||
currentUserId: userId,
|
||||
currentUserName: viewer.name || viewer.login,
|
||||
currentUserEmail: viewer.email ?? `${viewer.login}@users.noreply.github.com`,
|
||||
currentUserGithubLogin: viewer.login,
|
||||
currentUserRoleLabel: "GitHub user",
|
||||
githubAccessToken: accessToken,
|
||||
githubScope: scopes.join(","),
|
||||
oauthState: null,
|
||||
oauthStateExpiresAt: null,
|
||||
});
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
redirectTo: `${appShell.appUrl}/organizations?foundrySession=${encodeURIComponent(sessionId)}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Slow path: list GitHub orgs + installations, sync each org workspace,
|
||||
* and update the session's eligible organization list. Called from the
|
||||
* workflow queue so it runs in the background after the callback has
|
||||
* already returned a redirect to the browser.
|
||||
*
|
||||
* Also used synchronously by bootstrapAppGithubSession (dev-only) where
|
||||
* proxy timeouts are not a concern.
|
||||
*/
|
||||
export async function syncGithubOrganizations(c: any, input: { sessionId: string; accessToken: string }): Promise<void> {
|
||||
assertAppWorkspace(c);
|
||||
const { appShell } = getActorRuntimeContext();
|
||||
const { sessionId, accessToken } = input;
|
||||
const session = await requireAppSessionRow(c, sessionId);
|
||||
const token = { accessToken, scopes: splitScopes(session.githubScope) };
|
||||
const viewer = await appShell.github.getViewer(accessToken);
|
||||
const organizations = await safeListOrganizations(accessToken);
|
||||
const installations = await safeListInstallations(accessToken);
|
||||
|
|
@ -419,24 +525,75 @@ async function syncGithubSessionFromToken(c: any, sessionId: string, accessToken
|
|||
? (linkedOrganizationIds[0] ?? null)
|
||||
: null;
|
||||
|
||||
await updateAppSession(c, session.id, {
|
||||
currentUserId: userId,
|
||||
currentUserName: viewer.name || viewer.login,
|
||||
currentUserEmail: viewer.email ?? `${viewer.login}@users.noreply.github.com`,
|
||||
currentUserGithubLogin: viewer.login,
|
||||
currentUserRoleLabel: "GitHub user",
|
||||
await updateAppSession(c, sessionId, {
|
||||
eligibleOrganizationIdsJson: encodeEligibleOrganizationIds(linkedOrganizationIds),
|
||||
activeOrganizationId,
|
||||
githubAccessToken: accessToken,
|
||||
githubScope: token.scopes.join(","),
|
||||
oauthState: null,
|
||||
oauthStateExpiresAt: null,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
sessionId: session.id,
|
||||
redirectTo: `${appShell.appUrl}/organizations?foundrySession=${encodeURIComponent(session.id)}`,
|
||||
};
|
||||
export async function syncGithubOrganizationRepos(c: any, input: { sessionId: string; organizationId: string }): Promise<void> {
|
||||
assertAppWorkspace(c);
|
||||
const session = await requireSignedInSession(c, input.sessionId);
|
||||
requireEligibleOrganization(session, input.organizationId);
|
||||
|
||||
const { appShell } = getActorRuntimeContext();
|
||||
const workspace = await getOrCreateWorkspace(c, input.organizationId);
|
||||
const organization = await getOrganizationState(workspace);
|
||||
|
||||
try {
|
||||
let repositories;
|
||||
let installationStatus = organization.snapshot.github.installationStatus;
|
||||
|
||||
if (organization.snapshot.kind === "personal") {
|
||||
repositories = await appShell.github.listUserRepositories(session.githubAccessToken);
|
||||
installationStatus = "connected";
|
||||
} else if (organization.githubInstallationId) {
|
||||
try {
|
||||
repositories = await appShell.github.listInstallationRepositories(organization.githubInstallationId);
|
||||
} catch (error) {
|
||||
if (!(error instanceof GitHubAppError) || (error.status !== 403 && error.status !== 404)) {
|
||||
throw error;
|
||||
}
|
||||
repositories = (await appShell.github.listUserRepositories(session.githubAccessToken)).filter((repository) =>
|
||||
repository.fullName.startsWith(`${organization.githubLogin}/`),
|
||||
);
|
||||
installationStatus = "reconnect_required";
|
||||
}
|
||||
} else {
|
||||
repositories = (await appShell.github.listUserRepositories(session.githubAccessToken)).filter((repository) =>
|
||||
repository.fullName.startsWith(`${organization.githubLogin}/`),
|
||||
);
|
||||
installationStatus = "reconnect_required";
|
||||
}
|
||||
|
||||
await workspace.applyOrganizationSyncCompleted({
|
||||
repositories,
|
||||
installationStatus,
|
||||
lastSyncLabel: repositories.length > 0 ? "Synced just now" : "No repositories available",
|
||||
});
|
||||
} catch (error) {
|
||||
const installationStatus =
|
||||
error instanceof GitHubAppError && (error.status === 403 || error.status === 404)
|
||||
? "reconnect_required"
|
||||
: organization.snapshot.github.installationStatus;
|
||||
await workspace.markOrganizationSyncFailed({
|
||||
message: error instanceof Error ? error.message : "GitHub import failed",
|
||||
installationStatus,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Full synchronous sync: init session + sync orgs in one call.
|
||||
* Used by bootstrapAppGithubSession (dev-only) where there is no proxy
|
||||
* timeout concern and we want the session fully populated before returning.
|
||||
*/
|
||||
async function syncGithubSessionFromToken(c: any, sessionId: string, accessToken: string): Promise<{ sessionId: string; redirectTo: string }> {
|
||||
const session = await requireAppSessionRow(c, sessionId);
|
||||
const scopes = splitScopes(session.githubScope);
|
||||
const result = await initGithubSession(c, sessionId, accessToken, scopes);
|
||||
await syncGithubOrganizations(c, { sessionId, accessToken });
|
||||
return result;
|
||||
}
|
||||
|
||||
async function readOrganizationProfileRow(c: any) {
|
||||
|
|
@ -489,13 +646,14 @@ async function listOrganizationRepoCatalog(c: any): Promise<string[]> {
|
|||
}
|
||||
|
||||
async function buildOrganizationState(c: any) {
|
||||
const startedAt = performance.now();
|
||||
const row = await requireOrganizationProfileRow(c);
|
||||
const repoCatalog = await listOrganizationRepoCatalog(c);
|
||||
const members = await listOrganizationMembers(c);
|
||||
const seatAssignmentEmails = await listOrganizationSeatAssignments(c);
|
||||
const invoiceRows = await listOrganizationInvoices(c);
|
||||
|
||||
return {
|
||||
const state = {
|
||||
id: c.state.workspaceId,
|
||||
workspaceId: c.state.workspaceId,
|
||||
kind: row.kind,
|
||||
|
|
@ -540,6 +698,21 @@ async function buildOrganizationState(c: any) {
|
|||
repoCatalog,
|
||||
},
|
||||
};
|
||||
|
||||
logger.info(
|
||||
{
|
||||
workspaceId: c.state.workspaceId,
|
||||
githubLogin: row.githubLogin,
|
||||
repoCount: repoCatalog.length,
|
||||
memberCount: members.length,
|
||||
seatAssignmentCount: seatAssignmentEmails.length,
|
||||
invoiceCount: invoiceRows.length,
|
||||
durationMs: roundDurationMs(startedAt),
|
||||
},
|
||||
"build_organization_state_completed",
|
||||
);
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
async function applySubscriptionState(
|
||||
|
|
@ -621,11 +794,37 @@ export const workspaceAppActions = {
|
|||
throw new Error("GitHub OAuth state is invalid or expired");
|
||||
}
|
||||
|
||||
const token = await appShell.github.exchangeCode(input.code);
|
||||
// Clear state before exchangeCode — GitHub codes are single-use and
|
||||
// duplicate callback requests (from proxy retries or user refresh)
|
||||
// must fail the state check rather than attempt a second exchange.
|
||||
// See research/friction/general.mdx 2026-03-13 entry.
|
||||
await updateAppSession(c, session.id, {
|
||||
githubScope: token.scopes.join(","),
|
||||
oauthState: null,
|
||||
oauthStateExpiresAt: null,
|
||||
});
|
||||
return await syncGithubSessionFromToken(c, session.id, token.accessToken);
|
||||
|
||||
const token = await appShell.github.exchangeCode(input.code);
|
||||
|
||||
// Fast path: store token + user identity and return the redirect
|
||||
// immediately. The slow org sync (list orgs, list installations,
|
||||
// sync each workspace) runs in the workflow queue so the HTTP
|
||||
// response lands before any proxy/infra timeout triggers a retry.
|
||||
// The frontend already polls when it sees syncStatus === "syncing".
|
||||
const result = await initGithubSession(c, session.id, token.accessToken, token.scopes);
|
||||
|
||||
// Enqueue the slow org sync to the workflow. fire-and-forget (wait: false)
|
||||
// because the redirect does not depend on org data — the frontend will
|
||||
// poll getAppSnapshot until organizations are populated.
|
||||
const self = selfWorkspace(c);
|
||||
await self.send(
|
||||
"workspace.command.syncGithubSession",
|
||||
{ sessionId: session.id, accessToken: token.accessToken },
|
||||
{
|
||||
wait: false,
|
||||
},
|
||||
);
|
||||
|
||||
return result;
|
||||
},
|
||||
|
||||
async bootstrapAppGithubSession(c: any, input: { accessToken: string; sessionId?: string | null }): Promise<{ sessionId: string; redirectTo: string }> {
|
||||
|
|
@ -697,7 +896,22 @@ export const workspaceAppActions = {
|
|||
const workspace = await getOrCreateWorkspace(c, input.organizationId);
|
||||
const organization = await getOrganizationState(workspace);
|
||||
if (organization.snapshot.github.syncStatus !== "synced") {
|
||||
return await workspaceAppActions.triggerAppRepoImport(c, input);
|
||||
if (organization.snapshot.github.syncStatus !== "syncing") {
|
||||
await workspace.markOrganizationSyncStarted({
|
||||
label: "Importing repository catalog...",
|
||||
});
|
||||
|
||||
const self = selfWorkspace(c);
|
||||
await self.send(
|
||||
"workspace.command.syncGithubOrganizationRepos",
|
||||
{ sessionId: input.sessionId, organizationId: input.organizationId },
|
||||
{
|
||||
wait: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return await buildAppSnapshot(c, input.sessionId);
|
||||
}
|
||||
return await buildAppSnapshot(c, input.sessionId);
|
||||
},
|
||||
|
|
@ -723,55 +937,24 @@ export const workspaceAppActions = {
|
|||
const session = await requireSignedInSession(c, input.sessionId);
|
||||
requireEligibleOrganization(session, input.organizationId);
|
||||
|
||||
const { appShell } = getActorRuntimeContext();
|
||||
const workspace = await getOrCreateWorkspace(c, input.organizationId);
|
||||
const organization = await getOrganizationState(workspace);
|
||||
if (organization.snapshot.github.syncStatus === "syncing") {
|
||||
return await buildAppSnapshot(c, input.sessionId);
|
||||
}
|
||||
|
||||
await workspace.markOrganizationSyncStarted({
|
||||
label: "Importing repository catalog...",
|
||||
});
|
||||
|
||||
try {
|
||||
let repositories;
|
||||
let installationStatus = organization.snapshot.github.installationStatus;
|
||||
|
||||
if (organization.snapshot.kind === "personal") {
|
||||
repositories = await appShell.github.listUserRepositories(session.githubAccessToken);
|
||||
installationStatus = "connected";
|
||||
} else if (organization.githubInstallationId) {
|
||||
try {
|
||||
repositories = await appShell.github.listInstallationRepositories(organization.githubInstallationId);
|
||||
} catch (error) {
|
||||
if (!(error instanceof GitHubAppError) || (error.status !== 403 && error.status !== 404)) {
|
||||
throw error;
|
||||
}
|
||||
repositories = (await appShell.github.listUserRepositories(session.githubAccessToken)).filter((repository) =>
|
||||
repository.fullName.startsWith(`${organization.githubLogin}/`),
|
||||
);
|
||||
installationStatus = "reconnect_required";
|
||||
}
|
||||
} else {
|
||||
repositories = (await appShell.github.listUserRepositories(session.githubAccessToken)).filter((repository) =>
|
||||
repository.fullName.startsWith(`${organization.githubLogin}/`),
|
||||
);
|
||||
installationStatus = "reconnect_required";
|
||||
}
|
||||
|
||||
await workspace.applyOrganizationSyncCompleted({
|
||||
repositories,
|
||||
installationStatus,
|
||||
lastSyncLabel: repositories.length > 0 ? "Synced just now" : "No repositories available",
|
||||
});
|
||||
} catch (error) {
|
||||
const installationStatus =
|
||||
error instanceof GitHubAppError && (error.status === 403 || error.status === 404)
|
||||
? "reconnect_required"
|
||||
: organization.snapshot.github.installationStatus;
|
||||
await workspace.markOrganizationSyncFailed({
|
||||
message: error instanceof Error ? error.message : "GitHub import failed",
|
||||
installationStatus,
|
||||
});
|
||||
}
|
||||
const self = selfWorkspace(c);
|
||||
await self.send(
|
||||
"workspace.command.syncGithubOrganizationRepos",
|
||||
{ sessionId: input.sessionId, organizationId: input.organizationId },
|
||||
{
|
||||
wait: false,
|
||||
},
|
||||
);
|
||||
|
||||
return await buildAppSnapshot(c, input.sessionId);
|
||||
},
|
||||
|
|
@ -832,7 +1015,7 @@ export const workspaceAppActions = {
|
|||
customerId,
|
||||
customerEmail: session.currentUserEmail,
|
||||
planId: input.planId,
|
||||
successUrl: `${appShell.appUrl}/api/rivet/app/billing/checkout/complete?organizationId=${encodeURIComponent(
|
||||
successUrl: `${appShell.apiUrl}/v1/billing/checkout/complete?organizationId=${encodeURIComponent(
|
||||
input.organizationId,
|
||||
)}&foundrySession=${encodeURIComponent(input.sessionId)}&session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancelUrl: `${appShell.appUrl}/organizations/${input.organizationId}/billing?foundrySession=${encodeURIComponent(input.sessionId)}`,
|
||||
|
|
@ -1014,7 +1197,14 @@ export const workspaceAppActions = {
|
|||
const accountLogin = body.installation?.account?.login;
|
||||
const accountType = body.installation?.account?.type;
|
||||
if (!accountLogin) {
|
||||
console.log(`[github-webhook] Ignoring ${event}.${body.action ?? ""}: no installation account`);
|
||||
githubWebhookLogger.info(
|
||||
{
|
||||
event,
|
||||
action: body.action ?? null,
|
||||
reason: "missing_installation_account",
|
||||
},
|
||||
"ignored",
|
||||
);
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
|
|
@ -1022,7 +1212,15 @@ export const workspaceAppActions = {
|
|||
const organizationId = organizationWorkspaceId(kind, accountLogin);
|
||||
|
||||
if (event === "installation" && (body.action === "created" || body.action === "deleted" || body.action === "suspend" || body.action === "unsuspend")) {
|
||||
console.log(`[github-webhook] ${event}.${body.action} for ${accountLogin} (org=${organizationId})`);
|
||||
githubWebhookLogger.info(
|
||||
{
|
||||
event,
|
||||
action: body.action,
|
||||
accountLogin,
|
||||
organizationId,
|
||||
},
|
||||
"installation_event",
|
||||
);
|
||||
if (body.action === "deleted") {
|
||||
const workspace = await getOrCreateWorkspace(c, organizationId);
|
||||
await workspace.applyGithubInstallationRemoved({});
|
||||
|
|
@ -1036,8 +1234,16 @@ export const workspaceAppActions = {
|
|||
}
|
||||
|
||||
if (event === "installation_repositories") {
|
||||
console.log(
|
||||
`[github-webhook] ${event}.${body.action} for ${accountLogin}: +${body.repositories_added?.length ?? 0} -${body.repositories_removed?.length ?? 0}`,
|
||||
githubWebhookLogger.info(
|
||||
{
|
||||
event,
|
||||
action: body.action ?? null,
|
||||
accountLogin,
|
||||
organizationId,
|
||||
repositoriesAdded: body.repositories_added?.length ?? 0,
|
||||
repositoriesRemoved: body.repositories_removed?.length ?? 0,
|
||||
},
|
||||
"repository_membership_changed",
|
||||
);
|
||||
const workspace = await getOrCreateWorkspace(c, organizationId);
|
||||
await workspace.applyGithubRepositoryChanges({
|
||||
|
|
@ -1063,13 +1269,30 @@ export const workspaceAppActions = {
|
|||
) {
|
||||
const repoFullName = body.repository?.full_name;
|
||||
if (repoFullName) {
|
||||
console.log(`[github-webhook] ${event}.${body.action ?? ""} for ${repoFullName}`);
|
||||
githubWebhookLogger.info(
|
||||
{
|
||||
event,
|
||||
action: body.action ?? null,
|
||||
accountLogin,
|
||||
organizationId,
|
||||
repoFullName,
|
||||
},
|
||||
"repository_event",
|
||||
);
|
||||
// TODO: Dispatch to GitHubStateActor / downstream actors
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
console.log(`[github-webhook] Unhandled event: ${event}.${body.action ?? ""}`);
|
||||
githubWebhookLogger.info(
|
||||
{
|
||||
event,
|
||||
action: body.action ?? null,
|
||||
accountLogin,
|
||||
organizationId,
|
||||
},
|
||||
"unhandled_event",
|
||||
);
|
||||
return { ok: true };
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,97 @@
|
|||
CREATE TABLE `app_sessions` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`current_user_id` text,
|
||||
`current_user_name` text,
|
||||
`current_user_email` text,
|
||||
`current_user_github_login` text,
|
||||
`current_user_role_label` text,
|
||||
`eligible_organization_ids_json` text NOT NULL,
|
||||
`active_organization_id` text,
|
||||
`github_access_token` text,
|
||||
`github_scope` text NOT NULL,
|
||||
`starter_repo_status` text NOT NULL,
|
||||
`starter_repo_starred_at` integer,
|
||||
`starter_repo_skipped_at` integer,
|
||||
`oauth_state` text,
|
||||
`oauth_state_expires_at` integer,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `invoices` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`label` text NOT NULL,
|
||||
`issued_at` text NOT NULL,
|
||||
`amount_usd` integer NOT NULL,
|
||||
`status` text NOT NULL,
|
||||
`created_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `organization_members` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`email` text NOT NULL,
|
||||
`role` text NOT NULL,
|
||||
`state` text NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `organization_profile` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`kind` text NOT NULL,
|
||||
`github_account_id` text NOT NULL,
|
||||
`github_login` text NOT NULL,
|
||||
`github_account_type` text NOT NULL,
|
||||
`display_name` text NOT NULL,
|
||||
`slug` text NOT NULL,
|
||||
`primary_domain` text NOT NULL,
|
||||
`default_model` text NOT NULL,
|
||||
`auto_import_repos` integer NOT NULL,
|
||||
`repo_import_status` text NOT NULL,
|
||||
`github_connected_account` text NOT NULL,
|
||||
`github_installation_status` text NOT NULL,
|
||||
`github_sync_status` text NOT NULL,
|
||||
`github_installation_id` integer,
|
||||
`github_last_sync_label` text NOT NULL,
|
||||
`github_last_sync_at` integer,
|
||||
`stripe_customer_id` text,
|
||||
`stripe_subscription_id` text,
|
||||
`stripe_price_id` text,
|
||||
`billing_plan_id` text NOT NULL,
|
||||
`billing_status` text NOT NULL,
|
||||
`billing_seats_included` integer NOT NULL,
|
||||
`billing_trial_ends_at` text,
|
||||
`billing_renewal_at` text,
|
||||
`billing_payment_method_label` text NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `provider_profiles` (
|
||||
`provider_id` text PRIMARY KEY NOT NULL,
|
||||
`profile_json` text NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `repos` (
|
||||
`repo_id` text PRIMARY KEY NOT NULL,
|
||||
`remote_url` text NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `seat_assignments` (
|
||||
`email` text PRIMARY KEY NOT NULL,
|
||||
`created_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `stripe_lookup` (
|
||||
`lookup_key` text PRIMARY KEY NOT NULL,
|
||||
`organization_id` text NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE `task_lookup` (
|
||||
`task_id` text PRIMARY KEY NOT NULL,
|
||||
`repo_id` text NOT NULL
|
||||
);
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
CREATE TABLE `provider_profiles` (
|
||||
`provider_id` text PRIMARY KEY NOT NULL,
|
||||
`profile_json` text NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
CREATE TABLE `repos` (
|
||||
`repo_id` text PRIMARY KEY NOT NULL,
|
||||
`remote_url` text NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
CREATE TABLE `task_lookup` (
|
||||
`task_id` text PRIMARY KEY NOT NULL,
|
||||
`repo_id` text NOT NULL
|
||||
);
|
||||
|
|
@ -1,9 +1,448 @@
|
|||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "a85809c0-65c2-4f99-92ed-34357c9f83d7",
|
||||
"id": "0bef30e4-148a-4fe1-b2ca-a9721893c3ac",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"tables": {
|
||||
"app_sessions": {
|
||||
"name": "app_sessions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"current_user_id": {
|
||||
"name": "current_user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"current_user_name": {
|
||||
"name": "current_user_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"current_user_email": {
|
||||
"name": "current_user_email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"current_user_github_login": {
|
||||
"name": "current_user_github_login",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"current_user_role_label": {
|
||||
"name": "current_user_role_label",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"eligible_organization_ids_json": {
|
||||
"name": "eligible_organization_ids_json",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"active_organization_id": {
|
||||
"name": "active_organization_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"github_access_token": {
|
||||
"name": "github_access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"github_scope": {
|
||||
"name": "github_scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"starter_repo_status": {
|
||||
"name": "starter_repo_status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"starter_repo_starred_at": {
|
||||
"name": "starter_repo_starred_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"starter_repo_skipped_at": {
|
||||
"name": "starter_repo_skipped_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"oauth_state": {
|
||||
"name": "oauth_state",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"oauth_state_expires_at": {
|
||||
"name": "oauth_state_expires_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"invoices": {
|
||||
"name": "invoices",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"label": {
|
||||
"name": "label",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"issued_at": {
|
||||
"name": "issued_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"amount_usd": {
|
||||
"name": "amount_usd",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"organization_members": {
|
||||
"name": "organization_members",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"state": {
|
||||
"name": "state",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"organization_profile": {
|
||||
"name": "organization_profile",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"kind": {
|
||||
"name": "kind",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"github_account_id": {
|
||||
"name": "github_account_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"github_login": {
|
||||
"name": "github_login",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"github_account_type": {
|
||||
"name": "github_account_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"display_name": {
|
||||
"name": "display_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"slug": {
|
||||
"name": "slug",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"primary_domain": {
|
||||
"name": "primary_domain",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"default_model": {
|
||||
"name": "default_model",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"auto_import_repos": {
|
||||
"name": "auto_import_repos",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"repo_import_status": {
|
||||
"name": "repo_import_status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"github_connected_account": {
|
||||
"name": "github_connected_account",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"github_installation_status": {
|
||||
"name": "github_installation_status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"github_sync_status": {
|
||||
"name": "github_sync_status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"github_installation_id": {
|
||||
"name": "github_installation_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"github_last_sync_label": {
|
||||
"name": "github_last_sync_label",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"github_last_sync_at": {
|
||||
"name": "github_last_sync_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"stripe_customer_id": {
|
||||
"name": "stripe_customer_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"stripe_subscription_id": {
|
||||
"name": "stripe_subscription_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"stripe_price_id": {
|
||||
"name": "stripe_price_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"billing_plan_id": {
|
||||
"name": "billing_plan_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"billing_status": {
|
||||
"name": "billing_status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"billing_seats_included": {
|
||||
"name": "billing_seats_included",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"billing_trial_ends_at": {
|
||||
"name": "billing_trial_ends_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"billing_renewal_at": {
|
||||
"name": "billing_renewal_at",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"billing_payment_method_label": {
|
||||
"name": "billing_payment_method_label",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"provider_profiles": {
|
||||
"name": "provider_profiles",
|
||||
"columns": {
|
||||
|
|
@ -34,6 +473,123 @@
|
|||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"repos": {
|
||||
"name": "repos",
|
||||
"columns": {
|
||||
"repo_id": {
|
||||
"name": "repo_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"remote_url": {
|
||||
"name": "remote_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"seat_assignments": {
|
||||
"name": "seat_assignments",
|
||||
"columns": {
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"stripe_lookup": {
|
||||
"name": "stripe_lookup",
|
||||
"columns": {
|
||||
"lookup_key": {
|
||||
"name": "lookup_key",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"organization_id": {
|
||||
"name": "organization_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_lookup": {
|
||||
"name": "task_lookup",
|
||||
"columns": {
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"repo_id": {
|
||||
"name": "repo_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
|
|
|
|||
|
|
@ -1,87 +0,0 @@
|
|||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "450e2fdf-6349-482f-8a68-5bc0f0a9718a",
|
||||
"prevId": "a85809c0-65c2-4f99-92ed-34357c9f83d7",
|
||||
"tables": {
|
||||
"provider_profiles": {
|
||||
"name": "provider_profiles",
|
||||
"columns": {
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"profile_json": {
|
||||
"name": "profile_json",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"repos": {
|
||||
"name": "repos",
|
||||
"columns": {
|
||||
"repo_id": {
|
||||
"name": "repo_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"remote_url": {
|
||||
"name": "remote_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,22 +5,8 @@
|
|||
{
|
||||
"idx": 0,
|
||||
"version": "6",
|
||||
"when": 1770924376525,
|
||||
"tag": "0000_rare_iron_man",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1770947252912,
|
||||
"tag": "0001_sleepy_lady_deathstrike",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "6",
|
||||
"when": 1772668800000,
|
||||
"tag": "0002_tiny_silver_surfer",
|
||||
"when": 1773376221152,
|
||||
"tag": "0000_melted_viper",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -6,68 +6,8 @@ const journal = {
|
|||
entries: [
|
||||
{
|
||||
idx: 0,
|
||||
when: 1770924376525,
|
||||
tag: "0000_rare_iron_man",
|
||||
breakpoints: true,
|
||||
},
|
||||
{
|
||||
idx: 1,
|
||||
when: 1770947252912,
|
||||
tag: "0001_sleepy_lady_deathstrike",
|
||||
breakpoints: true,
|
||||
},
|
||||
{
|
||||
idx: 2,
|
||||
when: 1772668800000,
|
||||
tag: "0002_tiny_silver_surfer",
|
||||
breakpoints: true,
|
||||
},
|
||||
{
|
||||
idx: 3,
|
||||
when: 1773100800000,
|
||||
tag: "0003_app_shell_organization_profile",
|
||||
breakpoints: true,
|
||||
},
|
||||
{
|
||||
idx: 4,
|
||||
when: 1773100800001,
|
||||
tag: "0004_app_shell_organization_members",
|
||||
breakpoints: true,
|
||||
},
|
||||
{
|
||||
idx: 5,
|
||||
when: 1773100800002,
|
||||
tag: "0005_app_shell_seat_assignments",
|
||||
breakpoints: true,
|
||||
},
|
||||
{
|
||||
idx: 6,
|
||||
when: 1773100800003,
|
||||
tag: "0006_app_shell_invoices",
|
||||
breakpoints: true,
|
||||
},
|
||||
{
|
||||
idx: 7,
|
||||
when: 1773100800004,
|
||||
tag: "0007_app_shell_sessions",
|
||||
breakpoints: true,
|
||||
},
|
||||
{
|
||||
idx: 8,
|
||||
when: 1773100800005,
|
||||
tag: "0008_app_shell_stripe_lookup",
|
||||
breakpoints: true,
|
||||
},
|
||||
{
|
||||
idx: 9,
|
||||
when: 1773100800006,
|
||||
tag: "0009_github_sync_status",
|
||||
breakpoints: true,
|
||||
},
|
||||
{
|
||||
idx: 10,
|
||||
when: 1772928000000,
|
||||
tag: "0010_app_session_starter_repo",
|
||||
when: 1773376221152,
|
||||
tag: "0000_melted_viper",
|
||||
breakpoints: true,
|
||||
},
|
||||
],
|
||||
|
|
@ -76,77 +16,7 @@ const journal = {
|
|||
export default {
|
||||
journal,
|
||||
migrations: {
|
||||
m0000: `CREATE TABLE \`provider_profiles\` (
|
||||
\`provider_id\` text PRIMARY KEY NOT NULL,
|
||||
\`profile_json\` text NOT NULL,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
`,
|
||||
m0001: `CREATE TABLE \`repos\` (
|
||||
\`repo_id\` text PRIMARY KEY NOT NULL,
|
||||
\`remote_url\` text NOT NULL,
|
||||
\`created_at\` integer NOT NULL,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
`,
|
||||
m0002: `CREATE TABLE \`task_lookup\` (
|
||||
\`task_id\` text PRIMARY KEY NOT NULL,
|
||||
\`repo_id\` text NOT NULL
|
||||
);
|
||||
`,
|
||||
m0003: `CREATE TABLE \`organization_profile\` (
|
||||
\`id\` text PRIMARY KEY NOT NULL,
|
||||
\`kind\` text NOT NULL,
|
||||
\`github_account_id\` text NOT NULL,
|
||||
\`github_login\` text NOT NULL,
|
||||
\`github_account_type\` text NOT NULL,
|
||||
\`display_name\` text NOT NULL,
|
||||
\`slug\` text NOT NULL,
|
||||
\`primary_domain\` text NOT NULL,
|
||||
\`default_model\` text NOT NULL,
|
||||
\`auto_import_repos\` integer NOT NULL,
|
||||
\`repo_import_status\` text NOT NULL,
|
||||
\`github_connected_account\` text NOT NULL,
|
||||
\`github_installation_status\` text NOT NULL,
|
||||
\`github_installation_id\` integer,
|
||||
\`github_last_sync_label\` text NOT NULL,
|
||||
\`stripe_customer_id\` text,
|
||||
\`stripe_subscription_id\` text,
|
||||
\`stripe_price_id\` text,
|
||||
\`billing_plan_id\` text NOT NULL,
|
||||
\`billing_status\` text NOT NULL,
|
||||
\`billing_seats_included\` integer NOT NULL,
|
||||
\`billing_trial_ends_at\` text,
|
||||
\`billing_renewal_at\` text,
|
||||
\`billing_payment_method_label\` text NOT NULL,
|
||||
\`created_at\` integer NOT NULL,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
`,
|
||||
m0004: `CREATE TABLE \`organization_members\` (
|
||||
\`id\` text PRIMARY KEY NOT NULL,
|
||||
\`name\` text NOT NULL,
|
||||
\`email\` text NOT NULL,
|
||||
\`role\` text NOT NULL,
|
||||
\`state\` text NOT NULL,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
`,
|
||||
m0005: `CREATE TABLE \`seat_assignments\` (
|
||||
\`email\` text PRIMARY KEY NOT NULL,
|
||||
\`created_at\` integer NOT NULL
|
||||
);
|
||||
`,
|
||||
m0006: `CREATE TABLE \`invoices\` (
|
||||
\`id\` text PRIMARY KEY NOT NULL,
|
||||
\`label\` text NOT NULL,
|
||||
\`issued_at\` text NOT NULL,
|
||||
\`amount_usd\` integer NOT NULL,
|
||||
\`status\` text NOT NULL,
|
||||
\`created_at\` integer NOT NULL
|
||||
);
|
||||
`,
|
||||
m0007: `CREATE TABLE \`app_sessions\` (
|
||||
m0000: `CREATE TABLE \`app_sessions\` (
|
||||
\`id\` text PRIMARY KEY NOT NULL,
|
||||
\`current_user_id\` text,
|
||||
\`current_user_name\` text,
|
||||
|
|
@ -165,23 +35,84 @@ export default {
|
|||
\`created_at\` integer NOT NULL,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
`,
|
||||
m0008: `CREATE TABLE \`stripe_lookup\` (
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE \`invoices\` (
|
||||
\`id\` text PRIMARY KEY NOT NULL,
|
||||
\`label\` text NOT NULL,
|
||||
\`issued_at\` text NOT NULL,
|
||||
\`amount_usd\` integer NOT NULL,
|
||||
\`status\` text NOT NULL,
|
||||
\`created_at\` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE \`organization_members\` (
|
||||
\`id\` text PRIMARY KEY NOT NULL,
|
||||
\`name\` text NOT NULL,
|
||||
\`email\` text NOT NULL,
|
||||
\`role\` text NOT NULL,
|
||||
\`state\` text NOT NULL,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE \`organization_profile\` (
|
||||
\`id\` text PRIMARY KEY NOT NULL,
|
||||
\`kind\` text NOT NULL,
|
||||
\`github_account_id\` text NOT NULL,
|
||||
\`github_login\` text NOT NULL,
|
||||
\`github_account_type\` text NOT NULL,
|
||||
\`display_name\` text NOT NULL,
|
||||
\`slug\` text NOT NULL,
|
||||
\`primary_domain\` text NOT NULL,
|
||||
\`default_model\` text NOT NULL,
|
||||
\`auto_import_repos\` integer NOT NULL,
|
||||
\`repo_import_status\` text NOT NULL,
|
||||
\`github_connected_account\` text NOT NULL,
|
||||
\`github_installation_status\` text NOT NULL,
|
||||
\`github_sync_status\` text NOT NULL,
|
||||
\`github_installation_id\` integer,
|
||||
\`github_last_sync_label\` text NOT NULL,
|
||||
\`github_last_sync_at\` integer,
|
||||
\`stripe_customer_id\` text,
|
||||
\`stripe_subscription_id\` text,
|
||||
\`stripe_price_id\` text,
|
||||
\`billing_plan_id\` text NOT NULL,
|
||||
\`billing_status\` text NOT NULL,
|
||||
\`billing_seats_included\` integer NOT NULL,
|
||||
\`billing_trial_ends_at\` text,
|
||||
\`billing_renewal_at\` text,
|
||||
\`billing_payment_method_label\` text NOT NULL,
|
||||
\`created_at\` integer NOT NULL,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE \`provider_profiles\` (
|
||||
\`provider_id\` text PRIMARY KEY NOT NULL,
|
||||
\`profile_json\` text NOT NULL,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE \`repos\` (
|
||||
\`repo_id\` text PRIMARY KEY NOT NULL,
|
||||
\`remote_url\` text NOT NULL,
|
||||
\`created_at\` integer NOT NULL,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE \`seat_assignments\` (
|
||||
\`email\` text PRIMARY KEY NOT NULL,
|
||||
\`created_at\` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE \`stripe_lookup\` (
|
||||
\`lookup_key\` text PRIMARY KEY NOT NULL,
|
||||
\`organization_id\` text NOT NULL,
|
||||
\`updated_at\` integer NOT NULL
|
||||
);
|
||||
`,
|
||||
m0009: `ALTER TABLE \`organization_profile\` ADD COLUMN \`github_sync_status\` text NOT NULL DEFAULT 'pending';
|
||||
ALTER TABLE \`organization_profile\` ADD COLUMN \`github_last_sync_at\` integer;
|
||||
UPDATE \`organization_profile\`
|
||||
SET \`github_sync_status\` = CASE
|
||||
WHEN \`repo_import_status\` = 'ready' THEN 'synced'
|
||||
WHEN \`repo_import_status\` = 'importing' THEN 'syncing'
|
||||
ELSE 'pending'
|
||||
END;
|
||||
`,
|
||||
m0010: `-- no-op: starter_repo_* columns are already present in m0007 app_sessions
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE \`task_lookup\` (
|
||||
\`task_id\` text PRIMARY KEY NOT NULL,
|
||||
\`repo_id\` text NOT NULL
|
||||
);
|
||||
`,
|
||||
} as const,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { integer, sqliteTable, text } from "rivetkit/db/drizzle";
|
|||
// SQLite is per workspace actor instance, so no workspaceId column needed.
|
||||
export const providerProfiles = sqliteTable("provider_profiles", {
|
||||
providerId: text("provider_id").notNull().primaryKey(),
|
||||
// Structured by the provider profile snapshot returned by provider integrations.
|
||||
profileJson: text("profile_json").notNull(),
|
||||
updatedAt: integer("updated_at").notNull(),
|
||||
});
|
||||
|
|
@ -80,6 +81,7 @@ export const appSessions = sqliteTable("app_sessions", {
|
|||
currentUserEmail: text("current_user_email"),
|
||||
currentUserGithubLogin: text("current_user_github_login"),
|
||||
currentUserRoleLabel: text("current_user_role_label"),
|
||||
// Structured as a JSON array of eligible organization ids for the session.
|
||||
eligibleOrganizationIdsJson: text("eligible_organization_ids_json").notNull(),
|
||||
activeOrganizationId: text("active_organization_id"),
|
||||
githubAccessToken: text("github_access_token"),
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ export const workspace = actor({
|
|||
db: workspaceDb,
|
||||
queues: Object.fromEntries(WORKSPACE_QUEUE_NAMES.map((name) => [name, queue()])),
|
||||
options: {
|
||||
name: "Workspace",
|
||||
icon: "compass",
|
||||
actionTimeout: 5 * 60_000,
|
||||
},
|
||||
createState: (_c, workspaceId: string) => ({
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Hono } from "hono";
|
||||
import { cors } from "hono/cors";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { initActorRuntimeContext } from "./actors/context.js";
|
||||
import { registry } from "./actors/index.js";
|
||||
import { workspaceKey } from "./actors/keys.js";
|
||||
|
|
@ -11,12 +12,38 @@ import { createClient } from "rivetkit/client";
|
|||
import type { FoundryBillingPlanId } from "@sandbox-agent/foundry-shared";
|
||||
import { createDefaultAppShellServices } from "./services/app-shell-runtime.js";
|
||||
import { APP_SHELL_WORKSPACE_ID } from "./actors/workspace/app-shell.js";
|
||||
import { logger } from "./logging.js";
|
||||
|
||||
export interface BackendStartOptions {
|
||||
host?: string;
|
||||
port?: number;
|
||||
}
|
||||
|
||||
interface AppWorkspaceLogContext {
|
||||
action?: string;
|
||||
cfConnectingIp?: string;
|
||||
cfRay?: string;
|
||||
forwardedFor?: string;
|
||||
forwardedHost?: string;
|
||||
forwardedProto?: string;
|
||||
method?: string;
|
||||
path?: string;
|
||||
requestId?: string;
|
||||
referer?: string;
|
||||
secFetchDest?: string;
|
||||
secFetchMode?: string;
|
||||
secFetchSite?: string;
|
||||
secFetchUser?: string;
|
||||
sessionId?: string;
|
||||
userAgent?: string;
|
||||
xRealIp?: string;
|
||||
}
|
||||
|
||||
function isRivetRequest(request: Request): boolean {
|
||||
const { pathname } = new URL(request.url);
|
||||
return pathname === "/v1/rivet" || pathname.startsWith("/v1/rivet/");
|
||||
}
|
||||
|
||||
function isRetryableAppActorError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return message.includes("Actor not ready") || message.includes("socket connection was closed unexpectedly");
|
||||
|
|
@ -70,11 +97,26 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
|
|||
initActorRuntimeContext(config, providers, notifications, driver, createDefaultAppShellServices());
|
||||
|
||||
const actorClient = createClient({
|
||||
endpoint: `http://127.0.0.1:${config.backend.port}/api/rivet`,
|
||||
endpoint: `http://127.0.0.1:${config.backend.port}/v1/rivet`,
|
||||
}) as any;
|
||||
|
||||
// Wrap RivetKit and app routes in a single Hono app mounted at /api/rivet.
|
||||
const app = new Hono();
|
||||
const requestHeaderContext = (c: any): AppWorkspaceLogContext => ({
|
||||
cfConnectingIp: c.req.header("cf-connecting-ip") ?? undefined,
|
||||
cfRay: c.req.header("cf-ray") ?? undefined,
|
||||
forwardedFor: c.req.header("x-forwarded-for") ?? undefined,
|
||||
forwardedHost: c.req.header("x-forwarded-host") ?? undefined,
|
||||
forwardedProto: c.req.header("x-forwarded-proto") ?? undefined,
|
||||
referer: c.req.header("referer") ?? undefined,
|
||||
secFetchDest: c.req.header("sec-fetch-dest") ?? undefined,
|
||||
secFetchMode: c.req.header("sec-fetch-mode") ?? undefined,
|
||||
secFetchSite: c.req.header("sec-fetch-site") ?? undefined,
|
||||
secFetchUser: c.req.header("sec-fetch-user") ?? undefined,
|
||||
userAgent: c.req.header("user-agent") ?? undefined,
|
||||
xRealIp: c.req.header("x-real-ip") ?? undefined,
|
||||
});
|
||||
|
||||
// Serve custom Foundry HTTP APIs alongside the RivetKit registry.
|
||||
const app = new Hono<{ Variables: { requestId: string } }>();
|
||||
const allowHeaders = [
|
||||
"Content-Type",
|
||||
"Authorization",
|
||||
|
|
@ -93,7 +135,7 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
|
|||
];
|
||||
const exposeHeaders = ["Content-Type", "x-foundry-session", "x-rivet-ray-id"];
|
||||
app.use(
|
||||
"/api/rivet/*",
|
||||
"/v1/*",
|
||||
cors({
|
||||
origin: (origin) => origin ?? "*",
|
||||
credentials: true,
|
||||
|
|
@ -103,7 +145,7 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
|
|||
}),
|
||||
);
|
||||
app.use(
|
||||
"/api/rivet",
|
||||
"/v1",
|
||||
cors({
|
||||
origin: (origin) => origin ?? "*",
|
||||
credentials: true,
|
||||
|
|
@ -112,92 +154,208 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
|
|||
exposeHeaders,
|
||||
}),
|
||||
);
|
||||
app.use("*", async (c, next) => {
|
||||
const requestId = c.req.header("x-request-id")?.trim() || randomUUID();
|
||||
const start = performance.now();
|
||||
c.set("requestId", requestId);
|
||||
c.header("x-request-id", requestId);
|
||||
|
||||
const appWorkspace = async () =>
|
||||
await withRetries(
|
||||
async () =>
|
||||
await actorClient.workspace.getOrCreate(workspaceKey(APP_SHELL_WORKSPACE_ID), {
|
||||
createWithInput: APP_SHELL_WORKSPACE_ID,
|
||||
}),
|
||||
try {
|
||||
await next();
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
...requestHeaderContext(c),
|
||||
requestId,
|
||||
method: c.req.method,
|
||||
path: c.req.path,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
errorStack: error instanceof Error ? error.stack : undefined,
|
||||
},
|
||||
"http_request_failed",
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{
|
||||
...requestHeaderContext(c),
|
||||
requestId,
|
||||
method: c.req.method,
|
||||
path: c.req.path,
|
||||
status: c.res.status,
|
||||
durationMs: Math.round((performance.now() - start) * 100) / 100,
|
||||
},
|
||||
"http_request",
|
||||
);
|
||||
});
|
||||
|
||||
const appWorkspaceAction = async <T>(run: (workspace: any) => Promise<T>): Promise<T> => await withRetries(async () => await run(await appWorkspace()));
|
||||
let cachedAppWorkspace: any | null = null;
|
||||
|
||||
const appWorkspace = async (context: AppWorkspaceLogContext = {}) => {
|
||||
if (cachedAppWorkspace) return cachedAppWorkspace;
|
||||
|
||||
const start = performance.now();
|
||||
try {
|
||||
const handle = await withRetries(
|
||||
async () =>
|
||||
await actorClient.workspace.getOrCreate(workspaceKey(APP_SHELL_WORKSPACE_ID), {
|
||||
createWithInput: APP_SHELL_WORKSPACE_ID,
|
||||
}),
|
||||
);
|
||||
cachedAppWorkspace = handle;
|
||||
logger.info(
|
||||
{
|
||||
...context,
|
||||
cache: "miss",
|
||||
durationMs: Math.round((performance.now() - start) * 100) / 100,
|
||||
},
|
||||
"app_workspace_resolve",
|
||||
);
|
||||
return handle;
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
...context,
|
||||
cache: "miss",
|
||||
durationMs: Math.round((performance.now() - start) * 100) / 100,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
errorStack: error instanceof Error ? error.stack : undefined,
|
||||
},
|
||||
"app_workspace_resolve_failed",
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const appWorkspaceAction = async <T>(action: string, run: (workspace: any) => Promise<T>, context: AppWorkspaceLogContext = {}): Promise<T> => {
|
||||
try {
|
||||
return await run(await appWorkspace({ ...context, action }));
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{
|
||||
...context,
|
||||
action,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
errorStack: error instanceof Error ? error.stack : undefined,
|
||||
},
|
||||
"app_workspace_action_failed",
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const requestLogContext = (c: any, sessionId?: string): AppWorkspaceLogContext => ({
|
||||
...requestHeaderContext(c),
|
||||
method: c.req.method,
|
||||
path: c.req.path,
|
||||
requestId: c.get("requestId"),
|
||||
sessionId,
|
||||
});
|
||||
|
||||
const resolveSessionId = async (c: any): Promise<string> => {
|
||||
const requested = c.req.header("x-foundry-session");
|
||||
const { sessionId } = await appWorkspaceAction(
|
||||
"ensureAppSession",
|
||||
async (workspace) => await workspace.ensureAppSession(requested && requested.trim().length > 0 ? { requestedSessionId: requested } : {}),
|
||||
requestLogContext(c),
|
||||
);
|
||||
c.header("x-foundry-session", sessionId);
|
||||
return sessionId;
|
||||
};
|
||||
|
||||
app.get("/api/rivet/app/snapshot", async (c) => {
|
||||
app.get("/v1/app/snapshot", async (c) => {
|
||||
const sessionId = await resolveSessionId(c);
|
||||
return c.json(await appWorkspaceAction(async (workspace) => await workspace.getAppSnapshot({ sessionId })));
|
||||
return c.json(
|
||||
await appWorkspaceAction("getAppSnapshot", async (workspace) => await workspace.getAppSnapshot({ sessionId }), requestLogContext(c, sessionId)),
|
||||
);
|
||||
});
|
||||
|
||||
app.get("/api/rivet/app/auth/github/start", async (c) => {
|
||||
app.get("/v1/auth/github/start", async (c) => {
|
||||
const sessionId = await resolveSessionId(c);
|
||||
const result = await appWorkspaceAction(async (workspace) => await workspace.startAppGithubAuth({ sessionId }));
|
||||
const result = await appWorkspaceAction(
|
||||
"startAppGithubAuth",
|
||||
async (workspace) => await workspace.startAppGithubAuth({ sessionId }),
|
||||
requestLogContext(c, sessionId),
|
||||
);
|
||||
return Response.redirect(result.url, 302);
|
||||
});
|
||||
|
||||
const handleGithubAuthCallback = async (c: any) => {
|
||||
// TEMPORARY: dump all request headers to diagnose duplicate callback requests
|
||||
// (Railway nginx proxy_next_upstream? Cloudflare retry? browser?)
|
||||
// Remove once root cause is identified.
|
||||
const allHeaders: Record<string, string> = {};
|
||||
c.req.raw.headers.forEach((value: string, key: string) => {
|
||||
allHeaders[key] = value;
|
||||
});
|
||||
logger.info({ headers: allHeaders, url: c.req.url }, "github_callback_headers");
|
||||
|
||||
const code = c.req.query("code");
|
||||
const state = c.req.query("state");
|
||||
if (!code || !state) {
|
||||
return c.text("Missing GitHub OAuth callback parameters", 400);
|
||||
}
|
||||
const result = await appWorkspaceAction(async (workspace) => await workspace.completeAppGithubAuth({ code, state }));
|
||||
const result = await appWorkspaceAction(
|
||||
"completeAppGithubAuth",
|
||||
async (workspace) => await workspace.completeAppGithubAuth({ code, state }),
|
||||
requestLogContext(c),
|
||||
);
|
||||
c.header("x-foundry-session", result.sessionId);
|
||||
return Response.redirect(result.redirectTo, 302);
|
||||
};
|
||||
|
||||
app.get("/api/rivet/app/auth/github/callback", handleGithubAuthCallback);
|
||||
app.get("/v1/auth/github/callback", handleGithubAuthCallback);
|
||||
app.get("/api/auth/callback/github", handleGithubAuthCallback);
|
||||
|
||||
app.post("/api/rivet/app/sign-out", async (c) => {
|
||||
app.post("/v1/app/sign-out", async (c) => {
|
||||
const sessionId = await resolveSessionId(c);
|
||||
return c.json(await appWorkspaceAction(async (workspace) => await workspace.signOutApp({ sessionId })));
|
||||
return c.json(await appWorkspaceAction("signOutApp", async (workspace) => await workspace.signOutApp({ sessionId }), requestLogContext(c, sessionId)));
|
||||
});
|
||||
|
||||
app.post("/api/rivet/app/onboarding/starter-repo/skip", async (c) => {
|
||||
app.post("/v1/app/onboarding/starter-repo/skip", async (c) => {
|
||||
const sessionId = await resolveSessionId(c);
|
||||
return c.json(await appWorkspaceAction(async (workspace) => await workspace.skipAppStarterRepo({ sessionId })));
|
||||
return c.json(
|
||||
await appWorkspaceAction("skipAppStarterRepo", async (workspace) => await workspace.skipAppStarterRepo({ sessionId }), requestLogContext(c, sessionId)),
|
||||
);
|
||||
});
|
||||
|
||||
app.post("/api/rivet/app/organizations/:organizationId/starter-repo/star", async (c) => {
|
||||
app.post("/v1/app/organizations/:organizationId/starter-repo/star", async (c) => {
|
||||
const sessionId = await resolveSessionId(c);
|
||||
return c.json(
|
||||
await appWorkspaceAction(
|
||||
"starAppStarterRepo",
|
||||
async (workspace) =>
|
||||
await workspace.starAppStarterRepo({
|
||||
sessionId,
|
||||
organizationId: c.req.param("organizationId"),
|
||||
}),
|
||||
requestLogContext(c, sessionId),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
app.post("/api/rivet/app/organizations/:organizationId/select", async (c) => {
|
||||
app.post("/v1/app/organizations/:organizationId/select", async (c) => {
|
||||
const sessionId = await resolveSessionId(c);
|
||||
return c.json(
|
||||
await appWorkspaceAction(
|
||||
"selectAppOrganization",
|
||||
async (workspace) =>
|
||||
await workspace.selectAppOrganization({
|
||||
sessionId,
|
||||
organizationId: c.req.param("organizationId"),
|
||||
}),
|
||||
requestLogContext(c, sessionId),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
app.patch("/api/rivet/app/organizations/:organizationId/profile", async (c) => {
|
||||
app.patch("/v1/app/organizations/:organizationId/profile", async (c) => {
|
||||
const sessionId = await resolveSessionId(c);
|
||||
const body = await c.req.json();
|
||||
return c.json(
|
||||
await appWorkspaceAction(
|
||||
"updateAppOrganizationProfile",
|
||||
async (workspace) =>
|
||||
await workspace.updateAppOrganizationProfile({
|
||||
sessionId,
|
||||
|
|
@ -206,42 +364,47 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
|
|||
slug: typeof body?.slug === "string" ? body.slug : "",
|
||||
primaryDomain: typeof body?.primaryDomain === "string" ? body.primaryDomain : "",
|
||||
}),
|
||||
requestLogContext(c, sessionId),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
app.post("/api/rivet/app/organizations/:organizationId/import", async (c) => {
|
||||
app.post("/v1/app/organizations/:organizationId/import", async (c) => {
|
||||
const sessionId = await resolveSessionId(c);
|
||||
return c.json(
|
||||
await appWorkspaceAction(
|
||||
"triggerAppRepoImport",
|
||||
async (workspace) =>
|
||||
await workspace.triggerAppRepoImport({
|
||||
sessionId,
|
||||
organizationId: c.req.param("organizationId"),
|
||||
}),
|
||||
requestLogContext(c, sessionId),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
app.post("/api/rivet/app/organizations/:organizationId/reconnect", async (c) => {
|
||||
app.post("/v1/app/organizations/:organizationId/reconnect", async (c) => {
|
||||
const sessionId = await resolveSessionId(c);
|
||||
return c.json(
|
||||
await appWorkspaceAction(
|
||||
"beginAppGithubInstall",
|
||||
async (workspace) =>
|
||||
await workspace.beginAppGithubInstall({
|
||||
sessionId,
|
||||
organizationId: c.req.param("organizationId"),
|
||||
}),
|
||||
requestLogContext(c, sessionId),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
app.post("/api/rivet/app/organizations/:organizationId/billing/checkout", async (c) => {
|
||||
app.post("/v1/app/organizations/:organizationId/billing/checkout", async (c) => {
|
||||
const sessionId = await resolveSessionId(c);
|
||||
const body = await c.req.json().catch(() => ({}));
|
||||
const planId = body?.planId === "free" || body?.planId === "team" ? (body.planId as FoundryBillingPlanId) : "team";
|
||||
return c.json(
|
||||
await (await appWorkspace()).createAppCheckoutSession({
|
||||
await (await appWorkspace(requestLogContext(c, sessionId))).createAppCheckoutSession({
|
||||
sessionId,
|
||||
organizationId: c.req.param("organizationId"),
|
||||
planId,
|
||||
|
|
@ -249,14 +412,14 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
|
|||
);
|
||||
});
|
||||
|
||||
app.get("/api/rivet/app/billing/checkout/complete", async (c) => {
|
||||
app.get("/v1/billing/checkout/complete", async (c) => {
|
||||
const organizationId = c.req.query("organizationId");
|
||||
const sessionId = c.req.query("foundrySession");
|
||||
const checkoutSessionId = c.req.query("session_id");
|
||||
if (!organizationId || !sessionId || !checkoutSessionId) {
|
||||
return c.text("Missing Stripe checkout completion parameters", 400);
|
||||
}
|
||||
const result = await (await appWorkspace()).finalizeAppCheckoutSession({
|
||||
const result = await (await appWorkspace(requestLogContext(c, sessionId))).finalizeAppCheckoutSession({
|
||||
organizationId,
|
||||
sessionId,
|
||||
checkoutSessionId,
|
||||
|
|
@ -264,40 +427,40 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
|
|||
return Response.redirect(result.redirectTo, 302);
|
||||
});
|
||||
|
||||
app.post("/api/rivet/app/organizations/:organizationId/billing/portal", async (c) => {
|
||||
app.post("/v1/app/organizations/:organizationId/billing/portal", async (c) => {
|
||||
const sessionId = await resolveSessionId(c);
|
||||
return c.json(
|
||||
await (await appWorkspace()).createAppBillingPortalSession({
|
||||
await (await appWorkspace(requestLogContext(c, sessionId))).createAppBillingPortalSession({
|
||||
sessionId,
|
||||
organizationId: c.req.param("organizationId"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
app.post("/api/rivet/app/organizations/:organizationId/billing/cancel", async (c) => {
|
||||
app.post("/v1/app/organizations/:organizationId/billing/cancel", async (c) => {
|
||||
const sessionId = await resolveSessionId(c);
|
||||
return c.json(
|
||||
await (await appWorkspace()).cancelAppScheduledRenewal({
|
||||
await (await appWorkspace(requestLogContext(c, sessionId))).cancelAppScheduledRenewal({
|
||||
sessionId,
|
||||
organizationId: c.req.param("organizationId"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
app.post("/api/rivet/app/organizations/:organizationId/billing/resume", async (c) => {
|
||||
app.post("/v1/app/organizations/:organizationId/billing/resume", async (c) => {
|
||||
const sessionId = await resolveSessionId(c);
|
||||
return c.json(
|
||||
await (await appWorkspace()).resumeAppSubscription({
|
||||
await (await appWorkspace(requestLogContext(c, sessionId))).resumeAppSubscription({
|
||||
sessionId,
|
||||
organizationId: c.req.param("organizationId"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
app.post("/api/rivet/app/workspaces/:workspaceId/seat-usage", async (c) => {
|
||||
app.post("/v1/app/workspaces/:workspaceId/seat-usage", async (c) => {
|
||||
const sessionId = await resolveSessionId(c);
|
||||
return c.json(
|
||||
await (await appWorkspace()).recordAppSeatUsage({
|
||||
await (await appWorkspace(requestLogContext(c, sessionId))).recordAppSeatUsage({
|
||||
sessionId,
|
||||
workspaceId: c.req.param("workspaceId"),
|
||||
}),
|
||||
|
|
@ -306,19 +469,18 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
|
|||
|
||||
const handleStripeWebhook = async (c: any) => {
|
||||
const payload = await c.req.text();
|
||||
await (await appWorkspace()).handleAppStripeWebhook({
|
||||
await (await appWorkspace(requestLogContext(c))).handleAppStripeWebhook({
|
||||
payload,
|
||||
signatureHeader: c.req.header("stripe-signature") ?? null,
|
||||
});
|
||||
return c.json({ ok: true });
|
||||
};
|
||||
|
||||
app.post("/api/rivet/app/webhooks/stripe", handleStripeWebhook);
|
||||
app.post("/api/rivet/app/stripe/webhook", handleStripeWebhook);
|
||||
app.post("/v1/webhooks/stripe", handleStripeWebhook);
|
||||
|
||||
app.post("/api/rivet/app/webhooks/github", async (c) => {
|
||||
app.post("/v1/webhooks/github", async (c) => {
|
||||
const payload = await c.req.text();
|
||||
await (await appWorkspace()).handleAppGithubWebhook({
|
||||
await (await appWorkspace(requestLogContext(c))).handleAppGithubWebhook({
|
||||
payload,
|
||||
signatureHeader: c.req.header("x-hub-signature-256") ?? null,
|
||||
eventHeader: c.req.header("x-github-event") ?? null,
|
||||
|
|
@ -326,15 +488,25 @@ export async function startBackend(options: BackendStartOptions = {}): Promise<v
|
|||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.all("/api/rivet", (c) => registry.handler(c.req.raw));
|
||||
app.all("/api/rivet/*", (c) => registry.handler(c.req.raw));
|
||||
|
||||
const server = Bun.serve({
|
||||
fetch: app.fetch,
|
||||
fetch: (request) => {
|
||||
if (isRivetRequest(request)) {
|
||||
return registry.handler(request);
|
||||
}
|
||||
return app.fetch(request);
|
||||
},
|
||||
hostname: config.backend.host,
|
||||
port: config.backend.port,
|
||||
});
|
||||
|
||||
logger.info(
|
||||
{
|
||||
host: config.backend.host,
|
||||
port: config.backend.port,
|
||||
},
|
||||
"backend_started",
|
||||
);
|
||||
|
||||
process.on("SIGINT", async () => {
|
||||
server.stop();
|
||||
process.exit(0);
|
||||
|
|
@ -382,8 +554,13 @@ async function main(): Promise<void> {
|
|||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
main().catch((err: unknown) => {
|
||||
const message = err instanceof Error ? (err.stack ?? err.message) : String(err);
|
||||
console.error(message);
|
||||
logger.fatal(
|
||||
{
|
||||
errorMessage: err instanceof Error ? err.message : String(err),
|
||||
errorStack: err instanceof Error ? err.stack : undefined,
|
||||
},
|
||||
"backend_start_failed",
|
||||
);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
5
foundry/packages/backend/src/logging.ts
Normal file
5
foundry/packages/backend/src/logging.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { createFoundryLogger } from "@sandbox-agent/foundry-shared";
|
||||
|
||||
export const logger = createFoundryLogger({
|
||||
service: "foundry-backend",
|
||||
});
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { createHmac, createPrivateKey, createSign, timingSafeEqual } from "node:crypto";
|
||||
import { logger } from "../logging.js";
|
||||
|
||||
export class GitHubAppError extends Error {
|
||||
readonly status: number;
|
||||
|
|
@ -51,6 +52,10 @@ interface GitHubPageResponse<T> {
|
|||
nextUrl: string | null;
|
||||
}
|
||||
|
||||
const githubOAuthLogger = logger.child({
|
||||
scope: "github-oauth",
|
||||
});
|
||||
|
||||
export interface GitHubWebhookEvent {
|
||||
action?: string;
|
||||
installation?: { id: number; account?: { login?: string; type?: string; id?: number } | null };
|
||||
|
|
@ -161,21 +166,40 @@ export class GitHubAppClient {
|
|||
throw new GitHubAppError("GitHub OAuth is not configured", 500);
|
||||
}
|
||||
|
||||
const exchangeBody = {
|
||||
client_id: this.clientId,
|
||||
client_secret: this.clientSecret,
|
||||
code,
|
||||
redirect_uri: this.redirectUri,
|
||||
};
|
||||
githubOAuthLogger.debug(
|
||||
{
|
||||
url: `${this.authBaseUrl}/login/oauth/access_token`,
|
||||
clientId: this.clientId,
|
||||
redirectUri: this.redirectUri,
|
||||
codeLength: code.length,
|
||||
codePrefix: code.slice(0, 6),
|
||||
},
|
||||
"exchange_code_request",
|
||||
);
|
||||
|
||||
const response = await fetch(`${this.authBaseUrl}/login/oauth/access_token`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
client_id: this.clientId,
|
||||
client_secret: this.clientSecret,
|
||||
code,
|
||||
redirect_uri: this.redirectUri,
|
||||
}),
|
||||
body: JSON.stringify(exchangeBody),
|
||||
});
|
||||
|
||||
const responseText = await response.text();
|
||||
githubOAuthLogger.debug(
|
||||
{
|
||||
status: response.status,
|
||||
bodyPreview: responseText.slice(0, 300),
|
||||
},
|
||||
"exchange_code_response",
|
||||
);
|
||||
let payload: GitHubTokenResponse;
|
||||
try {
|
||||
payload = JSON.parse(responseText) as GitHubTokenResponse;
|
||||
|
|
|
|||
|
|
@ -47,12 +47,14 @@ export type AppShellStripeClient = Pick<
|
|||
|
||||
export interface AppShellServices {
|
||||
appUrl: string;
|
||||
apiUrl: string;
|
||||
github: AppShellGithubClient;
|
||||
stripe: AppShellStripeClient;
|
||||
}
|
||||
|
||||
export interface CreateAppShellServicesOptions {
|
||||
appUrl?: string;
|
||||
apiUrl?: string;
|
||||
github?: AppShellGithubClient;
|
||||
stripe?: AppShellStripeClient;
|
||||
}
|
||||
|
|
@ -60,6 +62,7 @@ export interface CreateAppShellServicesOptions {
|
|||
export function createDefaultAppShellServices(options: CreateAppShellServicesOptions = {}): AppShellServices {
|
||||
return {
|
||||
appUrl: (options.appUrl ?? process.env.APP_URL ?? "http://localhost:4173").replace(/\/$/, ""),
|
||||
apiUrl: (options.apiUrl ?? process.env.BETTER_AUTH_URL ?? process.env.APP_URL ?? "http://localhost:7741").replace(/\/$/, ""),
|
||||
github: options.github ?? new GitHubAppClient(),
|
||||
stripe: options.stripe ?? new StripeAppClient(),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
|
|||
import { checkBackendHealth } from "@sandbox-agent/foundry-client";
|
||||
import type { AppConfig } from "@sandbox-agent/foundry-shared";
|
||||
import { CLI_BUILD_ID } from "../build-id.js";
|
||||
import { logger } from "../logging.js";
|
||||
|
||||
const HEALTH_TIMEOUT_MS = 1_500;
|
||||
const START_TIMEOUT_MS = 30_000;
|
||||
|
|
@ -132,7 +133,7 @@ function removeStateFiles(host: string, port: number): void {
|
|||
|
||||
async function checkHealth(host: string, port: number): Promise<boolean> {
|
||||
return await checkBackendHealth({
|
||||
endpoint: `http://${host}:${port}/api/rivet`,
|
||||
endpoint: `http://${host}:${port}/v1/rivet`,
|
||||
timeoutMs: HEALTH_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
|
@ -237,7 +238,17 @@ async function startBackend(host: string, port: number): Promise<void> {
|
|||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
console.error(`failed to launch backend: ${String(error)}`);
|
||||
logger.error(
|
||||
{
|
||||
host,
|
||||
port,
|
||||
command: launch.command,
|
||||
args: launch.args,
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
errorStack: error instanceof Error ? error.stack : undefined,
|
||||
},
|
||||
"failed_to_launch_backend",
|
||||
);
|
||||
});
|
||||
|
||||
child.unref();
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { homedir } from "node:os";
|
|||
import { AgentTypeSchema, CreateTaskInputSchema, type TaskRecord } from "@sandbox-agent/foundry-shared";
|
||||
import { readBackendMetadata, createBackendClientFromConfig, formatRelativeAge, groupTaskStatus, summarizeTasks } from "@sandbox-agent/foundry-client";
|
||||
import { ensureBackendRunning, getBackendStatus, parseBackendPort, stopBackend } from "./backend/manager.js";
|
||||
import { writeStderr, writeStdout } from "./io.js";
|
||||
import { openEditorForTask } from "./task-editor.js";
|
||||
import { spawnCreateTmuxWindow } from "./tmux.js";
|
||||
import { loadConfig, resolveWorkspace, saveConfig } from "./workspace/config.js";
|
||||
|
|
@ -87,7 +88,7 @@ function positionals(args: string[]): string[] {
|
|||
}
|
||||
|
||||
function printUsage(): void {
|
||||
console.log(`
|
||||
writeStdout(`
|
||||
Usage:
|
||||
hf backend start [--host HOST] [--port PORT]
|
||||
hf backend stop [--host HOST] [--port PORT]
|
||||
|
|
@ -120,7 +121,7 @@ Tips:
|
|||
}
|
||||
|
||||
function printStatusUsage(): void {
|
||||
console.log(`
|
||||
writeStdout(`
|
||||
Usage:
|
||||
hf status [--workspace WS] [--json]
|
||||
|
||||
|
|
@ -146,7 +147,7 @@ JSON Output:
|
|||
}
|
||||
|
||||
function printHistoryUsage(): void {
|
||||
console.log(`
|
||||
writeStdout(`
|
||||
Usage:
|
||||
hf history [--workspace WS] [--limit N] [--branch NAME] [--task ID] [--json]
|
||||
|
||||
|
|
@ -195,13 +196,13 @@ async function handleBackend(args: string[]): Promise<void> {
|
|||
const pid = status.pid ?? "unknown";
|
||||
const version = status.version ?? "unknown";
|
||||
const stale = status.running && !status.versionCurrent ? " [outdated]" : "";
|
||||
console.log(`running=true pid=${pid} version=${version}${stale} log=${status.logPath}`);
|
||||
writeStdout(`running=true pid=${pid} version=${version}${stale} log=${status.logPath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === "stop") {
|
||||
await stopBackend(host, port);
|
||||
console.log(`running=false host=${host} port=${port}`);
|
||||
writeStdout(`running=false host=${host} port=${port}`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -210,21 +211,21 @@ async function handleBackend(args: string[]): Promise<void> {
|
|||
const pid = status.pid ?? "unknown";
|
||||
const version = status.version ?? "unknown";
|
||||
const stale = status.running && !status.versionCurrent ? " [outdated]" : "";
|
||||
console.log(`running=${status.running} pid=${pid} version=${version}${stale} host=${host} port=${port} log=${status.logPath}`);
|
||||
writeStdout(`running=${status.running} pid=${pid} version=${version}${stale} host=${host} port=${port} log=${status.logPath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === "inspect") {
|
||||
await ensureBackendRunning(backendConfig);
|
||||
const metadata = await readBackendMetadata({
|
||||
endpoint: `http://${host}:${port}/api/rivet`,
|
||||
endpoint: `http://${host}:${port}/v1/rivet`,
|
||||
timeoutMs: 4_000,
|
||||
});
|
||||
const managerEndpoint = metadata.clientEndpoint ?? `http://${host}:${port}`;
|
||||
const inspectorUrl = `https://inspect.rivet.dev?u=${encodeURIComponent(managerEndpoint)}`;
|
||||
const openCmd = process.platform === "darwin" ? "open" : "xdg-open";
|
||||
spawnSync(openCmd, [inspectorUrl], { stdio: "ignore" });
|
||||
console.log(inspectorUrl);
|
||||
writeStdout(inspectorUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -253,7 +254,7 @@ async function handleWorkspace(args: string[]): Promise<void> {
|
|||
// Backend may not be running yet. Config is already updated.
|
||||
}
|
||||
|
||||
console.log(`workspace=${name}`);
|
||||
writeStdout(`workspace=${name}`);
|
||||
}
|
||||
|
||||
async function handleList(args: string[]): Promise<void> {
|
||||
|
|
@ -265,12 +266,12 @@ async function handleList(args: string[]): Promise<void> {
|
|||
const rows = await client.listTasks(workspaceId);
|
||||
|
||||
if (format === "json") {
|
||||
console.log(JSON.stringify(rows, null, 2));
|
||||
writeStdout(JSON.stringify(rows, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
console.log("no tasks");
|
||||
writeStdout("no tasks");
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -281,7 +282,7 @@ async function handleList(args: string[]): Promise<void> {
|
|||
const task = row.task.length > 60 ? `${row.task.slice(0, 57)}...` : row.task;
|
||||
line += `\t${row.title}\t${task}\t${row.activeSessionId ?? "-"}\t${row.activeSandboxId ?? "-"}`;
|
||||
}
|
||||
console.log(line);
|
||||
writeStdout(line);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -294,7 +295,7 @@ async function handlePush(args: string[]): Promise<void> {
|
|||
const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config);
|
||||
const client = createBackendClientFromConfig(config);
|
||||
await client.runAction(workspaceId, taskId, "push");
|
||||
console.log("ok");
|
||||
writeStdout("ok");
|
||||
}
|
||||
|
||||
async function handleSync(args: string[]): Promise<void> {
|
||||
|
|
@ -306,7 +307,7 @@ async function handleSync(args: string[]): Promise<void> {
|
|||
const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config);
|
||||
const client = createBackendClientFromConfig(config);
|
||||
await client.runAction(workspaceId, taskId, "sync");
|
||||
console.log("ok");
|
||||
writeStdout("ok");
|
||||
}
|
||||
|
||||
async function handleKill(args: string[]): Promise<void> {
|
||||
|
|
@ -320,15 +321,15 @@ async function handleKill(args: string[]): Promise<void> {
|
|||
const abandon = hasFlag(args, "--abandon");
|
||||
|
||||
if (deleteBranch) {
|
||||
console.log("info: --delete-branch flag set, branch will be deleted after kill");
|
||||
writeStdout("info: --delete-branch flag set, branch will be deleted after kill");
|
||||
}
|
||||
if (abandon) {
|
||||
console.log("info: --abandon flag set, Graphite abandon will be attempted");
|
||||
writeStdout("info: --abandon flag set, Graphite abandon will be attempted");
|
||||
}
|
||||
|
||||
const client = createBackendClientFromConfig(config);
|
||||
await client.runAction(workspaceId, taskId, "kill");
|
||||
console.log("ok");
|
||||
writeStdout("ok");
|
||||
}
|
||||
|
||||
async function handlePrune(args: string[]): Promise<void> {
|
||||
|
|
@ -341,26 +342,26 @@ async function handlePrune(args: string[]): Promise<void> {
|
|||
const prunable = rows.filter((r) => r.status === "archived" || r.status === "killed");
|
||||
|
||||
if (prunable.length === 0) {
|
||||
console.log("nothing to prune");
|
||||
writeStdout("nothing to prune");
|
||||
return;
|
||||
}
|
||||
|
||||
for (const row of prunable) {
|
||||
const age = formatRelativeAge(row.updatedAt);
|
||||
console.log(`${dryRun ? "[dry-run] " : ""}${row.taskId}\t${row.branchName}\t${row.status}\t${age}`);
|
||||
writeStdout(`${dryRun ? "[dry-run] " : ""}${row.taskId}\t${row.branchName}\t${row.status}\t${age}`);
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
console.log(`\n${prunable.length} task(s) would be pruned`);
|
||||
writeStdout(`\n${prunable.length} task(s) would be pruned`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!yes) {
|
||||
console.log("\nnot yet implemented: auto-pruning requires confirmation");
|
||||
writeStdout("\nnot yet implemented: auto-pruning requires confirmation");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`\n${prunable.length} task(s) would be pruned (pruning not yet implemented)`);
|
||||
writeStdout(`\n${prunable.length} task(s) would be pruned (pruning not yet implemented)`);
|
||||
}
|
||||
|
||||
async function handleStatusline(args: string[]): Promise<void> {
|
||||
|
|
@ -375,11 +376,11 @@ async function handleStatusline(args: string[]): Promise<void> {
|
|||
const errorCount = summary.byStatus.error;
|
||||
|
||||
if (format === "claude-code") {
|
||||
console.log(`hf:${running}R/${idle}I/${errorCount}E`);
|
||||
writeStdout(`hf:${running}R/${idle}I/${errorCount}E`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`running=${running} idle=${idle} error=${errorCount}`);
|
||||
writeStdout(`running=${running} idle=${idle} error=${errorCount}`);
|
||||
}
|
||||
|
||||
async function handleDb(args: string[]): Promise<void> {
|
||||
|
|
@ -387,12 +388,12 @@ async function handleDb(args: string[]): Promise<void> {
|
|||
if (sub === "path") {
|
||||
const config = loadConfig();
|
||||
const dbPath = config.backend.dbPath.replace(/^~/, homedir());
|
||||
console.log(dbPath);
|
||||
writeStdout(dbPath);
|
||||
return;
|
||||
}
|
||||
|
||||
if (sub === "nuke") {
|
||||
console.log("WARNING: hf db nuke would delete the entire database. This is a placeholder and does not delete anything.");
|
||||
writeStdout("WARNING: hf db nuke would delete the entire database. This is a placeholder and does not delete anything.");
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -465,12 +466,12 @@ async function handleCreate(args: string[]): Promise<void> {
|
|||
const switched = await client.switchTask(workspaceId, task.taskId);
|
||||
const attached = await client.attachTask(workspaceId, task.taskId);
|
||||
|
||||
console.log(`Branch: ${task.branchName ?? "-"}`);
|
||||
console.log(`Task: ${task.taskId}`);
|
||||
console.log(`Provider: ${task.providerId}`);
|
||||
console.log(`Session: ${attached.sessionId ?? "none"}`);
|
||||
console.log(`Target: ${switched.switchTarget || attached.target}`);
|
||||
console.log(`Title: ${task.title ?? "-"}`);
|
||||
writeStdout(`Branch: ${task.branchName ?? "-"}`);
|
||||
writeStdout(`Task: ${task.taskId}`);
|
||||
writeStdout(`Provider: ${task.providerId}`);
|
||||
writeStdout(`Session: ${attached.sessionId ?? "none"}`);
|
||||
writeStdout(`Target: ${switched.switchTarget || attached.target}`);
|
||||
writeStdout(`Title: ${task.title ?? "-"}`);
|
||||
|
||||
const tmuxResult = spawnCreateTmuxWindow({
|
||||
branchName: task.branchName ?? task.taskId,
|
||||
|
|
@ -479,14 +480,14 @@ async function handleCreate(args: string[]): Promise<void> {
|
|||
});
|
||||
|
||||
if (tmuxResult.created) {
|
||||
console.log(`Window: created (${task.branchName})`);
|
||||
writeStdout(`Window: created (${task.branchName})`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("");
|
||||
console.log(`Run: hf switch ${task.taskId}`);
|
||||
writeStdout("");
|
||||
writeStdout(`Run: hf switch ${task.taskId}`);
|
||||
if ((switched.switchTarget || attached.target).startsWith("/")) {
|
||||
console.log(`cd ${switched.switchTarget || attached.target}`);
|
||||
writeStdout(`cd ${switched.switchTarget || attached.target}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -510,7 +511,7 @@ async function handleStatus(args: string[]): Promise<void> {
|
|||
const summary = summarizeTasks(rows);
|
||||
|
||||
if (hasFlag(args, "--json")) {
|
||||
console.log(
|
||||
writeStdout(
|
||||
JSON.stringify(
|
||||
{
|
||||
workspaceId,
|
||||
|
|
@ -528,16 +529,16 @@ async function handleStatus(args: string[]): Promise<void> {
|
|||
return;
|
||||
}
|
||||
|
||||
console.log(`workspace=${workspaceId}`);
|
||||
console.log(`backend running=${backendStatus.running} pid=${backendStatus.pid ?? "unknown"} version=${backendStatus.version ?? "unknown"}`);
|
||||
console.log(`tasks total=${summary.total}`);
|
||||
console.log(
|
||||
writeStdout(`workspace=${workspaceId}`);
|
||||
writeStdout(`backend running=${backendStatus.running} pid=${backendStatus.pid ?? "unknown"} version=${backendStatus.version ?? "unknown"}`);
|
||||
writeStdout(`tasks total=${summary.total}`);
|
||||
writeStdout(
|
||||
`status queued=${summary.byStatus.queued} running=${summary.byStatus.running} idle=${summary.byStatus.idle} archived=${summary.byStatus.archived} killed=${summary.byStatus.killed} error=${summary.byStatus.error}`,
|
||||
);
|
||||
const providerSummary = Object.entries(summary.byProvider)
|
||||
.map(([provider, count]) => `${provider}=${count}`)
|
||||
.join(" ");
|
||||
console.log(`providers ${providerSummary || "-"}`);
|
||||
writeStdout(`providers ${providerSummary || "-"}`);
|
||||
}
|
||||
|
||||
async function handleHistory(args: string[]): Promise<void> {
|
||||
|
|
@ -560,12 +561,12 @@ async function handleHistory(args: string[]): Promise<void> {
|
|||
});
|
||||
|
||||
if (hasFlag(args, "--json")) {
|
||||
console.log(JSON.stringify(rows, null, 2));
|
||||
writeStdout(JSON.stringify(rows, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
console.log("no events");
|
||||
writeStdout("no events");
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -576,7 +577,7 @@ async function handleHistory(args: string[]): Promise<void> {
|
|||
if (payload.length > 120) {
|
||||
payload = `${payload.slice(0, 117)}...`;
|
||||
}
|
||||
console.log(`${ts}\t${row.kind}\t${target}\t${payload}`);
|
||||
writeStdout(`${ts}\t${row.kind}\t${target}\t${payload}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -611,19 +612,19 @@ async function handleSwitchLike(cmd: string, args: string[]): Promise<void> {
|
|||
|
||||
if (cmd === "switch") {
|
||||
const result = await client.switchTask(workspaceId, taskId);
|
||||
console.log(`cd ${result.switchTarget}`);
|
||||
writeStdout(`cd ${result.switchTarget}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === "attach") {
|
||||
const result = await client.attachTask(workspaceId, taskId);
|
||||
console.log(`target=${result.target} session=${result.sessionId ?? "none"}`);
|
||||
writeStdout(`target=${result.target} session=${result.sessionId ?? "none"}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd === "merge" || cmd === "archive") {
|
||||
await client.runAction(workspaceId, taskId, cmd);
|
||||
console.log("ok");
|
||||
writeStdout("ok");
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -726,6 +727,6 @@ async function main(): Promise<void> {
|
|||
|
||||
main().catch((err: unknown) => {
|
||||
const msg = err instanceof Error ? (err.stack ?? err.message) : String(err);
|
||||
console.error(msg);
|
||||
writeStderr(msg);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
|
|||
7
foundry/packages/cli/src/io.ts
Normal file
7
foundry/packages/cli/src/io.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export function writeStdout(message = ""): void {
|
||||
process.stdout.write(`${message}\n`);
|
||||
}
|
||||
|
||||
export function writeStderr(message = ""): void {
|
||||
process.stderr.write(`${message}\n`);
|
||||
}
|
||||
5
foundry/packages/cli/src/logging.ts
Normal file
5
foundry/packages/cli/src/logging.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { createFoundryLogger } from "@sandbox-agent/foundry-shared";
|
||||
|
||||
export const logger = createFoundryLogger({
|
||||
service: "foundry-cli",
|
||||
});
|
||||
|
|
@ -2,6 +2,7 @@ import type { AppConfig, TaskRecord } from "@sandbox-agent/foundry-shared";
|
|||
import { spawnSync } from "node:child_process";
|
||||
import { createBackendClientFromConfig, filterTasks, formatRelativeAge, groupTaskStatus } from "@sandbox-agent/foundry-client";
|
||||
import { CLI_BUILD_ID } from "./build-id.js";
|
||||
import { writeStdout } from "./io.js";
|
||||
import { resolveTuiTheme, type TuiTheme } from "./theme.js";
|
||||
|
||||
interface KeyEventLike {
|
||||
|
|
@ -412,7 +413,7 @@ export async function runTui(config: AppConfig, workspaceId: string): Promise<vo
|
|||
process.off("SIGTERM", handleSignal);
|
||||
renderer.destroy();
|
||||
if (output) {
|
||||
console.log(output);
|
||||
writeStdout(output);
|
||||
}
|
||||
resolveDone();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -259,7 +259,7 @@ export interface BackendClient {
|
|||
}
|
||||
|
||||
export function rivetEndpoint(config: AppConfig): string {
|
||||
return `http://${config.backend.host}:${config.backend.port}/api/rivet`;
|
||||
return `http://${config.backend.host}:${config.backend.port}/v1/rivet`;
|
||||
}
|
||||
|
||||
export function createBackendClientFromConfig(config: AppConfig): BackendClient {
|
||||
|
|
@ -269,6 +269,32 @@ export function createBackendClientFromConfig(config: AppConfig): BackendClient
|
|||
});
|
||||
}
|
||||
|
||||
function stripTrailingSlash(value: string): string {
|
||||
return value.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function normalizeLegacyBackendEndpoint(endpoint: string): string {
|
||||
const normalized = stripTrailingSlash(endpoint);
|
||||
if (normalized.endsWith("/api/rivet")) {
|
||||
return `${normalized.slice(0, -"/api/rivet".length)}/v1/rivet`;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function deriveBackendEndpoints(endpoint: string): { appEndpoint: string; rivetEndpoint: string } {
|
||||
const normalized = normalizeLegacyBackendEndpoint(endpoint);
|
||||
if (normalized.endsWith("/rivet")) {
|
||||
return {
|
||||
appEndpoint: normalized.slice(0, -"/rivet".length),
|
||||
rivetEndpoint: normalized,
|
||||
};
|
||||
}
|
||||
return {
|
||||
appEndpoint: normalized,
|
||||
rivetEndpoint: `${normalized}/rivet`,
|
||||
};
|
||||
}
|
||||
|
||||
function isLoopbackHost(hostname: string): boolean {
|
||||
const h = hostname.toLowerCase();
|
||||
return h === "127.0.0.1" || h === "localhost" || h === "0.0.0.0" || h === "::1";
|
||||
|
|
@ -386,6 +412,9 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
|
|||
return createMockBackendClient(options.defaultWorkspaceId);
|
||||
}
|
||||
|
||||
const endpoints = deriveBackendEndpoints(options.endpoint);
|
||||
const rivetApiEndpoint = endpoints.rivetEndpoint;
|
||||
const appApiEndpoint = endpoints.appEndpoint;
|
||||
let clientPromise: Promise<RivetClient> | null = null;
|
||||
let appSessionId = typeof window !== "undefined" ? window.localStorage.getItem("sandbox-agent-foundry:remote-app-session") : null;
|
||||
const workbenchSubscriptions = new Map<
|
||||
|
|
@ -434,7 +463,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
|
|||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
const res = await fetch(`${options.endpoint.replace(/\/$/, "")}${path}`, {
|
||||
const res = await fetch(`${appApiEndpoint}${path}`, {
|
||||
...init,
|
||||
headers,
|
||||
credentials: "include",
|
||||
|
|
@ -465,22 +494,22 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
|
|||
// Use the serverless /metadata endpoint to discover the manager endpoint.
|
||||
// If the server reports a loopback clientEndpoint (127.0.0.1), rewrite to the same host
|
||||
// as the configured endpoint so remote browsers/clients can connect.
|
||||
const configured = new URL(options.endpoint);
|
||||
const configured = new URL(rivetApiEndpoint);
|
||||
const configuredOrigin = `${configured.protocol}//${configured.host}`;
|
||||
|
||||
const initialNamespace = undefined;
|
||||
const metadata = await fetchMetadataWithRetry(options.endpoint, initialNamespace, {
|
||||
const metadata = await fetchMetadataWithRetry(rivetApiEndpoint, initialNamespace, {
|
||||
timeoutMs: 30_000,
|
||||
requestTimeoutMs: 8_000,
|
||||
});
|
||||
|
||||
// Candidate endpoint: manager endpoint if provided, otherwise stick to the configured endpoint.
|
||||
const candidateEndpoint = metadata.clientEndpoint ? rewriteLoopbackClientEndpoint(metadata.clientEndpoint, configuredOrigin) : options.endpoint;
|
||||
const candidateEndpoint = metadata.clientEndpoint ? rewriteLoopbackClientEndpoint(metadata.clientEndpoint, configuredOrigin) : rivetApiEndpoint;
|
||||
|
||||
// If the manager port isn't reachable from this client (common behind reverse proxies),
|
||||
// fall back to the configured serverless endpoint to avoid hanging requests.
|
||||
const shouldUseCandidate = metadata.clientEndpoint ? await probeMetadataEndpoint(candidateEndpoint, metadata.clientNamespace, 1_500) : true;
|
||||
const resolvedEndpoint = shouldUseCandidate ? candidateEndpoint : options.endpoint;
|
||||
const resolvedEndpoint = shouldUseCandidate ? candidateEndpoint : rivetApiEndpoint;
|
||||
|
||||
return createClient({
|
||||
endpoint: resolvedEndpoint,
|
||||
|
|
@ -676,10 +705,10 @@ export function createBackendClient(options: BackendClientOptions): BackendClien
|
|||
|
||||
async signInWithGithub(): Promise<void> {
|
||||
if (typeof window !== "undefined") {
|
||||
window.location.assign(`${options.endpoint.replace(/\/$/, "")}/app/auth/github/start`);
|
||||
window.location.assign(`${appApiEndpoint}/auth/github/start`);
|
||||
return;
|
||||
}
|
||||
await redirectTo("/app/auth/github/start");
|
||||
await redirectTo("/auth/github/start");
|
||||
},
|
||||
|
||||
async signOutApp(): Promise<FoundryAppSnapshot> {
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@ async function ensureRemoteBranchExists(token: string, fullName: string, branchN
|
|||
|
||||
describe("e2e(client): full integration stack workflow", () => {
|
||||
it.skipIf(!RUN_FULL_E2E)("adds repo, loads branch graph, and executes a stack restack action", { timeout: 8 * 60_000 }, async () => {
|
||||
const endpoint = process.env.HF_E2E_BACKEND_ENDPOINT?.trim() || "http://127.0.0.1:7741/api/rivet";
|
||||
const endpoint = process.env.HF_E2E_BACKEND_ENDPOINT?.trim() || "http://127.0.0.1:7741/v1/rivet";
|
||||
const workspaceId = process.env.HF_E2E_WORKSPACE?.trim() || "default";
|
||||
const repoRemote = requiredEnv("HF_E2E_GITHUB_REPO");
|
||||
const githubToken = requiredEnv("GITHUB_TOKEN");
|
||||
|
|
|
|||
|
|
@ -144,7 +144,7 @@ async function githubApi(token: string, path: string, init?: RequestInit): Promi
|
|||
|
||||
describe("e2e: backend -> sandbox-agent -> git -> PR", () => {
|
||||
it.skipIf(!RUN_E2E)("creates a task, waits for agent to implement, and opens a PR", { timeout: 15 * 60_000 }, async () => {
|
||||
const endpoint = process.env.HF_E2E_BACKEND_ENDPOINT?.trim() || "http://127.0.0.1:7741/api/rivet";
|
||||
const endpoint = process.env.HF_E2E_BACKEND_ENDPOINT?.trim() || "http://127.0.0.1:7741/v1/rivet";
|
||||
const workspaceId = process.env.HF_E2E_WORKSPACE?.trim() || "default";
|
||||
const repoRemote = requiredEnv("HF_E2E_GITHUB_REPO");
|
||||
const githubToken = requiredEnv("GITHUB_TOKEN");
|
||||
|
|
|
|||
|
|
@ -145,7 +145,7 @@ function transcriptIncludesAgentText(transcript: WorkbenchTranscriptEvent[], exp
|
|||
|
||||
describe("e2e(client): workbench flows", () => {
|
||||
it.skipIf(!RUN_WORKBENCH_E2E)("creates a task, adds sessions, exchanges messages, and manages workbench state", { timeout: 20 * 60_000 }, async () => {
|
||||
const endpoint = process.env.HF_E2E_BACKEND_ENDPOINT?.trim() || "http://127.0.0.1:7741/api/rivet";
|
||||
const endpoint = process.env.HF_E2E_BACKEND_ENDPOINT?.trim() || "http://127.0.0.1:7741/v1/rivet";
|
||||
const workspaceId = process.env.HF_E2E_WORKSPACE?.trim() || "default";
|
||||
const repoRemote = requiredEnv("HF_E2E_GITHUB_REPO");
|
||||
const model = workbenchModelEnv("HF_E2E_MODEL", "gpt-4o");
|
||||
|
|
|
|||
|
|
@ -1,8 +1,21 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { TaskWorkbenchSnapshot, WorkbenchAgentTab, WorkbenchTask, WorkbenchModelId, WorkbenchTranscriptEvent } from "@sandbox-agent/foundry-shared";
|
||||
import {
|
||||
createFoundryLogger,
|
||||
type TaskWorkbenchSnapshot,
|
||||
type WorkbenchAgentTab,
|
||||
type WorkbenchTask,
|
||||
type WorkbenchModelId,
|
||||
type WorkbenchTranscriptEvent,
|
||||
} from "@sandbox-agent/foundry-shared";
|
||||
import { createBackendClient } from "../../src/backend-client.js";
|
||||
|
||||
const RUN_WORKBENCH_LOAD_E2E = process.env.HF_ENABLE_DAEMON_WORKBENCH_LOAD_E2E === "1";
|
||||
const logger = createFoundryLogger({
|
||||
service: "foundry-client-e2e",
|
||||
bindings: {
|
||||
suite: "workbench-load",
|
||||
},
|
||||
});
|
||||
|
||||
function requiredEnv(name: string): string {
|
||||
const value = process.env[name]?.trim();
|
||||
|
|
@ -175,7 +188,7 @@ async function measureWorkbenchSnapshot(
|
|||
|
||||
describe("e2e(client): workbench load", () => {
|
||||
it.skipIf(!RUN_WORKBENCH_LOAD_E2E)("runs a simple sequential load profile against the real backend", { timeout: 30 * 60_000 }, async () => {
|
||||
const endpoint = process.env.HF_E2E_BACKEND_ENDPOINT?.trim() || "http://127.0.0.1:7741/api/rivet";
|
||||
const endpoint = process.env.HF_E2E_BACKEND_ENDPOINT?.trim() || "http://127.0.0.1:7741/v1/rivet";
|
||||
const workspaceId = process.env.HF_E2E_WORKSPACE?.trim() || "default";
|
||||
const repoRemote = requiredEnv("HF_E2E_GITHUB_REPO");
|
||||
const model = workbenchModelEnv("HF_E2E_MODEL", "gpt-4o");
|
||||
|
|
@ -269,12 +282,12 @@ describe("e2e(client): workbench load", () => {
|
|||
|
||||
const snapshotMetrics = await measureWorkbenchSnapshot(client, workspaceId, 3);
|
||||
snapshotSeries.push(snapshotMetrics);
|
||||
console.info(
|
||||
"[workbench-load-snapshot]",
|
||||
JSON.stringify({
|
||||
logger.info(
|
||||
{
|
||||
taskIndex: taskIndex + 1,
|
||||
...snapshotMetrics,
|
||||
}),
|
||||
},
|
||||
"workbench_load_snapshot",
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -296,7 +309,7 @@ describe("e2e(client): workbench load", () => {
|
|||
snapshotTranscriptFinalCount: lastSnapshot.transcriptEventCount,
|
||||
};
|
||||
|
||||
console.info("[workbench-load-summary]", JSON.stringify(summary));
|
||||
logger.info(summary, "workbench_load_summary");
|
||||
|
||||
expect(createTaskLatencies.length).toBe(taskCount);
|
||||
expect(provisionLatencies.length).toBe(taskCount);
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
"tsx": "^4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@sandbox-agent/foundry-shared": "workspace:*",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-shell": "^2"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,15 +2,22 @@ import { execSync } from "node:child_process";
|
|||
import { cpSync, readFileSync, writeFileSync, rmSync, existsSync } from "node:fs";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createFoundryLogger } from "@sandbox-agent/foundry-shared";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const desktopRoot = resolve(__dirname, "..");
|
||||
const repoRoot = resolve(desktopRoot, "../../..");
|
||||
const frontendDist = resolve(desktopRoot, "../frontend/dist");
|
||||
const destDir = resolve(desktopRoot, "frontend-dist");
|
||||
const logger = createFoundryLogger({
|
||||
service: "foundry-desktop-build",
|
||||
bindings: {
|
||||
script: "build-frontend",
|
||||
},
|
||||
});
|
||||
|
||||
function run(cmd: string, opts?: { cwd?: string; env?: NodeJS.ProcessEnv }) {
|
||||
console.log(`> ${cmd}`);
|
||||
logger.info({ command: cmd, cwd: opts?.cwd ?? repoRoot }, "run_command");
|
||||
execSync(cmd, {
|
||||
stdio: "inherit",
|
||||
cwd: opts?.cwd ?? repoRoot,
|
||||
|
|
@ -19,15 +26,15 @@ function run(cmd: string, opts?: { cwd?: string; env?: NodeJS.ProcessEnv }) {
|
|||
}
|
||||
|
||||
// Step 1: Build the frontend with the desktop-specific backend endpoint
|
||||
console.log("\n=== Building frontend for desktop ===\n");
|
||||
logger.info("building_frontend");
|
||||
run("pnpm --filter @sandbox-agent/foundry-frontend build", {
|
||||
env: {
|
||||
VITE_HF_BACKEND_ENDPOINT: "http://127.0.0.1:7741/api/rivet",
|
||||
VITE_HF_BACKEND_ENDPOINT: "http://127.0.0.1:7741/v1/rivet",
|
||||
},
|
||||
});
|
||||
|
||||
// Step 2: Copy dist to frontend-dist/
|
||||
console.log("\n=== Copying frontend build output ===\n");
|
||||
logger.info({ frontendDist, destDir }, "copying_frontend_dist");
|
||||
if (existsSync(destDir)) {
|
||||
rmSync(destDir, { recursive: true });
|
||||
}
|
||||
|
|
@ -39,4 +46,4 @@ let html = readFileSync(indexPath, "utf-8");
|
|||
html = html.replace(/<script\s+src="https:\/\/unpkg\.com\/react-scan\/dist\/auto\.global\.js"[^>]*><\/script>\s*/g, "");
|
||||
writeFileSync(indexPath, html);
|
||||
|
||||
console.log("\n=== Frontend build complete ===\n");
|
||||
logger.info({ indexPath }, "frontend_build_complete");
|
||||
|
|
|
|||
|
|
@ -2,10 +2,17 @@ import { execSync } from "node:child_process";
|
|||
import { mkdirSync, existsSync } from "node:fs";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createFoundryLogger } from "@sandbox-agent/foundry-shared";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const desktopRoot = resolve(__dirname, "..");
|
||||
const sidecarDir = resolve(desktopRoot, "src-tauri/sidecars");
|
||||
const logger = createFoundryLogger({
|
||||
service: "foundry-desktop-build",
|
||||
bindings: {
|
||||
script: "build-sidecar",
|
||||
},
|
||||
});
|
||||
|
||||
const isDev = process.argv.includes("--dev");
|
||||
|
||||
|
|
@ -35,7 +42,7 @@ const targets: Array<{ bunTarget: string; tripleTarget: string }> = isDev
|
|||
];
|
||||
|
||||
function run(cmd: string, opts?: { cwd?: string; env?: NodeJS.ProcessEnv }) {
|
||||
console.log(`> ${cmd}`);
|
||||
logger.info({ command: cmd, cwd: opts?.cwd ?? desktopRoot }, "run_command");
|
||||
execSync(cmd, {
|
||||
stdio: "inherit",
|
||||
cwd: opts?.cwd ?? desktopRoot,
|
||||
|
|
@ -44,7 +51,7 @@ function run(cmd: string, opts?: { cwd?: string; env?: NodeJS.ProcessEnv }) {
|
|||
}
|
||||
|
||||
// Step 1: Build the backend with tsup
|
||||
console.log("\n=== Building backend with tsup ===\n");
|
||||
logger.info("building_backend");
|
||||
run("pnpm --filter @sandbox-agent/foundry-backend build", {
|
||||
cwd: resolve(desktopRoot, "../../.."),
|
||||
});
|
||||
|
|
@ -55,14 +62,14 @@ mkdirSync(sidecarDir, { recursive: true });
|
|||
const backendEntry = resolve(desktopRoot, "../backend/dist/index.js");
|
||||
|
||||
if (!existsSync(backendEntry)) {
|
||||
console.error(`Backend build output not found at ${backendEntry}`);
|
||||
logger.error({ backendEntry }, "backend_build_output_not_found");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
for (const { bunTarget, tripleTarget } of targets) {
|
||||
const outfile = resolve(sidecarDir, `foundry-backend-${tripleTarget}`);
|
||||
console.log(`\n=== Compiling sidecar for ${tripleTarget} ===\n`);
|
||||
logger.info({ bunTarget, tripleTarget, outfile }, "compiling_sidecar");
|
||||
run(`bun build --compile --target ${bunTarget} ${backendEntry} --outfile ${outfile}`);
|
||||
}
|
||||
|
||||
console.log("\n=== Sidecar build complete ===\n");
|
||||
logger.info({ targets: targets.map((target) => target.tripleTarget) }, "sidecar_build_complete");
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ fn get_backend_url() -> String {
|
|||
|
||||
#[tauri::command]
|
||||
async fn backend_health() -> Result<bool, String> {
|
||||
match reqwest::get("http://127.0.0.1:7741/api/rivet/metadata").await {
|
||||
match reqwest::get("http://127.0.0.1:7741/v1/rivet/metadata").await {
|
||||
Ok(resp) => Ok(resp.status().is_success()),
|
||||
Err(_) => Ok(false),
|
||||
}
|
||||
|
|
@ -32,7 +32,7 @@ async fn wait_for_backend(timeout_secs: u64) -> Result<(), String> {
|
|||
));
|
||||
}
|
||||
|
||||
match reqwest::get("http://127.0.0.1:7741/api/rivet/metadata").await {
|
||||
match reqwest::get("http://127.0.0.1:7741/v1/rivet/metadata").await {
|
||||
Ok(resp) if resp.status().is_success() => return Ok(()),
|
||||
_ => {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore, type PointerEvent as ReactPointerEvent } from "react";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useStyletron } from "baseui";
|
||||
import { createErrorContext } from "@sandbox-agent/foundry-shared";
|
||||
|
||||
import { PanelLeft, PanelRight } from "lucide-react";
|
||||
import { useFoundryTokens } from "../app/theme";
|
||||
import { logger } from "../logging.js";
|
||||
|
||||
import { DiffContent } from "./mock-layout/diff-content";
|
||||
import { MessageList } from "./mock-layout/message-list";
|
||||
|
|
@ -437,7 +439,13 @@ const TranscriptPanel = memo(function TranscriptPanel({
|
|||
await window.navigator.clipboard.writeText(message.text);
|
||||
setCopiedMessageId(message.id);
|
||||
} catch (error) {
|
||||
console.error("Failed to copy transcript message", error);
|
||||
logger.error(
|
||||
{
|
||||
messageId: message.id,
|
||||
...createErrorContext(error),
|
||||
},
|
||||
"failed_to_copy_transcript_message",
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
|
@ -1108,7 +1116,13 @@ export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: M
|
|||
const { tabId } = await taskWorkbenchClient.addTab({ taskId: activeTask.id });
|
||||
syncRouteSession(activeTask.id, tabId, true);
|
||||
} catch (error) {
|
||||
console.error("failed to auto-create workbench session", error);
|
||||
logger.error(
|
||||
{
|
||||
taskId: activeTask.id,
|
||||
...createErrorContext(error),
|
||||
},
|
||||
"failed_to_auto_create_workbench_session",
|
||||
);
|
||||
} finally {
|
||||
autoCreatingSessionForTaskRef.current.delete(activeTask.id);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { LabelSmall } from "baseui/typography";
|
|||
import { Archive, ArrowUpFromLine, ChevronRight, FileCode, FilePlus, FileX, FolderOpen, GitPullRequest, PanelRight } from "lucide-react";
|
||||
|
||||
import { useFoundryTokens } from "../../app/theme";
|
||||
import { createErrorContext } from "@sandbox-agent/foundry-shared";
|
||||
import { logger } from "../../logging.js";
|
||||
import { type ContextMenuItem, ContextMenuOverlay, PanelHeaderBar, SPanel, ScrollBody, useContextMenu } from "./ui";
|
||||
import { type FileTreeNode, type Task, diffTabId } from "./view-model";
|
||||
|
||||
|
|
@ -131,7 +133,13 @@ export const RightSidebar = memo(function RightSidebar({
|
|||
|
||||
await window.navigator.clipboard.writeText(path);
|
||||
} catch (error) {
|
||||
console.error("Failed to copy file path", error);
|
||||
logger.error(
|
||||
{
|
||||
path,
|
||||
...createErrorContext(error),
|
||||
},
|
||||
"failed_to_copy_file_path",
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
|
|
@ -12,9 +12,9 @@ declare global {
|
|||
|
||||
function resolveDefaultBackendEndpoint(): string {
|
||||
if (typeof window !== "undefined" && window.location?.origin) {
|
||||
return `${window.location.origin}/api/rivet`;
|
||||
return `${window.location.origin}/v1/rivet`;
|
||||
}
|
||||
return "http://127.0.0.1:7741/api/rivet";
|
||||
return "http://127.0.0.1:7741/v1/rivet";
|
||||
}
|
||||
|
||||
type FrontendImportMetaEnv = ImportMetaEnv & {
|
||||
|
|
|
|||
5
foundry/packages/frontend/src/logging.ts
Normal file
5
foundry/packages/frontend/src/logging.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { createFoundryLogger } from "@sandbox-agent/foundry-shared";
|
||||
|
||||
export const logger = createFoundryLogger({
|
||||
service: "foundry-frontend",
|
||||
});
|
||||
|
|
@ -19,7 +19,7 @@ export default defineConfig({
|
|||
server: {
|
||||
port: 4173,
|
||||
proxy: {
|
||||
"/api/rivet": {
|
||||
"/v1": {
|
||||
target: backendProxyTarget,
|
||||
changeOrigin: true,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"pino": "^10.3.1",
|
||||
"zod": "^4.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
export * from "./app-shell.js";
|
||||
export * from "./contracts.js";
|
||||
export * from "./config.js";
|
||||
export * from "./logging.js";
|
||||
export * from "./workbench.js";
|
||||
export * from "./workspace.js";
|
||||
|
|
|
|||
63
foundry/packages/shared/src/logging.ts
Normal file
63
foundry/packages/shared/src/logging.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { pino, type Logger, type LoggerOptions } from "pino";
|
||||
|
||||
export interface FoundryLoggerOptions {
|
||||
service: string;
|
||||
bindings?: Record<string, unknown>;
|
||||
level?: string;
|
||||
}
|
||||
|
||||
type ProcessLike = {
|
||||
env?: Record<string, string | undefined>;
|
||||
};
|
||||
|
||||
function resolveEnvVar(name: string): string | undefined {
|
||||
const value = (globalThis as { process?: ProcessLike }).process?.env?.[name];
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function defaultLevel(): string {
|
||||
return resolveEnvVar("FOUNDRY_LOG_LEVEL") ?? resolveEnvVar("LOG_LEVEL") ?? resolveEnvVar("RIVET_LOG_LEVEL") ?? "info";
|
||||
}
|
||||
|
||||
function isBrowserRuntime(): boolean {
|
||||
return typeof window !== "undefined" && typeof document !== "undefined";
|
||||
}
|
||||
|
||||
export function createFoundryLogger(options: FoundryLoggerOptions): Logger {
|
||||
const browser = isBrowserRuntime();
|
||||
const loggerOptions: LoggerOptions = {
|
||||
level: options.level ?? defaultLevel(),
|
||||
base: {
|
||||
service: options.service,
|
||||
...(options.bindings ?? {}),
|
||||
},
|
||||
};
|
||||
|
||||
if (browser) {
|
||||
loggerOptions.browser = {
|
||||
asObject: true,
|
||||
};
|
||||
} else {
|
||||
loggerOptions.timestamp = pino.stdTimeFunctions.isoTime;
|
||||
}
|
||||
|
||||
return pino(loggerOptions);
|
||||
}
|
||||
|
||||
export function createErrorContext(error: unknown): { errorMessage: string; errorStack?: string } {
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
errorMessage: error.message,
|
||||
errorStack: error.stack,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
errorMessage: String(error),
|
||||
};
|
||||
}
|
||||
|
|
@ -1,5 +1,33 @@
|
|||
# General Friction Log
|
||||
|
||||
## 2026-03-13 - uncommitted
|
||||
|
||||
### What I Was Working On
|
||||
|
||||
Debugging slow GitHub OAuth sign-in in production after deploying backend request logging (d0ed0a4).
|
||||
|
||||
### Friction / Issue
|
||||
|
||||
Production logs showed two separate HTTP requests (different request IDs, ~9s apart) hitting `GET /v1/auth/github/callback` with the same `code` and `state` parameters. The first request succeeded (`exchangeCode` returned a token) but took ~18s total due to `syncGithubSessionFromToken` making multiple sequential GitHub API calls. The second request arrived while the first was still syncing, passed the oauth state validation (state was never cleared), and attempted `exchangeCode` with the already-consumed code, which GitHub rejected with `bad_verification_code`.
|
||||
|
||||
The root cause of the duplicate HTTP request is unknown. It is not `appWorkspaceAction` (no retry logic in the current version), not Railway proxy retry (no such config), and not a frontend double-navigation (the SPA is not involved during the OAuth redirect chain). Best hypothesis is the user refreshing during the ~18s blank page wait, but unconfirmed.
|
||||
|
||||
### Attempted Fix / Workaround
|
||||
|
||||
1. Made `completeAppGithubAuth` clear `oauthState`/`oauthStateExpiresAt` immediately after validation and before `exchangeCode`, so any duplicate request fails the state check instead of hitting GitHub with a consumed code.
|
||||
2. Split `syncGithubSessionFromToken` into a fast path (`initGithubSession` — exchange code, get viewer, store token+identity) and a slow path (`syncGithubOrganizations` — list orgs, list installations, sync each workspace).
|
||||
3. `completeAppGithubAuth` now uses the fast path and enqueues the slow org sync to the workspace workflow queue (`workspace.command.syncGithubSession`, fire-and-forget). The HTTP callback returns a 302 redirect in ~2s instead of ~18s, eliminating the proxy timeout window.
|
||||
4. The frontend already polls `getAppSnapshot` every 500ms when any org has `syncStatus === "syncing"`, so the deferred sync is transparent to the user.
|
||||
5. `bootstrapAppGithubSession` (dev-only) still calls the full synchronous `syncGithubSessionFromToken` since proxy timeouts are not a concern in dev and it needs the session fully populated before returning.
|
||||
|
||||
### Outcome
|
||||
|
||||
- OAuth callback responds in ~2s (exchangeCode + getViewer) instead of ~18s.
|
||||
- Proxy retry window is eliminated — no duplicate requests should occur.
|
||||
- Duplicate requests are still guarded by the state-clearing idempotency check.
|
||||
- Organization data populates asynchronously via the workflow queue; the frontend shows loading state and polls until complete.
|
||||
- Root cause of the duplicate HTTP request (likely Railway/Cloudflare proxy retry on slow GET) remains uninvestigated but is no longer a practical problem.
|
||||
|
||||
## 2026-03-05 - uncommitted
|
||||
|
||||
### What I Was Working On
|
||||
|
|
|
|||
|
|
@ -1,5 +1,35 @@
|
|||
# Rivet Friction Log
|
||||
|
||||
## 2026-03-12 - 63df393
|
||||
|
||||
### What I Was Working On
|
||||
|
||||
Resolving GitHub OAuth callback failures caused by stale actor state after squashing Drizzle migrations.
|
||||
|
||||
### Friction / Issue
|
||||
|
||||
1. **Squashing Drizzle migrations breaks existing actors on Rivet Cloud.** When Drizzle migrations are squashed into a new baseline (`0000_*.sql`), the squashed migration has a different hash/name than the original migrations tracked in each actor's `__drizzle_migrations` journal table. On next wake, Drizzle sees the squashed baseline as a "new" migration and attempts to re-run `CREATE TABLE` statements, which fail because the tables already exist. This silently poisons the actor — RivetKit wraps the migration error as a generic "Internal error" on the action response, making root-cause diagnosis difficult.
|
||||
|
||||
2. **No programmatic way to list or destroy actors on Rivet Cloud without the service key.** The public runner token (`pk_*`) lacks permissions for actor management (list/destroy). The Cloud API token (`cloud_api_*`) in our `.env` was returning "token not found". The actual working token format is the service key (`sk_*`) from the namespace connection URL. This was not documented — the destroy docs reference "admin tokens" which are described as "currently not supported on Rivet Cloud" ([#3530](https://github.com/rivet-dev/rivet/issues/3530)), but the `sk_*` token works. The disconnect between the docs and reality cost significant debugging time.
|
||||
|
||||
3. **Actor errors during `getOrCreate` are opaque.** When the `workspace.completeAppGithubAuth` action triggered `getOrCreate` for org workspace actors, the migration failure inside the newly-woken actor was surfaced as `"Internal error"` with no indication that it was a migration/schema issue. The actual error (`table already exists`) was only visible in actor-level logs, not in the action response or the calling backend's logs.
|
||||
|
||||
### Attempted Fix / Workaround
|
||||
|
||||
1. Initially tried adding `IF NOT EXISTS` to all `CREATE TABLE`/`CREATE UNIQUE INDEX` statements in the squashed baseline migrations. This masked the symptom but violated Drizzle's migration tracking contract — the journal would still be inconsistent.
|
||||
|
||||
2. Reverted the `IF NOT EXISTS` hack and instead destroyed all stale actors via the Rivet Cloud API (`DELETE /actors/{actorId}?namespace={ns}` with the `sk_*` service key). Fresh actors get a clean migration journal matching the squashed baseline.
|
||||
|
||||
### Outcome
|
||||
|
||||
- All 4 stale workspace actors destroyed (3 org workspaces + 1 old v2-prefixed app workspace).
|
||||
- Reverted `IF NOT EXISTS` migration changes so Drizzle migrations remain standard.
|
||||
- After redeploy, new actors will be created fresh with the correct squashed migration journal.
|
||||
- **RivetKit improvement opportunities:**
|
||||
- Surface migration errors in action responses instead of generic "Internal error".
|
||||
- Document the `sk_*` service key as the correct token for actor management API calls, or make `cloud_api_*` tokens work.
|
||||
- Consider a migration reconciliation mode for Drizzle actors that detects "tables exist but journal doesn't match" and adopts the current schema state instead of failing.
|
||||
|
||||
## 2026-02-18 - uncommitted
|
||||
|
||||
### What I Was Working On
|
||||
|
|
|
|||
|
|
@ -0,0 +1,308 @@
|
|||
# End-To-End Async + Realtime Plan
|
||||
|
||||
## Purpose
|
||||
|
||||
This is the umbrella plan for the Foundry issues we traced across app shell, workbench, and actor runtime behavior:
|
||||
|
||||
- long-running work still sits inline in request/action paths
|
||||
- monolithic snapshot reads fan out across too many actors
|
||||
- the client uses polling and full refreshes where it should use realtime subscriptions
|
||||
- websocket subscriptions reconnect too aggressively
|
||||
- actor shutdown can race in-flight actions and clear `c.db` underneath them
|
||||
|
||||
The goal is not just to make individual endpoints faster. The goal is to move Foundry to a model where:
|
||||
|
||||
- request paths only validate, create minimal state, and enqueue background work
|
||||
- list views read actor-owned projections instead of recomputing deep state
|
||||
- detail views connect directly to the actor that owns the visible state
|
||||
- polling is replaced by actor events and bounded bootstrap fetches
|
||||
- actor shutdown drains active work before cleaning up resources
|
||||
|
||||
## Problem Summary
|
||||
|
||||
### App shell
|
||||
|
||||
- `getAppSnapshot` still rebuilds app shell state by reading the app session row and fanning out to every eligible organization actor.
|
||||
- `RemoteFoundryAppStore` still polls every `500ms` while any org is `syncing`.
|
||||
- Org sync/import is now off the select path, but the steady-state read path is still snapshot-based instead of subscription-based.
|
||||
|
||||
### Workbench
|
||||
|
||||
- `getWorkbench` still represents a monolithic workspace read that aggregates repo, project, and task state.
|
||||
- The remote workbench store still responds to every event by pulling a full fresh snapshot.
|
||||
- Some task/workbench detail is still too expensive to compute inline and too broad to refresh after every mutation.
|
||||
|
||||
### Realtime transport
|
||||
|
||||
- `subscribeWorkbench` and related connection helpers keep one connection per shared key, but the client contract still treats the socket as an invalidation channel for a later snapshot pull.
|
||||
- Reconnect/error handling is weak, so connection churn amplifies backend load instead of settling into long-lived subscriptions.
|
||||
|
||||
### Runtime
|
||||
|
||||
- RivetKit currently lets shutdown proceed far enough to clean up actor resources while actions can still be in flight or still be routed to the actor.
|
||||
- That creates the `Database not enabled` / missing `c.db` failure mode under stop/replay pressure.
|
||||
|
||||
## Target Architecture
|
||||
|
||||
### Request-path rule
|
||||
|
||||
Every request/action should do only one of these:
|
||||
|
||||
1. return actor-owned cached state
|
||||
2. persist a cheap mutation
|
||||
3. enqueue or signal background work
|
||||
|
||||
Requests should not block on provider calls, repo sync, sandbox provisioning, transcript enumeration, or deep cross-actor fan-out unless the UI cannot render at all without the result.
|
||||
|
||||
### View-model rule
|
||||
|
||||
- App shell view connects to app/session state and only the org actors visible on screen.
|
||||
- Workspace/task-list view connects to a workspace-owned summary projection.
|
||||
- Task detail view connects directly to the selected task actor.
|
||||
- Sandbox/session detail connects only when the user opens that detail.
|
||||
|
||||
Do not replace one monolith with one connection per row. List screens should still come from actor-owned projections.
|
||||
|
||||
### Runtime rule
|
||||
|
||||
Stopping actors must stop accepting new work and must not clear actor resources until active actions and requests have drained or been cancelled.
|
||||
|
||||
## Workstreams
|
||||
|
||||
### 1. Runtime hardening first
|
||||
|
||||
This is the only workstream that is not Foundry-only. It should start immediately because it is the only direct fix for the `c.db` shutdown race.
|
||||
|
||||
#### Changes
|
||||
|
||||
1. Add active action/request accounting in RivetKit actor instances.
|
||||
2. Mark actors as draining before cleanup starts.
|
||||
3. Reject or reroute new requests/actions once draining begins.
|
||||
4. Wait for active actions to finish or abort before `#cleanupDatabase()` runs.
|
||||
5. Delay clearing `#db` until no active actions remain.
|
||||
6. Add actor stop logs with:
|
||||
- actor id
|
||||
- active action count
|
||||
- active request count
|
||||
- drain start/end timestamps
|
||||
- cleanup start/end timestamps
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
- No action can successfully enter user code after actor draining begins.
|
||||
- `Database not enabled` cannot be produced by an in-flight action after stop has begun.
|
||||
- Stop logs make it obvious whether shutdown delay is run-handler time, active-action drain time, background promise time, or routing delay.
|
||||
|
||||
### 2. App shell moves from snapshot polling to subscriptions
|
||||
|
||||
The app shell should stop using `/app/snapshot` as the steady-state read model.
|
||||
|
||||
#### Changes
|
||||
|
||||
1. Introduce a small app-shell projection owned by the app workspace actor:
|
||||
- auth status
|
||||
- current user summary
|
||||
- active org id
|
||||
- visible org ids
|
||||
- per-org lightweight status summary
|
||||
2. Add app actor events, for example:
|
||||
- `appSessionUpdated`
|
||||
- `activeOrganizationChanged`
|
||||
- `organizationSyncStatusChanged`
|
||||
3. Expose connection helpers from the backend client for:
|
||||
- app actor subscription
|
||||
- organization actor subscription by id
|
||||
4. Update `RemoteFoundryAppStore` so it:
|
||||
- does one bootstrap fetch on first subscribe
|
||||
- connects to the app actor for ongoing updates
|
||||
- connects only to the org actors needed for the current view
|
||||
- disposes org subscriptions when they are no longer visible
|
||||
5. Remove `scheduleSyncPollingIfNeeded()` and the `500ms` refresh loop.
|
||||
|
||||
#### Likely files
|
||||
|
||||
- `foundry/packages/backend/src/actors/workspace/app-shell.ts`
|
||||
- `foundry/packages/client/src/backend-client.ts`
|
||||
- `foundry/packages/client/src/remote/app-client.ts`
|
||||
- `foundry/packages/shared/src/app-shell.ts`
|
||||
- app shell frontend consumers
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
- No app shell polling loop remains.
|
||||
- Selecting an org returns quickly and the UI updates from actor events.
|
||||
- App shell refresh cost is bounded by visible state, not every eligible organization on every poll.
|
||||
|
||||
### 3. Workspace summary becomes a projection, not a full snapshot
|
||||
|
||||
The task list should read a workspace-owned summary projection instead of calling into every task actor on each refresh.
|
||||
|
||||
#### Changes
|
||||
|
||||
1. Define a durable workspace summary model with only list-screen fields:
|
||||
- repo summary
|
||||
- project summary
|
||||
- task summary
|
||||
- selected/open task ids
|
||||
- unread/session status summary
|
||||
- coarse git/PR state summary
|
||||
2. Update workspace actor workflows so task/project changes incrementally update this projection.
|
||||
3. Change `getWorkbench` to return the projection only.
|
||||
4. Change `workbenchUpdated` from "invalidate and refetch everything" to "here is the updated projection version or changed entity ids".
|
||||
5. Remove task-actor fan-out from the default list read path.
|
||||
|
||||
#### Likely files
|
||||
|
||||
- `foundry/packages/backend/src/actors/workspace/actions.ts`
|
||||
- `foundry/packages/backend/src/actors/project/actions.ts`
|
||||
- `foundry/packages/backend/src/actors/task/index.ts`
|
||||
- `foundry/packages/backend/src/actors/task/workbench.ts`
|
||||
- task/workspace DB schema and migrations
|
||||
- `foundry/packages/client/src/remote/workbench-client.ts`
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
- Workbench list refresh does not call every task actor.
|
||||
- A websocket event does not force a full cross-actor rebuild.
|
||||
- Initial task-list load time scales roughly with workspace summary size, not repo count times task count times detail reads.
|
||||
|
||||
### 4. Task detail moves to direct actor reads and events
|
||||
|
||||
Heavy task detail should move out of the workspace summary and into the selected task actor.
|
||||
|
||||
#### Changes
|
||||
|
||||
1. Split task detail into focused reads/subscriptions:
|
||||
- task header/meta
|
||||
- tabs/session summary
|
||||
- transcript stream
|
||||
- diff/file tree
|
||||
- sandbox process state
|
||||
2. Open a task actor connection only for the selected task.
|
||||
3. Open sandbox/session subscriptions only for the active tab/pane.
|
||||
4. Dispose those subscriptions when the user changes selection.
|
||||
5. Keep expensive derived state cached in actor-owned tables and update it from background jobs or event ingestion.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
- Opening the task list does not open connections to every task actor.
|
||||
- Opening a task shows staged loading for heavy panes instead of blocking the whole workbench snapshot.
|
||||
- Transcript, diff, and file-tree reads are not recomputed for unrelated tasks.
|
||||
|
||||
### 5. Finish moving long-running mutations to background workflows
|
||||
|
||||
This extends and completes the existing async-action briefs in this folder.
|
||||
|
||||
#### Existing briefs to implement under this workstream
|
||||
|
||||
1. `01-task-creation-bootstrap-only.md`
|
||||
2. `02-repo-overview-from-cached-projection.md`
|
||||
3. `03-repo-actions-via-background-workflow.md`
|
||||
4. `04-workbench-session-creation-without-inline-provisioning.md`
|
||||
5. `05-workbench-snapshot-from-derived-state.md`
|
||||
6. `06-daytona-provisioning-staged-background-flow.md`
|
||||
|
||||
#### Additional rule
|
||||
|
||||
Every workflow-backed mutation should leave behind durable status rows or events that realtime clients can observe without polling.
|
||||
|
||||
### 6. Subscription lifecycle and reconnect behavior need one shared model
|
||||
|
||||
The current client-side connection pattern is too ad hoc. It needs a single lifecycle policy so sockets are long-lived and bounded.
|
||||
|
||||
#### Changes
|
||||
|
||||
1. Create one shared subscription manager in the client for:
|
||||
- reference counting
|
||||
- connection reuse
|
||||
- reconnect backoff
|
||||
- connection state events
|
||||
- clean disposal
|
||||
2. Make invalidation optional. Prefer payload-bearing events or projection version updates.
|
||||
3. Add structured logs/metrics in the client for:
|
||||
- connection created/disposed
|
||||
- reconnect attempts
|
||||
- subscription count per actor key
|
||||
- refresh triggered by event vs bootstrap vs mutation
|
||||
4. Stop calling full `refresh()` after every mutation when the mutation result or follow-up event already contains enough state to update locally.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
- Idle screens maintain stable websocket counts.
|
||||
- Transient socket failures do not create refresh storms.
|
||||
- The client can explain why any given refresh happened.
|
||||
|
||||
### 7. Clean up HTTP surface after realtime migration
|
||||
|
||||
Do not delete bootstrap endpoints first. Shrink them after the subscription model is working.
|
||||
|
||||
#### Changes
|
||||
|
||||
1. Keep one-shot bootstrap/read endpoints only where they still add value:
|
||||
- initial app load
|
||||
- initial workbench load
|
||||
- deep-link fallback
|
||||
2. Remove or de-emphasize monolithic snapshot endpoints for steady-state use.
|
||||
3. Keep HTTP for control-plane and external integrations.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
- Main interactive screens do not depend on polling.
|
||||
- Snapshot endpoints are bootstrap/fallback paths, not the primary UI contract.
|
||||
|
||||
## Suggested Implementation Order
|
||||
|
||||
1. Runtime hardening in RivetKit
|
||||
2. `01-task-creation-bootstrap-only.md`
|
||||
3. `03-repo-actions-via-background-workflow.md`
|
||||
4. `06-daytona-provisioning-staged-background-flow.md`
|
||||
5. App shell realtime subscription model
|
||||
6. `02-repo-overview-from-cached-projection.md`
|
||||
7. Workspace summary projection
|
||||
8. `04-workbench-session-creation-without-inline-provisioning.md`
|
||||
9. `05-workbench-snapshot-from-derived-state.md`
|
||||
10. Task-detail direct actor reads/subscriptions
|
||||
11. Client subscription lifecycle cleanup
|
||||
12. `07-auth-identity-simplification.md`
|
||||
|
||||
## Why This Order
|
||||
|
||||
- Runtime hardening removes the most dangerous correctness bug before more UI load shifts onto actor connections.
|
||||
- The first async workflow items reduce the biggest user-visible stalls quickly.
|
||||
- App shell realtime is smaller and lower-risk than the workbench migration, and it removes the current polling loop.
|
||||
- Workspace summary and task-detail split should happen after the async workflow moves so the projection model does not encode old synchronous assumptions.
|
||||
- Auth simplification is valuable but not required to remove the current refresh/polling/runtime problems.
|
||||
|
||||
## Observability Requirements
|
||||
|
||||
Before or alongside implementation, add metrics/logs for:
|
||||
|
||||
- app snapshot bootstrap duration
|
||||
- workbench bootstrap duration
|
||||
- actor connection count by actor type and view
|
||||
- reconnect count by actor key
|
||||
- projection rebuild/update duration
|
||||
- workflow queue latency
|
||||
- actor drain duration and active-action counts during stop
|
||||
|
||||
Each log line should include a request id or actor/event correlation id where possible.
|
||||
|
||||
## Rollout Strategy
|
||||
|
||||
1. Ship runtime hardening and observability first.
|
||||
2. Ship app-shell realtime behind a client flag while keeping snapshot bootstrap.
|
||||
3. Ship workspace summary projection behind a separate flag.
|
||||
4. Migrate one heavy detail pane at a time off the monolithic workbench payload.
|
||||
5. Remove polling once the matching event path is proven stable.
|
||||
6. Only then remove or demote the old snapshot-heavy steady-state flows.
|
||||
|
||||
## Done Means
|
||||
|
||||
This initiative is done when all of the following are true:
|
||||
|
||||
- no user-visible screen depends on `500ms` polling
|
||||
- no list view recomputes deep task/session/diff state inline on every refresh
|
||||
- long-running repo/provider/sandbox work always runs in durable background workflows
|
||||
- the client connects only to actors relevant to the current view and disposes them when the view changes
|
||||
- websocket counts stay stable on idle screens
|
||||
- actor shutdown cannot invalidate `c.db` underneath active actions
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
# Task Creation Should Return After Actor Bootstrap
|
||||
|
||||
Read `00-end-to-end-async-realtime-plan.md` first for the governing migration order, runtime constraints, and realtime client model this brief assumes.
|
||||
|
||||
## Problem
|
||||
|
||||
Task creation currently waits for full provisioning: naming, repo checks, sandbox creation/resume, sandbox-agent install/start, sandbox-instance wiring, and session creation.
|
||||
|
||||
That makes a user-facing action depend on queue-backed and provider-backed work that can take minutes. The client only needs the task actor to exist so it can navigate to the task and observe progress.
|
||||
|
||||
## Current Code Context
|
||||
|
||||
- Workspace entry point: `foundry/packages/backend/src/actors/workspace/actions.ts`
|
||||
- Project task creation path: `foundry/packages/backend/src/actors/project/actions.ts`
|
||||
- Task action surface: `foundry/packages/backend/src/actors/task/index.ts`
|
||||
- Task workflow: `foundry/packages/backend/src/actors/task/workflow/index.ts`
|
||||
- Task init/provision steps: `foundry/packages/backend/src/actors/task/workflow/init.ts`
|
||||
- Provider-backed long steps currently happen inside the task provision workflow.
|
||||
|
||||
## Target Contract
|
||||
|
||||
- `createTask` returns once the task actor exists and initial task metadata is persisted.
|
||||
- The response includes the task identity the client needs for follow-up reads and subscriptions.
|
||||
- Provisioning continues in the background through the task workflow.
|
||||
- Progress and failure are surfaced through task state, history events, and workbench updates.
|
||||
|
||||
## Proposed Fix
|
||||
|
||||
1. Restore the async split between `initialize` and `provision`.
|
||||
2. Keep `task.command.initialize` responsible for:
|
||||
- creating the task actor
|
||||
- bootstrapping DB rows
|
||||
- persisting any immediately-known metadata
|
||||
- returning the current task record
|
||||
3. After initialize completes, enqueue `task.command.provision` with `wait: false`.
|
||||
4. Change `workspace.createTask` to:
|
||||
- create or resolve the project
|
||||
- create the task actor
|
||||
- call `task.initialize(...)`
|
||||
- stop awaiting `task.provision(...)`
|
||||
- broadcast a workbench/task update
|
||||
- return the task record immediately
|
||||
5. Persist a clear queued/running state for provisioning so the frontend can distinguish:
|
||||
- `init_enqueue_provision`
|
||||
- `init_ensure_name`
|
||||
- `init_create_sandbox`
|
||||
- `init_ensure_agent`
|
||||
- `init_create_session`
|
||||
- `running`
|
||||
- `error`
|
||||
|
||||
## Files Likely To Change
|
||||
|
||||
- `foundry/packages/backend/src/actors/workspace/actions.ts`
|
||||
- `foundry/packages/backend/src/actors/project/actions.ts`
|
||||
- `foundry/packages/backend/src/actors/task/index.ts`
|
||||
- `foundry/packages/backend/src/actors/task/workflow/index.ts`
|
||||
- `foundry/packages/backend/src/actors/task/workflow/init.ts`
|
||||
- `foundry/packages/frontend/src/components/workspace-dashboard.tsx`
|
||||
- `foundry/packages/client/src/remote/workbench-client.ts`
|
||||
|
||||
## Client Impact
|
||||
|
||||
- Task creation UI should navigate immediately to the task page.
|
||||
- The page should render a provisioning state from task status instead of treating create as an all-or-nothing spinner.
|
||||
- Any tab/session creation that depends on provisioning should observe task state and wait for readiness asynchronously.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- Creating a task never waits on sandbox creation or session creation.
|
||||
- A timeout in provider setup does not make the original create request fail after several minutes.
|
||||
- After a backend restart, the task workflow can resume provisioning from durable state without requiring the client to retry create.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Preserve the existing task actor as the single writer for task runtime state.
|
||||
- Do not introduce a second creator path for task actors; keep one create/bootstrap path and one background provision path.
|
||||
- Fresh-agent check: verify that `createWorkbenchTask` and any dashboard create flow still have enough data to navigate immediately after this change.
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
# Repo Overview Should Read Cached State Only
|
||||
|
||||
Read `00-end-to-end-async-realtime-plan.md` first for the governing migration order, runtime constraints, and realtime client model this brief assumes.
|
||||
|
||||
## Problem
|
||||
|
||||
Repo overview currently forces PR sync and branch sync inline before returning data. That turns a read path into:
|
||||
|
||||
- repo fetch
|
||||
- branch enumeration
|
||||
- diff/conflict calculations
|
||||
- GitHub PR listing
|
||||
|
||||
The frontend polls repo overview repeatedly, so this design multiplies slow work and ties normal browsing to sync latency.
|
||||
|
||||
## Current Code Context
|
||||
|
||||
- Workspace overview entry point: `foundry/packages/backend/src/actors/workspace/actions.ts`
|
||||
- Project overview implementation: `foundry/packages/backend/src/actors/project/actions.ts`
|
||||
- Branch sync poller: `foundry/packages/backend/src/actors/project-branch-sync/index.ts`
|
||||
- PR sync poller: `foundry/packages/backend/src/actors/project-pr-sync/index.ts`
|
||||
- Repo overview client polling: `foundry/packages/frontend/src/components/workspace-dashboard.tsx`
|
||||
|
||||
## Target Contract
|
||||
|
||||
- `getRepoOverview` returns the latest cached repo projection immediately.
|
||||
- Sync happens on a background cadence or on an explicit async refresh trigger.
|
||||
- Overview responses include freshness metadata so the client can show "refreshing" or "stale" state without blocking.
|
||||
|
||||
## Proposed Fix
|
||||
|
||||
1. Remove inline `forceProjectSync()` from `getRepoOverview`.
|
||||
2. Add freshness fields to the project projection, for example:
|
||||
- `branchSyncAt`
|
||||
- `prSyncAt`
|
||||
- `branchSyncStatus`
|
||||
- `prSyncStatus`
|
||||
3. Let the existing polling actors own cache refresh.
|
||||
4. If the client needs a manual refresh, add a non-blocking command such as `project.requestOverviewRefresh` that:
|
||||
- enqueues refresh work
|
||||
- updates sync status to `queued` or `running`
|
||||
- returns immediately
|
||||
5. Keep `getRepoOverview` as a pure read over project SQLite state.
|
||||
|
||||
## Files Likely To Change
|
||||
|
||||
- `foundry/packages/backend/src/actors/workspace/actions.ts`
|
||||
- `foundry/packages/backend/src/actors/project/actions.ts`
|
||||
- `foundry/packages/backend/src/actors/project/db/schema.ts`
|
||||
- `foundry/packages/backend/src/actors/project/db/migrations.ts`
|
||||
- `foundry/packages/backend/src/actors/project-branch-sync/index.ts`
|
||||
- `foundry/packages/backend/src/actors/project-pr-sync/index.ts`
|
||||
- `foundry/packages/frontend/src/components/workspace-dashboard.tsx`
|
||||
|
||||
## Client Impact
|
||||
|
||||
- The repo overview screen should render cached rows immediately.
|
||||
- If the user requests a refresh, the UI should show a background sync indicator instead of waiting for the GET call to complete.
|
||||
- Polling frequency can be reduced because reads are now cheap and sync is event-driven.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- `getRepoOverview` does not call `force()` on polling actors.
|
||||
- Opening the repo overview page does not trigger network/git work inline.
|
||||
- Slow branch sync or PR sync no longer blocks the page request.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Favor adding explicit freshness metadata over implicit timing assumptions in the frontend.
|
||||
- The overview query should remain safe to call frequently even if the UI still polls during the transition.
|
||||
- Fresh-agent check: confirm no other read paths call `forceProjectSync()` inline after this change.
|
||||
|
|
@ -0,0 +1,74 @@
|
|||
# Repo Sync And Stack Actions Should Run In Background Workflows
|
||||
|
||||
Read `00-end-to-end-async-realtime-plan.md` first for the governing migration order, runtime constraints, and realtime client model this brief assumes.
|
||||
|
||||
## Problem
|
||||
|
||||
Repo stack actions currently run inside a synchronous action and surround the action with forced sync before and after. Branch-backed task creation also forces repo sync inline before it can proceed.
|
||||
|
||||
These flows depend on repo/network state and can take minutes. They should not hold an action open.
|
||||
|
||||
## Current Code Context
|
||||
|
||||
- Workspace repo action entry point: `foundry/packages/backend/src/actors/workspace/actions.ts`
|
||||
- Project repo action implementation: `foundry/packages/backend/src/actors/project/actions.ts`
|
||||
- Branch/task index state lives in the project actor SQLite DB.
|
||||
- Current forced sync uses the PR and branch polling actors before and after the action.
|
||||
|
||||
## Target Contract
|
||||
|
||||
- Repo-affecting actions are accepted quickly and run in the background.
|
||||
- The project actor owns a durable action record with progress and final result.
|
||||
- Clients observe status via project/task state instead of waiting for a single response.
|
||||
|
||||
## Proposed Fix
|
||||
|
||||
1. Introduce a project-level workflow/job model for repo actions, for example:
|
||||
- `sync_repo`
|
||||
- `restack_repo`
|
||||
- `restack_subtree`
|
||||
- `rebase_branch`
|
||||
- `reparent_branch`
|
||||
- `register_existing_branch`
|
||||
2. Persist a job row with:
|
||||
- job id
|
||||
- action kind
|
||||
- target branch fields
|
||||
- status
|
||||
- message
|
||||
- timestamps
|
||||
3. Change `runRepoStackAction` to:
|
||||
- validate cheap local inputs only
|
||||
- create a job row
|
||||
- enqueue the workflow with `wait: false`
|
||||
- return the job id and accepted status immediately
|
||||
4. Move pre/post sync into the background workflow.
|
||||
5. For branch-backed task creation:
|
||||
- use the cached branch projection if present
|
||||
- if branch data is stale or missing, enqueue branch registration/refresh work and surface pending state instead of blocking create
|
||||
|
||||
## Files Likely To Change
|
||||
|
||||
- `foundry/packages/backend/src/actors/workspace/actions.ts`
|
||||
- `foundry/packages/backend/src/actors/project/actions.ts`
|
||||
- `foundry/packages/backend/src/actors/project/db/schema.ts`
|
||||
- `foundry/packages/backend/src/actors/project/db/migrations.ts`
|
||||
- `foundry/packages/frontend/src/components/workspace-dashboard.tsx`
|
||||
- Any shared types in `foundry/packages/shared/src`
|
||||
|
||||
## Client Impact
|
||||
|
||||
- Repo action buttons should show queued/running/completed/error job state.
|
||||
- Task creation from an existing branch may produce a task in a pending branch-attach state rather than blocking on repo sync.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- No repo stack action waits for full git-spice execution inside the request.
|
||||
- No action forces branch sync or PR sync inline.
|
||||
- Action result state survives retries and backend restarts because the workflow status is persisted.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Keep validation cheap in the request path; expensive repo inspection belongs in the workflow.
|
||||
- If job rows are added, decide whether they are project-owned only or also mirrored into history events for UI consumption.
|
||||
- Fresh-agent check: branch-backed task creation and explicit repo stack actions should use the same background job/status vocabulary where possible.
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
# Workbench Session Creation Must Not Trigger Inline Provisioning
|
||||
|
||||
Read `00-end-to-end-async-realtime-plan.md` first for the governing migration order, runtime constraints, and realtime client model this brief assumes.
|
||||
|
||||
## Problem
|
||||
|
||||
Creating a workbench tab currently provisions the whole task if no active sandbox exists. A user action that looks like "open tab" can therefore block on sandbox creation and agent setup.
|
||||
|
||||
## Current Code Context
|
||||
|
||||
- Workspace workbench action entry point: `foundry/packages/backend/src/actors/workspace/actions.ts`
|
||||
- Task workbench behavior: `foundry/packages/backend/src/actors/task/workbench.ts`
|
||||
- Task provision action: `foundry/packages/backend/src/actors/task/index.ts`
|
||||
- Sandbox session creation path: `foundry/packages/backend/src/actors/sandbox-instance/index.ts`
|
||||
- Remote workbench refresh behavior: `foundry/packages/client/src/remote/workbench-client.ts`
|
||||
|
||||
## Target Contract
|
||||
|
||||
- Creating a tab returns quickly.
|
||||
- If the task is not provisioned yet, the tab enters a pending state and becomes usable once provisioning completes.
|
||||
- Provisioning remains a task workflow concern, not a workbench request concern.
|
||||
|
||||
## Proposed Fix
|
||||
|
||||
1. Split tab creation from sandbox session creation.
|
||||
2. On `createWorkbenchSession`:
|
||||
- create session metadata or a placeholder tab row immediately
|
||||
- if the task is not provisioned, enqueue the required background work and return the placeholder id
|
||||
- if the task is provisioned, enqueue background session creation if that step can also be slow
|
||||
3. Add a tab/session state model such as:
|
||||
- `pending_provision`
|
||||
- `pending_session_create`
|
||||
- `ready`
|
||||
- `error`
|
||||
4. When provisioning or session creation finishes, update the placeholder row with the real sandbox/session identifiers and notify the workbench.
|
||||
|
||||
## Files Likely To Change
|
||||
|
||||
- `foundry/packages/backend/src/actors/workspace/actions.ts`
|
||||
- `foundry/packages/backend/src/actors/task/workbench.ts`
|
||||
- `foundry/packages/backend/src/actors/task/index.ts`
|
||||
- `foundry/packages/backend/src/actors/task/db/schema.ts`
|
||||
- `foundry/packages/backend/src/actors/task/db/migrations.ts`
|
||||
- `foundry/packages/client/src/remote/workbench-client.ts`
|
||||
- `foundry/packages/frontend/src/components/mock-layout.tsx`
|
||||
|
||||
## Client Impact
|
||||
|
||||
- The workbench can show a disabled composer or "Preparing environment" state for a pending tab.
|
||||
- The UI no longer needs to block on the mutation itself.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- `createWorkbenchSession` never calls task provisioning inline.
|
||||
- Opening a tab on an unprovisioned task returns promptly with a placeholder tab id.
|
||||
- The tab transitions to ready through background updates only.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- The main design choice here is placeholder identity. Decide early whether placeholder tab ids are durable synthetic ids or whether a pending row can be updated in place once a real session exists.
|
||||
- Avoid coupling this design to Daytona specifically; it should work for local and remote providers.
|
||||
- Fresh-agent check: confirm composer, unread state, and tab close behavior all handle pending/error tabs cleanly.
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
# Workbench Snapshots Should Read Derived State, Not Recompute It
|
||||
|
||||
Read `00-end-to-end-async-realtime-plan.md` first for the governing migration order, runtime constraints, and realtime client model this brief assumes.
|
||||
|
||||
## Problem
|
||||
|
||||
Workbench snapshot reads currently execute expensive sandbox commands and transcript reads inline:
|
||||
|
||||
- `git status`
|
||||
- `git diff --numstat`
|
||||
- one diff per changed file
|
||||
- file tree enumeration
|
||||
- transcript reads for each session
|
||||
- session status lookups
|
||||
|
||||
The remote workbench client refreshes after each action and on update events, so this synchronous snapshot work is amplified.
|
||||
|
||||
## Current Code Context
|
||||
|
||||
- Workspace workbench snapshot builder: `foundry/packages/backend/src/actors/workspace/actions.ts`
|
||||
- Task workbench snapshot builder: `foundry/packages/backend/src/actors/task/workbench.ts`
|
||||
- Sandbox session event persistence: `foundry/packages/backend/src/actors/sandbox-instance/persist.ts`
|
||||
- Remote workbench client refresh loop: `foundry/packages/client/src/remote/workbench-client.ts`
|
||||
- Mock layout consumer: `foundry/packages/frontend/src/components/mock-layout.tsx`
|
||||
|
||||
## Target Contract
|
||||
|
||||
- `getWorkbench` reads a cached projection only.
|
||||
- Expensive sandbox- or session-derived data is updated asynchronously and stored in actor-owned tables.
|
||||
- Detail-heavy payloads are fetched separately when the user actually opens that view.
|
||||
|
||||
## Proposed Fix
|
||||
|
||||
1. Split the current monolithic workbench snapshot into:
|
||||
- lightweight task/workbench summary
|
||||
- session transcript endpoint
|
||||
- file diff endpoint
|
||||
- file tree endpoint
|
||||
2. Cache derived git state in SQLite, updated by background jobs or targeted invalidation after mutating actions.
|
||||
3. Cache transcript/session metadata incrementally from sandbox events instead of reading full transcripts on every snapshot.
|
||||
4. Keep `getWorkbench` limited to summary fields needed for the main screen.
|
||||
5. Update the remote workbench client to rely more on push updates and less on immediate full refresh after every mutation.
|
||||
|
||||
## Files Likely To Change
|
||||
|
||||
- `foundry/packages/backend/src/actors/workspace/actions.ts`
|
||||
- `foundry/packages/backend/src/actors/task/workbench.ts`
|
||||
- `foundry/packages/backend/src/actors/task/db/schema.ts`
|
||||
- `foundry/packages/backend/src/actors/task/db/migrations.ts`
|
||||
- `foundry/packages/client/src/remote/workbench-client.ts`
|
||||
- `foundry/packages/shared/src`
|
||||
- `foundry/packages/frontend/src/components/mock-layout.tsx`
|
||||
|
||||
## Client Impact
|
||||
|
||||
- Main workbench loads faster and remains responsive with many tasks/files/sessions.
|
||||
- Heavy panes can show their own loading states when opened.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- `getWorkbench` does not run per-file diff commands inline.
|
||||
- `getWorkbench` does not read full transcripts for every tab inline.
|
||||
- Full workbench refresh cost stays roughly proportional to task count, not task count times changed files times sessions.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- This is the broadest UI-facing refactor in the set.
|
||||
- Prefer introducing lighter cached summary fields first, then moving heavy detail into separate reads.
|
||||
- Fresh-agent check: define the final snapshot contract before changing frontend consumers, otherwise the refactor will sprawl.
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
# Daytona Provisioning Should Be A Staged Background Flow
|
||||
|
||||
Read `00-end-to-end-async-realtime-plan.md` first for the governing migration order, runtime constraints, and realtime client model this brief assumes.
|
||||
|
||||
## Problem
|
||||
|
||||
Daytona provisioning currently performs long-running setup inline:
|
||||
|
||||
- sandbox create/start
|
||||
- package/tool installation
|
||||
- repo clone/fetch/checkout
|
||||
- sandbox-agent install
|
||||
- agent plugin install
|
||||
- sandbox-agent boot
|
||||
- health wait loop
|
||||
|
||||
This is acceptable inside a durable background workflow, but not as part of a user-facing action response.
|
||||
|
||||
## Current Code Context
|
||||
|
||||
- Daytona provider implementation: `foundry/packages/backend/src/providers/daytona/index.ts`
|
||||
- Task provisioning workflow: `foundry/packages/backend/src/actors/task/workflow/index.ts`
|
||||
- Task init activities: `foundry/packages/backend/src/actors/task/workflow/init.ts`
|
||||
- Sandbox-instance actor: `foundry/packages/backend/src/actors/sandbox-instance/index.ts`
|
||||
- Provider registry/runtime context: `foundry/packages/backend/src/providers/index.ts` and `foundry/packages/backend/src/actors/context.ts`
|
||||
|
||||
## Target Contract
|
||||
|
||||
- Requests that need Daytona resources only wait for persisted actor/job creation.
|
||||
- Daytona setup progresses through durable stages with explicit status.
|
||||
- Follow-up work resumes from persisted state after crashes or restarts.
|
||||
|
||||
## Proposed Fix
|
||||
|
||||
1. Introduce a provider-facing staged readiness model, for example:
|
||||
- `sandbox_allocated`
|
||||
- `repo_prepared`
|
||||
- `agent_installing`
|
||||
- `agent_starting`
|
||||
- `agent_ready`
|
||||
- `session_creating`
|
||||
- `ready`
|
||||
- `error`
|
||||
2. Persist stage transitions in task or sandbox-instance state.
|
||||
3. Keep provider calls inside background workflow steps only.
|
||||
4. Replace synchronous health-wait loops in request paths with:
|
||||
- background step execution
|
||||
- status updates after each step
|
||||
- follow-up workflow progression once the prior stage completes
|
||||
5. If sandbox-agent session creation is also slow, treat that as its own stage instead of folding it into request completion.
|
||||
|
||||
## Files Likely To Change
|
||||
|
||||
- `foundry/packages/backend/src/providers/daytona/index.ts`
|
||||
- `foundry/packages/backend/src/actors/task/workflow/index.ts`
|
||||
- `foundry/packages/backend/src/actors/task/workflow/init.ts`
|
||||
- `foundry/packages/backend/src/actors/task/db/schema.ts`
|
||||
- `foundry/packages/backend/src/actors/task/db/migrations.ts`
|
||||
- `foundry/packages/backend/src/actors/sandbox-instance/index.ts`
|
||||
- Potentially shared provider types in `foundry/packages/backend/src/providers/provider-api/index.ts`
|
||||
|
||||
## Client Impact
|
||||
|
||||
- Users see staged progress instead of a long spinner.
|
||||
- Failures point to a concrete stage, which makes retries and debugging much easier.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- No user-facing request waits for Daytona package installs, repo clone, sandbox-agent installation, or health polling.
|
||||
- Progress survives backend restarts because the stage is persisted.
|
||||
- The system can resume from the last completed stage instead of replaying the whole provisioning path blindly.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- If this is implemented after item 1, much of the user-facing pain disappears immediately; this item then becomes about reliability and clearer progress reporting.
|
||||
- Keep the stage model provider-agnostic where possible so local and future providers can share the same task runtime semantics.
|
||||
- Fresh-agent check: decide whether stage ownership lives on the task actor, sandbox-instance actor, or both before changing schema.
|
||||
|
|
@ -0,0 +1,328 @@
|
|||
# Auth & Identity Simplification: Adopt BetterAuth + Extract User Model
|
||||
|
||||
Read `00-end-to-end-async-realtime-plan.md` first for the governing migration order, runtime constraints, and realtime client model this brief assumes.
|
||||
|
||||
## Problem
|
||||
|
||||
Authentication and user identity are conflated into a single `appSessions` table that serves as the session store, user record, OAuth credential store, navigation state, and onboarding tracker simultaneously. There is no canonical user record — identity fields are denormalized into every session row. BetterAuth env vars exist (`BETTER_AUTH_URL`, `BETTER_AUTH_SECRET`) but the library is not used; all OAuth and session handling is hand-rolled.
|
||||
|
||||
### Specific issues
|
||||
|
||||
1. **No user table.** Same GitHub user in two browsers = two independent copies of identity fields with no shared record. Org membership, onboarding state, and role are per-session instead of per-user.
|
||||
2. **Unsigned session tokens.** Session IDs are plain UUIDs in `localStorage`, sent via `x-foundry-session` header. The backend trusts them at face value — no signature verification.
|
||||
3. **Unstable user IDs.** User ID is `user-${slugify(viewer.login)}` which breaks on GitHub username renames. GitHub numeric `id` is available from the API but not used as the stable key.
|
||||
4. **Dead BetterAuth references.** `BETTER_AUTH_URL` is used as a URL alias in `app-shell-runtime.ts:65`. `BETTER_AUTH_SECRET` is documented but never read. This creates confusion about what auth system is actually in use.
|
||||
5. **Overloaded session row.** `appSessions` has 15+ columns mixing auth credentials, user identity, org navigation, onboarding state, and transient OAuth flow state.
|
||||
|
||||
## Current Code Context
|
||||
|
||||
- Custom OAuth flow: `foundry/packages/backend/src/services/app-github.ts` (`buildAuthorizeUrl`, `exchangeCode`, `getViewer`)
|
||||
- Session + identity management: `foundry/packages/backend/src/actors/workspace/app-shell.ts` (`ensureAppSession`, `updateAppSession`, `initGithubSession`, `syncGithubOrganizations`)
|
||||
- Session schema: `foundry/packages/backend/src/actors/workspace/db/schema.ts` (`appSessions` table)
|
||||
- Shared types: `foundry/packages/shared/src/app-shell.ts` (`FoundryUser`, `FoundryAppSnapshot`)
|
||||
- HTTP routes: `foundry/packages/backend/src/index.ts` (`resolveSessionId`, `/v1/auth/github/*`, all `/v1/app/*` routes)
|
||||
- Frontend session persistence: `foundry/packages/client/src/backend-client.ts` (`persistAppSessionId`, `x-foundry-session` header, `foundrySession` URL param extraction)
|
||||
- Runtime config: `foundry/packages/backend/src/services/app-shell-runtime.ts` (`BETTER_AUTH_URL` fallback)
|
||||
- Compose config: `foundry/compose.dev.yaml` (`BETTER_AUTH_URL`, `BETTER_AUTH_SECRET` env vars)
|
||||
- Self-hosting docs: `docs/deploy/foundry-self-hosting.mdx` (documents both env vars)
|
||||
|
||||
## Target State
|
||||
|
||||
### BetterAuth owns auth plumbing
|
||||
|
||||
- BetterAuth handles GitHub OAuth (authorize URL, code exchange, CSRF state, token storage).
|
||||
- BetterAuth manages session lifecycle (signed tokens, expiration, revocation).
|
||||
- BetterAuth creates and maintains `user`, `session`, and `account` tables with proper FKs.
|
||||
- `BETTER_AUTH_SECRET` is actually used for session signing.
|
||||
- `BETTER_AUTH_URL` is actually used as the auth callback base URL.
|
||||
|
||||
### Custom actor-routed adapter
|
||||
|
||||
- BetterAuth uses a custom adapter that routes all DB operations through RivetKit actors.
|
||||
- Each user has their own actor. BetterAuth's `user`, `session`, and `account` tables live in the per-user actor's SQLite via `c.db`.
|
||||
- The adapter resolves which actor to target based on the primary key BetterAuth passes for each operation (user ID, session ID, account ID).
|
||||
- A lightweight **session index** on the app-shell workspace actor maps session tokens → user actor identity, so inbound requests can be routed to the correct user actor without knowing the user ID upfront.
|
||||
|
||||
### Canonical user record
|
||||
|
||||
- Users are identified by GitHub numeric account ID (immutable across renames).
|
||||
- BetterAuth's `user` table in the per-user actor is the single source of truth for identity.
|
||||
- App-specific user fields (`eligibleOrganizationIds`, `starterRepoStatus`, `roleLabel`) live in a `userProfiles` table in the same per-user actor, keyed by user ID, not duplicated per session.
|
||||
|
||||
### Thin sessions
|
||||
|
||||
- Sessions reference a user ID (FK) instead of duplicating identity fields.
|
||||
- App-specific session state (`activeOrganizationId`) lives in a `sessionState` table in the per-user actor or as BetterAuth session additional fields.
|
||||
- Transient OAuth flow state (`oauthState`, `oauthStateExpiresAt`) is handled by BetterAuth internally.
|
||||
|
||||
### Snapshot projection unchanged
|
||||
|
||||
- `FoundryAppSnapshot` and `FoundryUser` types remain the same — they're already the right shape.
|
||||
- The snapshot builder reads from the user actor's BetterAuth tables + `userProfiles` instead of reading everything from `appSessions`.
|
||||
|
||||
## Architecture: Custom Actor-Routed BetterAuth Adapter
|
||||
|
||||
### Why a custom adapter
|
||||
|
||||
BetterAuth expects a single database. Foundry uses per-actor SQLite — each actor instance gets its own `c.db`. Users each have their own actor, so BetterAuth's `user`, `session`, and `account` records must live inside the correct user actor's database. The adapter must route each BetterAuth DB operation to the right actor based on the primary key.
|
||||
|
||||
### Routing challenge: session → user actor
|
||||
|
||||
When an HTTP request arrives, the backend has a session token but doesn't know the user ID yet. BetterAuth calls adapter methods like `findSession(sessionId)` to resolve this. But which actor holds that session row?
|
||||
|
||||
**Solution: session index on the app-shell workspace actor.**
|
||||
|
||||
The app-shell workspace actor (which already handles auth routing) maintains a lightweight index table:
|
||||
|
||||
```
|
||||
sessionIndex
|
||||
├── sessionId (text, PK)
|
||||
├── userActorKey (text) — actor key for the user actor that owns this session
|
||||
├── createdAt (integer)
|
||||
```
|
||||
|
||||
The adapter flow for session lookup:
|
||||
1. BetterAuth calls `findSession(sessionId)`.
|
||||
2. Adapter queries `sessionIndex` on the workspace actor to resolve `userActorKey`.
|
||||
3. Adapter gets the user actor handle and queries BetterAuth's `session` table in that actor's `c.db`.
|
||||
|
||||
The adapter flow for user creation (OAuth callback):
|
||||
1. BetterAuth calls `createUser(userData)`.
|
||||
2. Adapter resolves the GitHub numeric ID from the user data.
|
||||
3. Adapter creates/gets the user actor keyed by GitHub ID.
|
||||
4. Adapter inserts into BetterAuth's `user` table in that actor's `c.db`.
|
||||
5. When `createSession` follows, adapter writes to the user actor's `session` table AND inserts into the workspace actor's `sessionIndex`.
|
||||
|
||||
### User actor shape
|
||||
|
||||
```text
|
||||
UserActor (key: ["ws", workspaceId, "user", githubNumericId])
|
||||
├── BetterAuth tables: user, session, account (managed by BetterAuth schema)
|
||||
├── userProfiles (app-specific: eligibleOrganizationIds, starterRepoStatus, roleLabel)
|
||||
└── sessionState (app-specific: activeOrganizationId per session)
|
||||
```
|
||||
|
||||
### BetterAuth adapter interface (concrete)
|
||||
|
||||
BetterAuth uses `createAdapterFactory` from `"better-auth/adapters"`. The adapter is **model-based, not entity-based** — it receives a `model` string (`"user"`, `"session"`, `"account"`, `"verification"`) and generic CRUD parameters. All methods are **async** and return Promises. The adapter can do arbitrary async work including actor handle resolution and cross-actor messages.
|
||||
|
||||
```typescript
|
||||
// Adapter methods (all async, all receive model name + generic params):
|
||||
create: ({ model, data, select? }) => Promise<T>
|
||||
findOne: ({ model, where, select?, join? }) => Promise<T | null>
|
||||
findMany: ({ model, where, limit?, offset?, sortBy?, join? }) => Promise<T[]>
|
||||
update: ({ model, where, update }) => Promise<T | null>
|
||||
updateMany: ({ model, where, update }) => Promise<number>
|
||||
delete: ({ model, where }) => Promise<void>
|
||||
deleteMany: ({ model, where }) => Promise<number>
|
||||
count: ({ model, where }) => Promise<number>
|
||||
```
|
||||
|
||||
The `where` clauses use `{ field, value, operator?, connector? }` objects (operators: `eq`, `ne`, `in`, `contains`, etc.).
|
||||
|
||||
#### Routing logic inside the adapter
|
||||
|
||||
The adapter must inspect `model` and `where` to determine the target actor:
|
||||
|
||||
| Model | Routing strategy |
|
||||
|-------|-----------------|
|
||||
| `user` (by id) | User actor key derived directly from user ID |
|
||||
| `user` (by email) | `emailIndex` on workspace actor → user actor key |
|
||||
| `session` (by token) | `sessionIndex` on workspace actor → user actor key |
|
||||
| `session` (by id) | `sessionIndex` on workspace actor → user actor key |
|
||||
| `session` (by userId) | User actor key derived directly from userId |
|
||||
| `account` | Always has `userId` in where or data → user actor key |
|
||||
| `verification` | Workspace actor (not user-scoped — used for email verification, password reset) |
|
||||
|
||||
On `create` for `session` model: write to user actor's `session` table AND insert into workspace actor's `sessionIndex`.
|
||||
On `delete` for `session` model: delete from user actor's `session` table AND remove from workspace actor's `sessionIndex`.
|
||||
|
||||
#### Adapter construction
|
||||
|
||||
The adapter is instantiated at BetterAuth init time with a closure over the RivetKit registry. It does **not** depend on an ambient actor context — it resolves actor handles on demand via the registry.
|
||||
|
||||
```typescript
|
||||
import { createAdapterFactory } from "better-auth/adapters";
|
||||
|
||||
const actorRoutedAdapter = (registry: Registry) => {
|
||||
return createAdapterFactory({
|
||||
config: {
|
||||
adapterId: "rivetkit-actor",
|
||||
adapterName: "RivetKit Actor Adapter",
|
||||
supportsJSON: false, // SQLite — auto-serialize JSON
|
||||
supportsDates: false, // SQLite — ISO string conversion
|
||||
supportsBooleans: false, // SQLite — 0/1 conversion
|
||||
},
|
||||
adapter: ({ getModelName, transformInput, transformOutput, transformWhereClause }) => ({
|
||||
create: async ({ model, data }) => {
|
||||
const actorKey = resolveActorKeyForCreate(model, data);
|
||||
const actor = await registry.get("user", actorKey);
|
||||
// delegate insert to actor's c.db
|
||||
// if model === "session", also write sessionIndex
|
||||
},
|
||||
findOne: async ({ model, where }) => {
|
||||
const actorKey = await resolveActorKeyForQuery(model, where);
|
||||
// ...
|
||||
},
|
||||
// ... remaining methods
|
||||
}),
|
||||
});
|
||||
};
|
||||
```
|
||||
|
||||
#### BetterAuth session tokens
|
||||
|
||||
BetterAuth uses **opaque session tokens** stored in the `session` table's `token` column. By default, the token is set as a cookie (`better-auth.session_token`). On every request, BetterAuth looks up the session in the DB by token and checks `expiresAt`.
|
||||
|
||||
**Cookie caching** can be enabled to reduce DB lookups: the session data is signed (HMAC-SHA256) or encrypted (AES-256) and embedded in the cookie. When the cache is fresh (configurable `maxAge`, e.g., 5 minutes), BetterAuth validates the signature locally without hitting the adapter. This **eliminates the hot-path actor lookup for most requests** — the adapter is only called when the cache expires or on write operations.
|
||||
|
||||
```typescript
|
||||
session: {
|
||||
cookieCache: {
|
||||
enabled: true,
|
||||
maxAge: 5 * 60, // 5 minutes — most requests skip the adapter entirely
|
||||
strategy: "compact", // HMAC-signed, minimal size
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
#### BetterAuth core tables
|
||||
|
||||
Four tables, all in the per-user actor's SQLite (except `verification` which goes on workspace actor):
|
||||
|
||||
**`user`**: `id`, `name`, `email`, `emailVerified`, `image`, `createdAt`, `updatedAt`
|
||||
**`session`**: `id`, `token`, `userId`, `expiresAt`, `ipAddress?`, `userAgent?`, `createdAt`, `updatedAt`
|
||||
**`account`**: `id`, `userId`, `accountId` (GitHub numeric ID), `providerId` ("github"), `accessToken?`, `refreshToken?`, `scope?`, `createdAt`, `updatedAt`
|
||||
**`verification`**: `id`, `identifier`, `value`, `expiresAt`, `createdAt`, `updatedAt`
|
||||
|
||||
For `findUserByEmail`, a secondary index (email → user actor key) is needed on the workspace actor alongside `sessionIndex`.
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 0: Spike — custom adapter feasibility
|
||||
|
||||
Research confirms:
|
||||
- BetterAuth adapter methods are **fully async** (`Promise`-based). Arbitrary async work (actor handle resolution, cross-actor messages) is allowed.
|
||||
- The adapter is instantiated at BetterAuth init time and receives no request context — it's a plain object of async functions. This means the adapter can close over a RivetKit registry reference and resolve actor handles on demand.
|
||||
- Cookie caching (`cookieCache.enabled: true`) eliminates the adapter hot-path for most read requests — the session is validated from the signed cookie, and the adapter is only called when the cache expires or on writes.
|
||||
|
||||
**Remaining spike work:**
|
||||
|
||||
1. **Prototype the adapter + user actor end-to-end** — wire up `createAdapterFactory` with a minimal actor-routed implementation. Confirm that BetterAuth's GitHub OAuth flow completes successfully with user/session/account records landing in the correct per-user actor's SQLite.
|
||||
2. **Verify `findOne` for session model** — confirm the `where` clause BetterAuth passes for session lookup includes the `token` field (not just `id`), so the adapter can route via `sessionIndex` keyed by token.
|
||||
3. **Measure cookie-cached vs uncached request latency** — confirm that with cookie caching enabled, the adapter is not called on every request, and that the uncached fallback (workspace actor index → user actor → session table) is acceptable.
|
||||
|
||||
### Phase 1: User actor + adapter infrastructure (no behavior change)
|
||||
|
||||
1. **Install `better-auth` package** in `packages/backend`.
|
||||
2. **Define `UserActor`** with actor key `["ws", workspaceId, "user", githubNumericId]`. Include BetterAuth's required tables (`user`, `session`, `account`) plus app-specific tables in its schema.
|
||||
3. **Create `userProfiles` table** in user actor schema:
|
||||
```
|
||||
userProfiles
|
||||
├── userId (text, PK) — GitHub numeric account ID (string form)
|
||||
├── githubLogin (text)
|
||||
├── roleLabel (text)
|
||||
├── eligibleOrganizationIdsJson (text)
|
||||
├── starterRepoStatus (text)
|
||||
├── starterRepoStarredAt (integer, nullable)
|
||||
├── starterRepoSkippedAt (integer, nullable)
|
||||
├── createdAt (integer)
|
||||
├── updatedAt (integer)
|
||||
```
|
||||
4. **Create `sessionState` table** in user actor schema:
|
||||
```
|
||||
sessionState
|
||||
├── sessionId (text, PK) — references BetterAuth session ID
|
||||
├── activeOrganizationId (text, nullable)
|
||||
├── createdAt (integer)
|
||||
├── updatedAt (integer)
|
||||
```
|
||||
5. **Create `sessionIndex` and `emailIndex` tables** on the app-shell workspace actor:
|
||||
```
|
||||
sessionIndex
|
||||
├── sessionId (text, PK)
|
||||
├── userActorKey (text)
|
||||
├── createdAt (integer)
|
||||
|
||||
emailIndex
|
||||
├── email (text, PK)
|
||||
├── userActorKey (text)
|
||||
├── updatedAt (integer)
|
||||
```
|
||||
6. **Implement the custom BetterAuth adapter** that routes operations through the index tables and user actors.
|
||||
7. **Configure BetterAuth** with GitHub OAuth provider using existing `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET` env vars. Wire `BETTER_AUTH_SECRET` for session signing and `BETTER_AUTH_URL` as the auth base URL.
|
||||
8. **Keep `appSessions` table operational** — no reads/writes change yet.
|
||||
|
||||
### Phase 2: Migrate OAuth flow to BetterAuth
|
||||
|
||||
1. **Replace `startAppGithubAuth`** — delegate to BetterAuth's GitHub OAuth initiation instead of hand-rolling `buildAuthorizeUrl` + `oauthState` + `oauthStateExpiresAt`.
|
||||
2. **Replace `completeAppGithubAuth`** — delegate to BetterAuth's callback handler. BetterAuth creates/updates the user record in the user actor and creates a signed session. The adapter writes to `sessionIndex` on the workspace actor.
|
||||
3. **After BetterAuth callback completes**, populate `userProfiles` in the user actor with app-specific fields and enqueue the slow org sync (same background workflow pattern as today).
|
||||
4. **Replace `signOutApp`** — delegate to BetterAuth session invalidation. Adapter removes entry from `sessionIndex`.
|
||||
5. **Update `resolveSessionId`** in `index.ts` — validate the session via BetterAuth (which routes through the adapter → `sessionIndex` → user actor). BetterAuth verifies the signature and checks expiration.
|
||||
6. **Keep `bootstrapAppGithubSession`** (dev-only) — adapt it to create a BetterAuth session from a raw token for local development.
|
||||
|
||||
### Phase 3: Migrate reads to new tables
|
||||
|
||||
1. **Update `getAppSnapshot`** — read user identity from BetterAuth's user table in the user actor, app-specific fields from `userProfiles`, and active org from `sessionState`.
|
||||
2. **Update `selectOrganization`** — write to `sessionState` in the user actor instead of `appSessions`.
|
||||
3. **Update `syncGithubOrganizations`** — write `eligibleOrganizationIds` to `userProfiles` in the user actor instead of `appSessions`. This fixes the multi-session divergence bug.
|
||||
4. **Update onboarding actions** (`skipAppStarterRepo`, `starAppStarterRepo`) — write to `userProfiles` in the user actor instead of `appSessions`.
|
||||
5. **Update `FoundryUser.id`** — use GitHub numeric ID (from BetterAuth's `account.providerAccountId`) instead of `user-${slugify(login)}`.
|
||||
|
||||
### Phase 4: Frontend migration
|
||||
|
||||
1. **Replace `x-foundry-session` header** with BetterAuth's session mechanism (likely a signed cookie or Authorization header, depending on BetterAuth config).
|
||||
2. **Remove `foundrySession` URL param extraction** from `backend-client.ts` — BetterAuth handles post-OAuth session establishment via cookies.
|
||||
3. **Remove `localStorage` session persistence** — BetterAuth manages this via HTTP-only cookies.
|
||||
4. **Update `signInWithGithub`** — redirect to BetterAuth's auth endpoint instead of `/v1/auth/github/start`.
|
||||
|
||||
### Phase 5: Cleanup
|
||||
|
||||
1. **Drop `appSessions` table** (migration).
|
||||
2. **Remove hand-rolled OAuth functions** from `app-shell.ts`: `ensureAppSession`, `updateAppSession`, `initGithubSession`, `encodeOauthState`, `decodeOauthState`, `requireAppSessionRow`, `requireSignedInSession`.
|
||||
3. **Remove `buildAuthorizeUrl` and `exchangeCode`** from `GitHubAppClient` (keep `getViewer`, installation token methods, webhook verification).
|
||||
4. **Update `foundry-self-hosting.mdx`** — document `BETTER_AUTH_SECRET` as required for session signing (already documented, now actually true).
|
||||
5. **Remove `BETTER_AUTH_URL` fallback** from `app-shell-runtime.ts` — BetterAuth reads it directly.
|
||||
|
||||
## Constraints
|
||||
|
||||
- **Actor-routed adapter.** BetterAuth does not natively support per-user actor databases. The custom adapter must route every DB operation to the correct actor. This adds a layer of indirection and latency (actor handle resolution + message) on adapter calls.
|
||||
- **Session index cost is mitigated by cookie caching.** With `cookieCache` enabled, BetterAuth validates sessions from a signed cookie on most requests — the adapter (and thus the `sessionIndex` lookup + user actor round-trip) is only called when the cache expires or on writes. Without caching, every authenticated request would hit the workspace actor's `sessionIndex` table then the user actor.
|
||||
- **Two-actor write on session create/destroy.** Creating or destroying a session requires writing to both the user actor (BetterAuth's `session` table) and the workspace actor (`sessionIndex`). These must be consistent — if the user actor write succeeds but the index write fails, the session exists but is unreachable.
|
||||
- **Background org sync pattern must be preserved.** The fast-path/slow-path split (`initGithubSession` returns immediately, `syncGithubOrganizations` runs in workflow queue) is critical for avoiding proxy timeout retries. BetterAuth handles the OAuth exchange, but the org sync stays as a background workflow.
|
||||
- **`GitHubAppClient` is still needed.** BetterAuth replaces the OAuth user-auth flow, but installation tokens, webhook verification, repo listing, and org listing are GitHub App operations that BetterAuth does not cover.
|
||||
- **User ID migration.** Changing user IDs from `user-${slugify(login)}` to GitHub numeric IDs affects `organizationMembers`, `seatAssignments`, and any cross-actor references to user IDs. Existing data needs a migration path.
|
||||
- **`findUserByEmail` requires a secondary index.** BetterAuth sometimes looks up users by email (e.g., account linking). An `emailIndex` table on the workspace actor is needed. This must be kept in sync with the user actor's email field.
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
- **Adapter call context — RESOLVED.** Research confirms BetterAuth adapter methods are plain async functions with no request context dependency. The adapter closes over the RivetKit registry at init time and resolves actor handles on demand. No ambient `c` context needed.
|
||||
- **Hot-path latency — MITIGATED.** Cookie caching (`cookieCache` with `strategy: "compact"`) means most authenticated requests validate the session from a signed cookie without calling the adapter at all. The adapter (and thus the actor round-trip) is only hit when the cache expires (configurable, e.g., every 5 minutes) or on writes. This makes the session index + user actor lookup acceptable.
|
||||
- **Two-actor consistency.** Session create/destroy touches two actors (user actor + workspace index). If either write fails, the system is in an inconsistent state. Recommended: write index first, then user actor. A dangling index entry pointing to a nonexistent session is benign — BetterAuth treats it as "session not found" and the user just re-authenticates.
|
||||
- **Cookie vs header auth.** BetterAuth defaults to HTTP-only cookies (`better-auth.session_token`). The current system uses a custom `x-foundry-session` header with `localStorage`. BetterAuth supports `bearer` token mode for programmatic clients via its `bearer` plugin. Enable both for browser + API access.
|
||||
- **Dev bootstrap flow.** `bootstrapAppGithubSession` bypasses the normal OAuth flow for local development. BetterAuth supports programmatic session creation via its internal adapter — the dev path can call the adapter's `create` method directly for the `session` and `account` models.
|
||||
- **Actor lifecycle for users.** User actors are long-lived but low-traffic. RivetKit will idle/unload them. With cookie caching, cold-start only happens when the cache expires — not on every request. Acceptable.
|
||||
|
||||
## Suggested Implementation Order
|
||||
|
||||
1. **Phase 0 spike** — confirm adapter feasibility (go/no-go gate)
|
||||
2. Phase 1 (user actor + adapter infrastructure, no behavior change)
|
||||
3. Phase 2 (OAuth migration)
|
||||
4. Phase 3 (read path migration)
|
||||
5. Phase 4 (frontend migration)
|
||||
6. Phase 5 (cleanup)
|
||||
|
||||
Phases 2-4 can be deployed incrementally. Each phase should leave the system fully functional — no big-bang cutover.
|
||||
|
||||
## Alternative: Fix Without BetterAuth
|
||||
|
||||
If the BetterAuth + actor SQLite spike fails, the same goals can be achieved without BetterAuth:
|
||||
|
||||
1. Extract `userProfiles` and `sessionState` tables (same as Phase 1).
|
||||
2. Sign session tokens with HMAC using `BETTER_AUTH_SECRET` (rename to `SESSION_SECRET`).
|
||||
3. Use GitHub numeric ID as user PK.
|
||||
4. Keep the custom OAuth flow but thin it out.
|
||||
5. Drop `appSessions` once migration is complete.
|
||||
|
||||
This is more code to maintain but avoids the BetterAuth integration risk.
|
||||
56
foundry/research/specs/async-action-fixes/README.md
Normal file
56
foundry/research/specs/async-action-fixes/README.md
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
# Async Action Fixes Handoff
|
||||
|
||||
## Purpose
|
||||
|
||||
This folder contains implementation briefs for removing long-running synchronous waits from Foundry request and action paths.
|
||||
|
||||
Start with `00-end-to-end-async-realtime-plan.md`. It is the umbrella plan for the broader migration away from monolithic snapshots and polling, and it adds the missing runtime hardening and subscription-lifecycle work that the numbered implementation briefs did not previously cover.
|
||||
|
||||
The governing policy now lives in `foundry/CLAUDE.md`:
|
||||
|
||||
- always await `send(...)`
|
||||
- default to `wait: false`
|
||||
- only use `wait: true` for short, bounded mutations
|
||||
- do not force repo/provider sync in read paths
|
||||
- only block until the minimum client-needed resource exists
|
||||
|
||||
## Shared Context
|
||||
|
||||
- Backend actor entry points live under `foundry/packages/backend/src/actors`.
|
||||
- Provider-backed long-running work lives under `foundry/packages/backend/src/providers`.
|
||||
- The main UI consumers are:
|
||||
- `foundry/packages/frontend/src/components/workspace-dashboard.tsx`
|
||||
- `foundry/packages/frontend/src/components/mock-layout.tsx`
|
||||
- `foundry/packages/client/src/remote/workbench-client.ts`
|
||||
- Existing non-blocking examples already exist in app-shell GitHub auth/import flows. Use those as the reference pattern for request returns plus background completion.
|
||||
|
||||
## Suggested Implementation Order
|
||||
|
||||
1. `00-end-to-end-async-realtime-plan.md`
|
||||
2. `01-task-creation-bootstrap-only.md`
|
||||
3. `03-repo-actions-via-background-workflow.md`
|
||||
4. `06-daytona-provisioning-staged-background-flow.md`
|
||||
5. App shell realtime subscription work from `00-end-to-end-async-realtime-plan.md`
|
||||
6. `02-repo-overview-from-cached-projection.md`
|
||||
7. Workspace summary projection work from `00-end-to-end-async-realtime-plan.md`
|
||||
8. `04-workbench-session-creation-without-inline-provisioning.md`
|
||||
9. `05-workbench-snapshot-from-derived-state.md`
|
||||
10. Task-detail direct subscription work from `00-end-to-end-async-realtime-plan.md`
|
||||
11. `07-auth-identity-simplification.md`
|
||||
|
||||
## Why This Order
|
||||
|
||||
- Runtime hardening and the first async workflow items remove the highest-risk correctness and timeout issues first.
|
||||
- App shell realtime is a smaller migration than the workbench and removes the current polling loop early.
|
||||
- Workspace summary and task-detail subscription work are easier once long-running mutations already report durable background state.
|
||||
- Auth simplification is important, but it should not block the snapshot/polling/runtime fixes.
|
||||
|
||||
## Fresh Agent Checklist
|
||||
|
||||
Before implementing any item:
|
||||
|
||||
1. Read `foundry/CLAUDE.md` runtime and actor rules.
|
||||
2. Read the specific item doc in this folder.
|
||||
3. Confirm the current code paths named in that doc still match the repo.
|
||||
4. Preserve actor single-writer ownership.
|
||||
5. Prefer workflow status and push updates over synchronous completion.
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue