From dbe57d45b9ae1a07a47b45e57417409a5604e2e6 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Sun, 15 Mar 2026 10:20:27 -0700 Subject: [PATCH] feat(foundry): checkpoint actor and workspace refactor --- foundry/CLAUDE.md | 12 +- foundry/FOUNDRY-CHANGES.md | 1343 +++++++++++++++++ foundry/packages/backend/CLAUDE.md | 23 +- .../actors/{auth-user => audit-log}/db/db.ts | 2 +- .../src/actors/audit-log/db/drizzle.config.ts | 6 + .../db/drizzle/0000_fluffy_kid_colt.sql | 0 .../db/drizzle/meta/0000_snapshot.json | 0 .../db/drizzle/meta/_journal.json | 0 .../{history => audit-log}/db/migrations.ts | 0 .../{history => audit-log}/db/schema.ts | 2 +- .../actors/{history => audit-log}/index.ts | 46 +- .../backend/src/actors/auth-user/db/schema.ts | 70 - .../src/actors/github-data/db/migrations.ts | 3 +- .../src/actors/github-data/db/schema.ts | 27 +- .../backend/src/actors/github-data/index.ts | 8 +- .../packages/backend/src/actors/handles.ts | 22 +- .../src/actors/history/db/drizzle.config.ts | 6 - foundry/packages/backend/src/actors/index.ts | 12 +- foundry/packages/backend/src/actors/keys.ts | 6 +- .../src/actors/organization/actions.ts | 458 ++---- .../src/actors/organization/app-shell.ts | 25 +- .../src/actors/organization/db/migrations.ts | 74 +- .../src/actors/organization/db/schema.ts | 99 +- .../backend/src/actors/repository/actions.ts | 363 +++-- .../src/actors/repository/db/migrations.ts | 35 +- .../src/actors/repository/db/schema.ts | 54 +- .../task/db/drizzle/0000_charming_maestro.sql | 15 +- .../task/db/drizzle/meta/0000_snapshot.json | 4 +- .../backend/src/actors/task/db/migrations.ts | 33 +- .../backend/src/actors/task/db/schema.ts | 15 +- .../packages/backend/src/actors/task/index.ts | 209 +-- .../src/actors/task/workflow/commands.ts | 36 +- .../src/actors/task/workflow/common.ts | 64 +- .../backend/src/actors/task/workflow/index.ts | 136 +- .../backend/src/actors/task/workflow/init.ts | 65 +- .../backend/src/actors/task/workflow/push.ts | 18 +- .../backend/src/actors/task/workflow/queue.ts | 35 +- .../task/{workbench.ts => workspace.ts} | 250 +-- .../src/actors/{history => user}/db/db.ts | 2 +- .../{auth-user => user}/db/migrations.ts | 26 +- .../backend/src/actors/user/db/schema.ts | 103 ++ .../src/actors/{auth-user => user}/index.ts | 134 +- .../backend/src/services/better-auth.ts | 36 +- foundry/packages/backend/test/keys.test.ts | 4 +- ...nread.test.ts => workspace-unread.test.ts} | 10 +- foundry/packages/client/package.json | 4 +- foundry/packages/client/src/app-client.ts | 2 + foundry/packages/client/src/backend-client.ts | 282 ++-- foundry/packages/client/src/index.ts | 2 +- foundry/packages/client/src/keys.ts | 4 +- foundry/packages/client/src/mock-app.ts | 24 +- .../client/src/mock/backend-client.ts | 146 +- ...orkbench-client.ts => workspace-client.ts} | 92 +- .../packages/client/src/remote/app-client.ts | 7 +- .../client/src/remote/workbench-client.ts | 198 --- .../client/src/remote/workspace-client.ts | 193 +++ .../client/src/subscription/topics.ts | 58 +- .../packages/client/src/workbench-client.ts | 64 - .../packages/client/src/workspace-client.ts | 63 + ...{workbench-model.ts => workspace-model.ts} | 36 +- .../test/e2e/full-integration-e2e.test.ts | 2 +- .../client/test/e2e/github-pr-e2e.test.ts | 2 +- ...ench-e2e.test.ts => workspace-e2e.test.ts} | 62 +- ...e2e.test.ts => workspace-load-e2e.test.ts} | 52 +- foundry/packages/client/test/keys.test.ts | 4 +- .../client/test/subscription-manager.test.ts | 29 +- .../frontend/src/components/dev-panel.tsx | 16 +- .../frontend/src/components/mock-layout.tsx | 198 ++- .../src/components/mock-layout/sidebar.tsx | 3 - .../mock-layout/transcript-header.tsx | 69 +- .../components/mock-layout/view-model.test.ts | 4 +- .../src/components/mock-layout/view-model.ts | 26 +- .../src/components/organization-dashboard.tsx | 6 +- .../frontend/src/features/tasks/status.ts | 4 +- foundry/packages/frontend/src/lib/mock-app.ts | 11 +- .../frontend/src/sandbox-agent-react.d.ts | 12 + foundry/packages/shared/src/app-shell.ts | 4 +- foundry/packages/shared/src/contracts.ts | 14 +- foundry/packages/shared/src/index.ts | 2 +- .../packages/shared/src/realtime-events.ts | 15 +- .../shared/src/{workbench.ts => workspace.ts} | 172 +-- 81 files changed, 3441 insertions(+), 2332 deletions(-) create mode 100644 foundry/FOUNDRY-CHANGES.md rename foundry/packages/backend/src/actors/{auth-user => audit-log}/db/db.ts (69%) create mode 100644 foundry/packages/backend/src/actors/audit-log/db/drizzle.config.ts rename foundry/packages/backend/src/actors/{history => audit-log}/db/drizzle/0000_fluffy_kid_colt.sql (100%) rename foundry/packages/backend/src/actors/{history => audit-log}/db/drizzle/meta/0000_snapshot.json (100%) rename foundry/packages/backend/src/actors/{history => audit-log}/db/drizzle/meta/_journal.json (100%) rename foundry/packages/backend/src/actors/{history => audit-log}/db/migrations.ts (100%) rename foundry/packages/backend/src/actors/{history => audit-log}/db/schema.ts (82%) rename foundry/packages/backend/src/actors/{history => audit-log}/index.ts (59%) delete mode 100644 foundry/packages/backend/src/actors/auth-user/db/schema.ts delete mode 100644 foundry/packages/backend/src/actors/history/db/drizzle.config.ts rename foundry/packages/backend/src/actors/task/{workbench.ts => workspace.ts} (81%) rename foundry/packages/backend/src/actors/{history => user}/db/db.ts (70%) rename foundry/packages/backend/src/actors/{auth-user => user}/db/migrations.ts (72%) create mode 100644 foundry/packages/backend/src/actors/user/db/schema.ts rename foundry/packages/backend/src/actors/{auth-user => user}/index.ts (68%) rename foundry/packages/backend/test/{workbench-unread.test.ts => workspace-unread.test.ts} (92%) rename foundry/packages/client/src/mock/{workbench-client.ts => workspace-client.ts} (83%) delete mode 100644 foundry/packages/client/src/remote/workbench-client.ts create mode 100644 foundry/packages/client/src/remote/workspace-client.ts delete mode 100644 foundry/packages/client/src/workbench-client.ts create mode 100644 foundry/packages/client/src/workspace-client.ts rename foundry/packages/client/src/{workbench-model.ts => workspace-model.ts} (98%) rename foundry/packages/client/test/e2e/{workbench-e2e.test.ts => workspace-e2e.test.ts} (81%) rename foundry/packages/client/test/e2e/{workbench-load-e2e.test.ts => workspace-load-e2e.test.ts} (87%) rename foundry/packages/shared/src/{workbench.ts => workspace.ts} (51%) diff --git a/foundry/CLAUDE.md b/foundry/CLAUDE.md index e347a60..067dfda 100644 --- a/foundry/CLAUDE.md +++ b/foundry/CLAUDE.md @@ -73,7 +73,7 @@ Use `pnpm` workspaces and Turborepo. - 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(.../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 the mock workspace 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. - If Foundry uses a shared component from `@sandbox-agent/react`, make changes in `sdks/react` instead of copying or forking that component into Foundry. - When changing shared React components in `sdks/react` for Foundry, verify they still work in the Sandbox Agent Inspector before finishing. @@ -227,8 +227,8 @@ Action handlers must return fast. The pattern: Examples: - `createTask` → `wait: true` (returns `{ taskId }`), then enqueue provisioning with `wait: false`. Client sees task appear immediately with pending status, observes `ready` via organization events. -- `sendWorkbenchMessage` → validate session is `ready` (throw if not), enqueue with `wait: false`. Client observes session transition to `running` → `idle` via session events. -- `createWorkbenchSession` → `wait: true` (returns `{ tabId }`), enqueue sandbox provisioning with `wait: false`. Client observes `pending_provision` → `ready` via task events. +- `sendWorkspaceMessage` → validate session is `ready` (throw if not), enqueue with `wait: false`. Client observes session transition to `running` → `idle` via session events. +- `createWorkspaceSession` → `wait: true` (returns `{ sessionId }`), enqueue sandbox provisioning with `wait: false`. Client observes `pending_provision` → `ready` via task events. Never use `wait: true` for operations that depend on external readiness, sandbox I/O, agent responses, git network operations, polling loops, or long-running queue drains. Never hold an action open while waiting for an external system to become ready — that is a polling/retry loop in disguise. @@ -320,9 +320,9 @@ Each entry must include: - Friction/issue - Attempted fix/workaround and outcome -## History Events +## Audit Log Events -Log notable workflow changes to `events` so `hf history` remains complete: +Log notable workflow changes to `events` so the audit log remains complete: - create - attach @@ -331,6 +331,8 @@ Log notable workflow changes to `events` so `hf history` remains complete: - status transitions - PR state transitions +When adding new task/workspace commands, always add a corresponding audit log event. + ## Validation After Changes Always run and fix failures: diff --git a/foundry/FOUNDRY-CHANGES.md b/foundry/FOUNDRY-CHANGES.md new file mode 100644 index 0000000..14ea666 --- /dev/null +++ b/foundry/FOUNDRY-CHANGES.md @@ -0,0 +1,1343 @@ +# Foundry Planned Changes + +## How to use this document + +Work through items checking boxes as you go. Some items have dependencies — do not start an item until its dependencies are checked off. After each item, run `pnpm -w typecheck && pnpm -w build && pnpm -w test` to validate. If an item includes a "CLAUDE.md update" section, apply it in the same change. Commit after each item passes validation. + +## Progress Log + +- 2026-03-14 10: Initial architecture mapping complete. + - Confirmed the current hot spots match the spec: `auth-user` is still mutation-by-action, `history` is still a separate actor with an `append` action wrapper, organization still owns `taskLookup`/`taskSummaries`, and the `Workbench*` surface is still shared across backend/client/frontend. + - Started foundational rename and migration planning for items `1`, `6`, and `25` because they drive most of the later fallout. +- 2026-03-14 11: Audit-log rename slice landed. + - Renamed the backend actor from `history` to `audit-log`, switched the queue name to `auditLog.command.append`, and removed the `append` action wrapper. + - Updated task/repository/organization call sites to send directly to the audit-log queue or read through the renamed audit-log handle. +- 2026-03-14 12: Foundational naming and dead-surface cleanup landed. + - Renamed the backend auth actor surface from `authUser` to `user`, including actor registration, key helpers, handles, and Better Auth service routing. + - Deleted the dead `getTaskEnriched` / `enrichTaskRecord` fan-out path and changed organization task reads to go straight to the task actor. + - Renamed admin-only GitHub rebuild/reload actions with the `admin*` prefix across backend, client, and frontend. + - Collapsed organization realtime to full-snapshot `organizationUpdated` events and aligned task events to `type: "taskUpdated"`. +- 2026-03-14 13: Task schema migration cleanup landed. + - Removed the task actor's runtime `CREATE TABLE IF NOT EXISTS` / `ALTER TABLE` helpers from `task/workbench.ts` and `task/workflow/init.ts`. + - Updated the checked-in task migration artifacts so the schema-defined task/session/runtime columns are created directly by migrations. +- 2026-03-14 14: Item 3 blocker documented. + - The spec's requested literal singleton `CHECK (id = 1)` on the Better Auth `user` table conflicts with the existing Better Auth adapter contract, which relies on external string `user.id`. + - Proceeding safely will require a design adjustment for that table rather than a straight mechanical migration. +- 2026-03-14 15: Better Auth mapping comments landed. + - Added Better Auth vs custom Foundry table/action comments in the user and organization actor schema/action surfaces so the adapter-constrained paths are explicit. +- 2026-03-15 09: Branch rename surface deleted and stale organization subscription fixed. + - Removed the remaining branch-rename surface from the client, mock backend, frontend UI, and repository action layer. There are no remaining `renameBranch` / `renameWorkbenchBranch` references in Foundry. + - Fixed the remote backend client to listen for `organizationUpdated` on the organization connection instead of the dead `workspaceUpdated` event name. +- 2026-03-15 10: Backend workspace rename landed. + - Renamed the backend task UI/workflow surface from `workbench` to `workspace`, including the task actor file, queue topic family, organization proxy actions, and the task session table name (`task_workspace_sessions`). + - Backend actor code no longer contains `Workbench` / `workbench` references, so the remaining shared/client/frontend rename can align to a stable backend target. +- 2026-03-15 11: Default model moved to user-scoped app state. + - Removed `defaultModel` from the organization schema/snapshot and stored it on the user profile instead, exposed through the app snapshot as a user preference. + - Wired `setAppDefaultModel` through the backend/app clients and changed the model picker to persist the starred/default model instead of resetting local React state on reload. +- 2026-03-15 11: Workspace surface completed across Foundry packages. + - Renamed the shared/client/frontend surface from `Workbench` to `Workspace`, including `workspace.ts`, workspace client/model files, DTO/type names, backend-client method names, frontend view-model imports, and the affected e2e/test files. + - Verified that Foundry backend/shared/client/frontend packages no longer contain `Workbench` / `workbench` references. +- 2026-03-15 11: Singleton constraints tightened where safe. + - Added `CHECK (id = 1)` enforcement for `github_meta`, `repo_meta`, `organization_profile`, and `user_profiles`, and updated the affected code paths/migrations to use row id `1`. + - The Better Auth `user` table remains blocked by the adapter contract, so item `3` is still open overall. +- 2026-03-14 12: Confirmed blocker for later user-table singleton work. + - Item `3` conflicts with the current Better Auth adapter contract for the `user` table: the adapter depends on the external string `user.id`, while the spec also asks for a literal singleton `CHECK (id = 1)` on that same table. + - That cannot be applied mechanically without redesigning the Better Auth adapter contract or introducing a separate surrogate identity column. I have not forced that change yet. + +No backwards compatibility — delete old code, don't deprecate. If something is removed, remove it everywhere (backend, client, shared types, frontend, tests, mocks). + +### Suggested execution order (respects dependencies) + +**Wave 1 — no dependencies, can be done in any order:** +1, 2, 3, 4, 5, 6, 13, 16, 20, 21, 23, 25 + +**Wave 2 — depends on wave 1:** +7 (after 1), 9 (after 13), 10 (after 1+6), 11 (after 4), 22 (after 1), 24 (after 21), 26 (after 25) + +**Wave 3 — depends on wave 2:** +8 (after 7+25), 12 (after 10), 15 (after 9+13), 19 (after 21+24) + +**Wave 4 — depends on wave 3:** +14 (after 15) + +**Final:** +17 (deferred), 18 (after everything), final audit pass (after everything) + +### Index + +- [x] 1. Rename Auth User actor → User actor +- [x] 2. Add Better Auth mapping comments to user/org actor tables +- [ ] 3. Enforce `id = 1` CHECK constraint on single-row tables +- [ ] 4. Move all mutation actions to queue messages +- [x] 5. Migrate task actor raw SQL to Drizzle migrations +- [x] 6. Rename History actor → Audit Log actor +- [x] 7. Move starred/default model to user actor settings *(depends on: 1)* +- [ ] 8. Replace hardcoded model/agent lists with sandbox-agent API data *(depends on: 7, 25)* +- [ ] 9. Flatten `taskLookup` + `taskSummaries` into single `tasks` table *(depends on: 13)* +- [ ] 10. Reorganize user and org actor actions into `actions/` folders *(depends on: 1, 6)* +- [ ] 11. Standardize workflow file structure across all actors *(depends on: 4)* +- [ ] 12. Audit and remove dead code in organization actor *(depends on: 10)* +- [ ] 13. Enforce coordinator pattern and fix ownership violations +- [ ] 14. Standardize one event per subscription topic *(depends on: 15)* +- [ ] 15. Unify tasks and pull requests — PRs are just task data *(depends on: 9, 13)* +- [ ] 16. Chunk GitHub data sync and publish progress +- [ ] 17. Type all actor context parameters — remove `c: any` *(DEFERRED — do last)* +- [ ] 18. Final pass: remove all dead code *(depends on: all other items)* +- [ ] 19. Remove duplicate data between `c.state` and SQLite *(depends on: 21, 24)* +- [x] 20. Prefix admin/recovery actions with `admin` +- [ ] 21. Remove legacy/session-scoped fields from task table +- [ ] 22. Move per-user UI state from task actor to user actor *(depends on: 1)* +- [x] 23. Delete `getTaskEnriched` and `enrichTaskRecord` (dead code) +- [ ] 24. Clean up task status tracking *(depends on: 21)* +- [x] 25. Remove "Workbench" prefix from all types, functions, files, tables +- [x] 26. Delete branch rename (branches immutable after creation) *(depends on: 25)* +- [ ] Final audit pass: dead events scan *(depends on: all other items)* + +--- + +## [ ] 1. Rename Auth User actor → User actor + +**Rationale:** The actor is already a single per-user actor storing all user data. The "Auth" prefix is unnecessary. + +### Files to change + +- **`foundry/packages/backend/src/actors/auth-user/`** → rename directory to `user/` + - `index.ts` — rename export `authUser` → `user`, display name `"Auth User"` → `"User"` + - `db/schema.ts`, `db/db.ts`, `db/migrations.ts`, `db/drizzle.config.ts` — update any auth-prefixed references +- **`foundry/packages/backend/src/actors/keys.ts`** — `authUserKey()` → `userKey()` +- **`foundry/packages/backend/src/actors/handles.ts`** — `getOrCreateAuthUser` → `getOrCreateUser`, `getAuthUser` → `getUser`, `selfAuthUser` → `selfUser` +- **`foundry/packages/backend/src/actors/index.ts`** — update import path and registration +- **`foundry/packages/backend/src/services/better-auth.ts`** — update all `authUser` references +- **Action names** — consider dropping "Auth" prefix from `createAuthRecord`, `findOneAuthRecord`, `updateAuthRecord`, `deleteAuthRecord`, `countAuthRecords`, etc. + +--- + +## [ ] 2. Add Better Auth mapping comments to user/org actor tables, actions, and queues + +**Rationale:** The user and organization actors contain a mix of Better Auth-driven and custom Foundry code. Tables, actions, and queues that exist to serve Better Auth's adapter need comments so developers know which pieces are constrained by Better Auth's schema/contract and which are ours to change freely. + +### Table mapping + +| Actor | Table | Better Auth? | Notes | +|---|---|---|---| +| user | `user` | Yes — 1:1 `user` model | All fields from Better Auth | +| user | `session` | Yes — 1:1 `session` model | All fields from Better Auth | +| user | `account` | Yes — 1:1 `account` model | All fields from Better Auth | +| user | `user_profiles` | No — custom Foundry | GitHub login, role, eligible orgs, starter repo status | +| user | `session_state` | No — custom Foundry | Active organization per session | +| org | `auth_verification` | Yes — Better Auth `verification` model | Lives on org actor because verification happens before user exists | +| org | `auth_session_index` | No — custom routing index | Maps session tokens → user actor IDs for Better Auth adapter routing | +| org | `auth_email_index` | No — custom routing index | Maps emails → user actor IDs for Better Auth adapter routing | +| org | `auth_account_index` | No — custom routing index | Maps OAuth accounts → user actor IDs for Better Auth adapter routing | + +### Action/queue mapping (user actor) + +| Action/Queue | Better Auth? | Notes | +|---|---|---| +| `createAuthRecord` | Yes — Better Auth adapter | Called by Better Auth adapter to create user/session/account records | +| `findOneAuthRecord` | Yes — Better Auth adapter | Called by Better Auth adapter for single-record lookups with joins | +| `findManyAuthRecords` | Yes — Better Auth adapter | Called by Better Auth adapter for multi-record queries | +| `updateAuthRecord` | Yes — Better Auth adapter | Called by Better Auth adapter to update records | +| `updateManyAuthRecords` | Yes — Better Auth adapter | Called by Better Auth adapter for bulk updates | +| `deleteAuthRecord` | Yes — Better Auth adapter | Called by Better Auth adapter to delete records | +| `deleteManyAuthRecords` | Yes — Better Auth adapter | Called by Better Auth adapter for bulk deletes | +| `countAuthRecords` | Yes — Better Auth adapter | Called by Better Auth adapter for count queries | +| `getAppAuthState` | No — custom Foundry | Aggregates auth state for frontend consumption | +| `upsertUserProfile` | No — custom Foundry | Manages Foundry-specific user profile data | +| `upsertSessionState` | No — custom Foundry | Manages Foundry-specific session state | + +### Action/queue mapping (organization actor app-shell) + +| Action/Queue | Better Auth? | Notes | +|---|---|---| +| App-shell auth index CRUD actions | Yes — Better Auth adapter routing | Maintain lookup indexes so the adapter can route by session/email/account to the correct user actor | +| `auth_verification` CRUD | Yes — Better Auth `verification` model | Used for email verification and password resets | + +### Files to change + +- **`foundry/packages/backend/src/actors/auth-user/db/schema.ts`** — add doc comments to each table: + - `user`, `session`, `account`: "Better Auth core model — schema defined at https://better-auth.com/docs/concepts/database" + - `user_profiles`, `session_state`: "Custom Foundry table — not part of Better Auth" +- **`foundry/packages/backend/src/actors/auth-user/index.ts`** — add doc comments to each action/queue: + - Better Auth adapter actions: "Better Auth adapter — called by the Better Auth adapter in better-auth.ts. Schema constrained by Better Auth." + - Custom actions: "Custom Foundry action — not part of Better Auth" +- **`foundry/packages/backend/src/actors/organization/db/schema.ts`** — add doc comments to `auth_verification` (Better Auth core), and the three index tables (Better Auth adapter routing) +- **`foundry/packages/backend/src/actors/organization/app-shell.ts`** — add doc comments to auth index actions marking them as Better Auth adapter routing infrastructure + +--- + +## [ ] 3. Enforce `id = 1` CHECK constraint on all single-row actor tables + +**Rationale:** When an actor instance represents a single entity, tables that hold exactly one row should enforce this at the DB level with a `CHECK (id = 1)` constraint. The task actor already does this correctly; other actors don't. + +### Tables needing the constraint + +| Actor | Table | Current enforcement | Fix needed | +|---|---|---|---| +| auth-user (→ user) | `user` | None | Add `CHECK (id = 1)`, use integer PK | +| auth-user (→ user) | `user_profiles` | None | Add `CHECK (id = 1)`, use integer PK | +| github-data | `github_meta` | Hardcoded `id=1` in code only | Add `CHECK (id = 1)` in schema | +| organization | `organization_profile` | None | Add `CHECK (id = 1)`, use integer PK | +| repository | `repo_meta` | Hardcoded `id=1` in code only | Add `CHECK (id = 1)` in schema | +| task | `task` | CHECK constraint | Already correct | +| task | `task_runtime` | CHECK constraint | Already correct | + +### Files to change + +- **`foundry/packages/backend/src/actors/auth-user/db/schema.ts`** — change `user` and `user_profiles` tables to integer PK with CHECK constraint +- **`foundry/packages/backend/src/actors/auth-user/index.ts`** — update queries to use `id = 1` pattern +- **`foundry/packages/backend/src/services/better-auth.ts`** — update adapter to use fixed `id = 1` +- **`foundry/packages/backend/src/actors/github-data/db/schema.ts`** — add CHECK constraint to `github_meta` (already uses `id=1` in code) +- **`foundry/packages/backend/src/actors/organization/db/schema.ts`** — change `organization_profile` to integer PK with CHECK constraint +- **`foundry/packages/backend/src/actors/organization/actions.ts`** — update queries to use `id = 1` +- **`foundry/packages/backend/src/actors/repository/db/schema.ts`** — add CHECK constraint to `repo_meta` (already uses `id=1` in code) +- All affected actors — regenerate `db/migrations.ts` + +### CLAUDE.md update + +- **`foundry/packages/backend/CLAUDE.md`** — add constraint: "Single-row tables (tables that hold exactly one record per actor instance, e.g. metadata or profile tables) must use an integer primary key with a `CHECK (id = 1)` constraint to enforce the singleton invariant at the database level. Follow the pattern established in the task actor's `task` and `task_runtime` tables." + +--- + +## [ ] 4. Move all mutation actions to queue messages + +**Rationale:** Actions should be read-only (queries). All mutations (INSERT/UPDATE/DELETE) should go through queue messages processed by workflow handlers. This ensures single-writer consistency and aligns with the actor model. No actor currently does this correctly — the history actor has the mutation in the workflow handler, but the `append` action wraps a `wait: true` queue send, which is the same anti-pattern (callers should send to the queue directly). + +### Violations by actor + +**User actor (auth-user)** — `auth-user/index.ts` — 7 mutation actions: +- `createAuthRecord` (INSERT, line 164) +- `updateAuthRecord` (UPDATE, line 205) +- `updateManyAuthRecords` (UPDATE, line 219) +- `deleteAuthRecord` (DELETE, line 234) +- `deleteManyAuthRecords` (DELETE, line 243) +- `upsertUserProfile` (UPSERT, line 283) +- `upsertSessionState` (UPSERT, line 331) + +**GitHub Data actor** — `github-data/index.ts` — 7 mutation actions: +- `fullSync` (batch INSERT/DELETE/UPDATE, line 686) +- `reloadOrganization` (batch, line 690) +- `reloadAllPullRequests` (batch, line 694) +- `reloadRepository` (INSERT/UPDATE, line 698) +- `reloadPullRequest` (INSERT/DELETE/UPDATE, line 763) +- `clearState` (batch DELETE, line 851) +- `handlePullRequestWebhook` (INSERT/UPDATE/DELETE, line 879) + +**Organization actor — `actions.ts`** — 5 mutation actions: +- `applyTaskSummaryUpdate` (UPSERT, line 464) +- `removeTaskSummary` (DELETE, line 476) +- `applyGithubRepositoryProjection` (UPSERT, line 521) +- `applyGithubDataProjection` (INSERT/UPDATE/DELETE, line 547) +- `recordGithubWebhookReceipt` (UPDATE, line 620) + +**Organization actor — `app-shell.ts`** — 38 mutation actions: + +Better Auth index mutations (11): +- `authUpsertSessionIndex` (UPSERT) +- `authDeleteSessionIndex` (DELETE) +- `authUpsertEmailIndex` (UPSERT) +- `authDeleteEmailIndex` (DELETE) +- `authUpsertAccountIndex` (UPSERT) +- `authDeleteAccountIndex` (DELETE) +- `authCreateVerification` (INSERT) +- `authUpdateVerification` (UPDATE) +- `authUpdateManyVerification` (UPDATE) +- `authDeleteVerification` (DELETE) +- `authDeleteManyVerification` (DELETE) + +Organization profile/state mutations (13): +- `updateOrganizationShellProfile` (UPDATE on organizationProfile) +- `markOrganizationSyncStarted` (UPDATE on organizationProfile) +- `applyOrganizationSyncCompleted` (UPDATE on organizationProfile) +- `markOrganizationSyncFailed` (UPDATE on organizationProfile) +- `applyOrganizationStripeCustomer` (UPDATE on organizationProfile) +- `applyOrganizationStripeSubscription` (UPSERT on organizationProfile) +- `applyOrganizationFreePlan` (UPDATE on organizationProfile) +- `setOrganizationBillingPaymentMethod` (UPDATE on organizationProfile) +- `setOrganizationBillingStatus` (UPDATE on organizationProfile) +- `upsertOrganizationInvoice` (UPSERT on invoices) +- `recordOrganizationSeatUsage` (UPSERT on seatAssignments) +- `applyGithubInstallationCreated` (UPDATE on organizationProfile) +- `applyGithubInstallationRemoved` (UPDATE on organizationProfile) + +App-level mutations that delegate + mutate (8): +- `skipAppStarterRepo` (calls upsertUserProfile) +- `starAppStarterRepo` (calls upsertUserProfile + child mutation) +- `selectAppOrganization` (calls setActiveOrganization) +- `triggerAppRepoImport` (calls markOrganizationSyncStarted) +- `createAppCheckoutSession` (calls applyOrganizationFreePlan + applyOrganizationStripeCustomer) +- `finalizeAppCheckoutSession` (calls applyOrganizationStripeCustomer) +- `cancelAppScheduledRenewal` (calls setOrganizationBillingStatus) +- `resumeAppSubscription` (calls setOrganizationBillingStatus) +- `recordAppSeatUsage` (calls recordOrganizationSeatUsage) +- `handleAppStripeWebhook` (calls multiple org mutations) +- `handleAppGithubWebhook` (calls org mutations + github-data mutations) +- `syncOrganizationShellFromGithub` (multiple DB operations) +- `applyGithubRepositoryChanges` (calls applyGithubRepositoryProjection) + +**Task actor workbench** — `task/workbench.ts` — 14 mutation actions: +- `renameWorkbenchTask` (UPDATE, line 970) +- `renameWorkbenchBranch` (UPDATE, line 988) +- `createWorkbenchSession` (INSERT, line 1039) +- `renameWorkbenchSession` (UPDATE, line 1125) +- `setWorkbenchSessionUnread` (UPDATE, line 1136) +- `updateWorkbenchDraft` (UPDATE, line 1143) +- `changeWorkbenchModel` (UPDATE, line 1152) +- `sendWorkbenchMessage` (UPDATE, line 1205) +- `stopWorkbenchSession` (UPDATE, line 1255) +- `syncWorkbenchSessionStatus` (UPDATE, line 1265) +- `closeWorkbenchSession` (UPDATE, line 1331) +- `markWorkbenchUnread` (UPDATE, line 1363) +- `publishWorkbenchPr` (UPDATE, line 1375) +- `revertWorkbenchFile` (UPDATE, line 1403) + +**Repository actor** — `repository/actions.ts` — 5 mutation actions/helpers: +- `createTask` → calls `createTaskMutation()` (INSERT on taskIndex + creates task actor) +- `registerTaskBranch` → calls `registerTaskBranchMutation()` (INSERT/UPDATE on taskIndex) +- `reinsertTaskIndexRow()` (INSERT/UPDATE, called from `getTaskEnriched`) +- `deleteStaleTaskIndexRow()` (DELETE) +- `persistRemoteUrl()` (INSERT/UPDATE on repoMeta, called from `getRepoOverview`) + +### History (audit log) actor — `append` action must also be removed + +The history actor's workflow handler is correct (mutation in queue handler), but the `append` action (line 77) is a `wait: true` wrapper around the queue send — same anti-pattern. Delete the `append` action. Callers (the `appendHistory()` helper in `task/workflow/common.ts`) should send directly to the `auditLog.command.append` queue with `wait: false` (audit log writes are fire-and-forget, no need to block the caller). + +### Reference patterns (queue handlers only, no action wrappers) +- **Task actor core** — initialize, attach, push, sync, merge, archive, kill all use queue messages directly + +### Migration approach + +This is NOT about wrapping queue sends inside actions. The mutation actions must be **removed entirely** and replaced with queue messages that callers (including `packages/client`) send directly. + +Each actor needs: +1. Define queue message types for each mutation +2. Move mutation logic from action handlers into workflow/queue handlers +3. **Delete the mutation actions** — do not wrap them +4. Update `packages/client` to send queue messages directly to the actor instead of calling the old action +5. Update any inter-actor callers (e.g. `better-auth.ts`, `app-shell.ts`, other actors) to send queue messages instead of calling actions + +### CLAUDE.md update + +- **`foundry/packages/backend/CLAUDE.md`** — add constraint: "Actions must be read-only. All database mutations (INSERT, UPDATE, DELETE, UPSERT) must be queue messages processed by workflow handlers. Callers (client, other actors, services) send messages directly to the queue — do not wrap queue sends inside actions. Follow the pattern established in the task workflow actor's queue handlers." + +--- + +## [ ] 5. Migrate task actor raw SQL to Drizzle migrations + +**Rationale:** The task actor uses raw `db.execute()` with `ALTER TABLE ... ADD COLUMN` in `workbench.ts` and `workflow/init.ts` instead of proper Drizzle migrations. All actor DBs should use the standard Drizzle migration pattern. + +### Files to change + +- **`foundry/packages/backend/src/actors/task/workbench.ts`** (lines 24-56) — remove `ALTER TABLE` raw SQL, add columns to `db/schema.ts` and generate a proper migration +- **`foundry/packages/backend/src/actors/task/workflow/init.ts`** (lines 12-15) — same treatment +- **`foundry/packages/backend/src/actors/task/db/schema.ts`** — add the missing columns that are currently added via `ALTER TABLE` +- **`foundry/packages/backend/src/actors/task/db/migrations.ts`** — regenerate with new migration + +### CLAUDE.md update + +- **`foundry/packages/backend/CLAUDE.md`** — add constraint: "All actor databases must use Drizzle ORM with proper schema definitions and generated migrations. No raw SQL (`db.execute()`, `ALTER TABLE`, etc.). Schema changes must go through `schema.ts` + migration generation." + +--- + +## [ ] 6. Rename History actor → Audit Log actor + +**Rationale:** The actor functions as a comprehensive audit log tracking task lifecycle events. "Audit Log" better describes its purpose. + +### Files to change + +- **`foundry/packages/backend/src/actors/history/`** → rename directory to `audit-log/` + - `index.ts` — rename export `history` → `auditLog`, display name `"History"` → `"Audit Log"`, queue `history.command.append` → `auditLog.command.append` + - Internal types: `HistoryInput` → `AuditLogInput`, `AppendHistoryCommand` → `AppendAuditLogCommand`, `ListHistoryParams` → `ListAuditLogParams` +- **`foundry/packages/backend/src/actors/keys.ts`** — `historyKey()` → `auditLogKey()` +- **`foundry/packages/backend/src/actors/handles.ts`** — `getOrCreateHistory` → `getOrCreateAuditLog`, `selfHistory` → `selfAuditLog` +- **`foundry/packages/backend/src/actors/index.ts`** — update import path and registration +- **`foundry/packages/shared/src/contracts.ts`** — `HistoryEvent` → `AuditLogEvent` +- **`foundry/packages/backend/src/actors/organization/actions.ts`** — `history()` action → `auditLog()`, update imports +- **`foundry/packages/backend/src/actors/repository/actions.ts`** — update `getOrCreateHistory` calls +- **`foundry/packages/backend/src/actors/task/workflow/common.ts`** — `appendHistory()` → `appendAuditLog()` +- **`foundry/packages/backend/src/actors/task/workflow/init.ts`** — update imports and calls +- **`foundry/packages/backend/src/actors/task/workflow/commands.ts`** — update imports and calls +- **`foundry/packages/backend/src/actors/task/workflow/push.ts`** — update imports and calls + +### Coverage gaps to fix + +The audit log only covers 9 of ~24 significant events (37.5%). The entire `task/workbench.ts` file has zero logging. Add audit log calls for: + +**High priority (missing lifecycle events):** +- `task.switch` — in `task/workflow/index.ts` handleSwitchActivity +- `task.session.created` — in `task/workbench.ts` createWorkbenchSession +- `task.session.closed` — in `task/workbench.ts` closeWorkbenchSession +- `task.session.stopped` — in `task/workbench.ts` stopWorkbenchSession + +**Medium priority (missing user actions):** +- `task.session.renamed` — renameWorkbenchSession +- `task.message.sent` — sendWorkbenchMessage +- `task.model.changed` — changeWorkbenchModel +- `task.title.changed` — renameWorkbenchTask +- `task.branch.renamed` — renameWorkbenchBranch +- `task.pr.published` — publishWorkbenchPr +- `task.file.reverted` — revertWorkbenchFile + +**Low priority / debatable:** +- `task.draft.updated`, `task.session.unread`, `task.derived.refreshed`, `task.transcript.refreshed` + +### CLAUDE.md updates needed + +- **`foundry/packages/backend/CLAUDE.md`** — rename `HistoryActor` → `AuditLogActor` in actor hierarchy, add maintenance rule: "Every new action or command handler that represents a user-visible or workflow-significant event must append to the audit log actor. The audit log must remain a comprehensive record of all significant operations." +- **`foundry/CLAUDE.md`** — rename "History Events" section → "Audit Log Events", update the list to include all events above, add note: "When adding new task/workbench commands, always add a corresponding audit log event." + +--- + +## [ ] 7. Move starred/default model to user actor settings + +**Dependencies:** item 1 + +**Rationale:** The starred/default model preference is currently broken — the frontend stores it in local React state that resets on reload. The org actor's `organizationProfile` table has a `defaultModel` column but there's no action to update it and it's the wrong scope anyway. This is a per-user preference, not an org setting. + +### Current state (broken) + +- **Frontend** (`mock-layout.tsx` line 313) — `useState("claude-sonnet-4")` — local state, lost on reload +- **Model picker UI** (`model-picker.tsx`) — has star icons + `onSetDefault` callback, but it only updates local state +- **Org actor** (`organization/db/schema.ts` line 43) — `defaultModel` column exists but nothing writes to it +- **No backend persistence** — starred model is not saved anywhere + +### Changes needed + +1. **Add `user_settings` table to user actor** (or add `defaultModel` column to `user_profiles`): + - `defaultModel` (text) — the user's starred/preferred model + - File: `foundry/packages/backend/src/actors/auth-user/db/schema.ts` + +2. **Add queue message to user actor** to update the default model: + - File: `foundry/packages/backend/src/actors/auth-user/index.ts` + +3. **Remove `defaultModel` from org actor** `organizationProfile` table (wrong scope): + - File: `foundry/packages/backend/src/actors/organization/db/schema.ts` + +4. **Update frontend** to read starred model from user settings (via `app` subscription) and send queue message on star click: + - File: `foundry/packages/frontend/src/components/mock-layout/model-picker.tsx` + - File: `foundry/packages/frontend/src/components/mock-layout.tsx` + +5. **Update shared types** — move `defaultModel` from `FoundryOrganizationSettings` to user settings type: + - File: `foundry/packages/shared/src/app-shell.ts` + +6. **Update client** to send the queue message to user actor: + - File: `foundry/packages/client/` + +--- + +## [ ] 8. Replace hardcoded model/agent lists with sandbox-agent API data + +**Dependencies:** items 7, 25 + +**Rationale:** The frontend hardcodes 8 models in a static list and ignores the sandbox-agent API's `GET /v1/agents` endpoint which already exposes the full agent config — models, modes, and reasoning/thought levels per agent. The frontend should consume this API 1:1 instead of maintaining its own stale copy. + +### Current state (hardcoded) + +- **`foundry/packages/frontend/src/components/mock-layout/view-model.ts`** (lines 20-39) — hardcoded `MODEL_GROUPS` with 8 models +- **`foundry/packages/client/src/workbench-model.ts`** (lines 18-37) — identical hardcoded `MODEL_GROUPS` copy +- **`foundry/packages/shared/src/workbench.ts`** (lines 5-13) — `WorkbenchModelId` hardcoded union type +- No modes or thought/reasoning levels exposed in UI at all +- No API calls to discover available models + +### What the sandbox-agent API already provides (`GET /v1/agents`) + +Per agent, the API returns: +- **models** — full list with display names (Claude: 4, Codex: 6, Cursor: 35+, OpenCode: 239) +- **modes** — execution modes (Claude: 5, Codex: 3, OpenCode: 2) +- **thought_level** — reasoning levels (Codex: low/medium/high/xhigh, Mock: low/medium/high) +- **capabilities** — plan_mode, reasoning, status support +- **credentialsAvailable** / **installed** — agent availability + +### Changes needed + +1. **Remove hardcoded model lists** from: + - `foundry/packages/frontend/src/components/mock-layout/view-model.ts` — delete `MODEL_GROUPS` + - `foundry/packages/client/src/workbench-model.ts` — delete `MODEL_GROUPS` + - `foundry/packages/shared/src/workbench.ts` — replace `WorkbenchModelId` union type with `string` (dynamic from API) + +2. **Backend: fetch and cache agent config from sandbox-agent API** + - Add an action or startup flow that calls `GET /v1/agents?config=true` on the sandbox-agent API + - Cache the result (agent list + models + modes + thought levels) in the appropriate actor + - Expose it to the frontend via the existing subscription/event system + +3. **Frontend: consume API-driven config** + - Model picker reads available models from backend-provided agent config, not hardcoded list + - Expose modes selector per agent + - Expose thought/reasoning level selector for agents that support it (Codex, Mock) + - Group models by agent as the API does (not by arbitrary provider grouping) + +4. **Update shared types** — make model/mode/thought_level types dynamic strings rather than hardcoded unions: + - `foundry/packages/shared/src/workbench.ts` + +5. **No backwards compatibility needed** — we're cleaning up, not preserving old behavior + +--- + +## [ ] 9. Flatten `taskLookup` + `taskSummaries` into single `tasks` table on org actor + +**Dependencies:** item 13 + +**Rationale:** `taskLookup` (taskId → repoId) is a strict subset of `taskSummaries` (which also has repoId + title, status, branch, PR, sessions). There's no reason for two tables with the same primary key. Flatten into one `tasks` table. + +### Current state + +- **`taskLookup`** — `taskId` (PK), `repoId` — used only for taskId → repoId resolution +- **`taskSummaries`** — `taskId` (PK), `repoId`, `title`, `status`, `repoName`, `updatedAtMs`, `branch`, `pullRequestJson`, `sessionsSummaryJson` — materialized sidebar data + +### Changes needed + +1. **Merge into single `tasks` table** in `foundry/packages/backend/src/actors/organization/db/schema.ts`: + - Drop `taskLookup` table + - Rename `taskSummaries` → `tasks` + - Keep all columns from `taskSummaries` (already includes `repoId`) + +2. **Update all references**: + - `foundry/packages/backend/src/actors/organization/actions.ts` — replace `taskLookup` queries with `tasks` table lookups + - `foundry/packages/backend/src/actors/organization/app-shell.ts` — if it references either table + - Any imports of the old table names from schema + +3. **Regenerate migrations** — `foundry/packages/backend/src/actors/organization/db/migrations.ts` + +--- + +## [ ] 10. Reorganize user and organization actor actions into `actions/` folders + +**Dependencies:** items 1, 6 + +**Rationale:** Both actors cram too many concerns into single files. The organization actor has `app-shell.ts` (1,947 lines) + `actions.ts` mixing Better Auth, Stripe, GitHub, onboarding, workbench proxying, and org state. The user actor mixes Better Auth adapter CRUD with custom Foundry actions. Split into `actions/` folders grouped by domain, with `betterAuth` prefix on all Better Auth actions. + +### User actor → `user/actions/` + +| File | Actions | Source | +|---|---|---| +| `actions/better-auth.ts` | `betterAuthCreateRecord`, `betterAuthFindOneRecord`, `betterAuthFindManyRecords`, `betterAuthUpdateRecord`, `betterAuthUpdateManyRecords`, `betterAuthDeleteRecord`, `betterAuthDeleteManyRecords`, `betterAuthCountRecords` + all helper functions (`tableFor`, `columnFor`, `normalizeValue`, `clauseToExpr`, `buildWhere`, `applyJoinToRow`, `applyJoinToRows`) | Currently in `index.ts` | +| `actions/user.ts` | `getAppAuthState`, `upsertUserProfile`, `upsertSessionState` | Currently in `index.ts` | + +### Organization actor → `organization/actions/` + +**Delete `app-shell.ts`** — split its ~50 actions + helpers across these files: + +| File | Actions | Source | +|---|---|---| +| `actions/better-auth.ts` | `betterAuthFindSessionIndex`, `betterAuthUpsertSessionIndex`, `betterAuthDeleteSessionIndex`, `betterAuthFindEmailIndex`, `betterAuthUpsertEmailIndex`, `betterAuthDeleteEmailIndex`, `betterAuthFindAccountIndex`, `betterAuthUpsertAccountIndex`, `betterAuthDeleteAccountIndex`, `betterAuthCreateVerification`, `betterAuthFindOneVerification`, `betterAuthFindManyVerification`, `betterAuthUpdateVerification`, `betterAuthUpdateManyVerification`, `betterAuthDeleteVerification`, `betterAuthDeleteManyVerification`, `betterAuthCountVerification` + auth clause builder helpers | Currently in `app-shell.ts` | +| `actions/stripe.ts` | `createAppCheckoutSession`, `finalizeAppCheckoutSession`, `createAppBillingPortalSession`, `cancelAppScheduledRenewal`, `resumeAppSubscription`, `recordAppSeatUsage`, `handleAppStripeWebhook`, `applyOrganizationStripeCustomer`, `applyOrganizationStripeSubscription`, `applyOrganizationFreePlan`, `setOrganizationBillingPaymentMethod`, `setOrganizationBillingStatus`, `upsertOrganizationInvoice`, `recordOrganizationSeatUsage` | Currently in `app-shell.ts` | +| `actions/github.ts` | `resolveAppGithubToken`, `beginAppGithubInstall`, `triggerAppRepoImport`, `handleAppGithubWebhook`, `syncOrganizationShellFromGithub`, `syncGithubOrganizations`, `applyGithubInstallationCreated`, `applyGithubInstallationRemoved`, `applyGithubRepositoryChanges`, `reloadGithubOrganization`, `reloadGithubPullRequests`, `reloadGithubRepository`, `reloadGithubPullRequest`, `applyGithubRepositoryProjection`, `applyGithubDataProjection`, `recordGithubWebhookReceipt`, `refreshTaskSummaryForGithubBranch` | Currently split across `app-shell.ts` and `actions.ts` | +| `actions/onboarding.ts` | `skipAppStarterRepo`, `starAppStarterRepo`, `starSandboxAgentRepo`, `selectAppOrganization` | Currently in `app-shell.ts` | +| `actions/organization.ts` | `getAppSnapshot`, `getOrganizationShellState`, `getOrganizationShellStateIfInitialized`, `updateOrganizationShellProfile`, `updateAppOrganizationProfile`, `markOrganizationSyncStarted`, `applyOrganizationSyncCompleted`, `markOrganizationSyncFailed`, `useOrganization`, `getOrganizationSummary`, `reconcileWorkbenchState` | Currently split across `app-shell.ts` and `actions.ts` | +| `actions/tasks.ts` | `createTask`, `createWorkbenchTask`, `listTasks`, `getTask`, `switchTask`, `applyTaskSummaryUpdate`, `removeTaskSummary`, `findTaskForGithubBranch`, `applyOpenPullRequestUpdate`, `removeOpenPullRequest`, `attachTask`, `pushTask`, `syncTask`, `mergeTask`, `archiveTask`, `killTask` | Currently in `actions.ts` | +| `actions/workbench.ts` | `markWorkbenchUnread`, `renameWorkbenchTask`, `renameWorkbenchBranch`, `createWorkbenchSession`, `renameWorkbenchSession`, `setWorkbenchSessionUnread`, `updateWorkbenchDraft`, `changeWorkbenchModel`, `sendWorkbenchMessage`, `stopWorkbenchSession`, `closeWorkbenchSession`, `publishWorkbenchPr`, `revertWorkbenchFile` | Currently in `actions.ts` (proxy calls to task actor) | +| `actions/repos.ts` | `listRepos`, `getRepoOverview` | Currently in `actions.ts` | +| `actions/history.ts` | `history` (→ `auditLog` after rename) | Currently in `actions.ts` | + +Also move: +- `APP_SHELL_ORGANIZATION_ID` constant → `organization/constants.ts` +- `runOrganizationWorkflow` → `organization/workflow.ts` +- Private helpers (`buildAppSnapshot`, `assertAppOrganization`, `collectAllTaskSummaries`, etc.) → colocate with the action file that uses them + +### Files to update + +- **`foundry/packages/backend/src/services/better-auth.ts`** — update all action name references to use `betterAuth` prefix +- **`foundry/packages/backend/src/actors/organization/index.ts`** — import and spread action objects from `actions/` files instead of `app-shell.ts` + `actions.ts` +- **`foundry/packages/backend/src/actors/auth-user/index.ts`** (or `user/index.ts`) — import actions from `actions/` files + +--- + +## [ ] 11. Standardize workflow file structure across all actors + +**Dependencies:** item 4 + +**Rationale:** Workflow logic is inconsistently placed — inline in `index.ts`, in `actions.ts`, or in a `workflow/` directory. Standardize: every actor with a workflow gets a `workflow.ts` file. If the workflow is large, use `workflow/{index,...}.ts`. + +### Changes per actor + +| Actor | Current location | New location | Notes | +|---|---|---|---| +| user (auth-user) | None | `workflow.ts` (new) | Needs a workflow for mutations (item 4) | +| github-data | Inline in `index.ts` (~57 lines) | `workflow.ts` | Extract `runGithubDataWorkflow` + handler | +| history (→ audit-log) | Inline in `index.ts` (~18 lines) | `workflow.ts` | Extract `runHistoryWorkflow` + `appendHistoryRow` | +| organization | In `actions.ts` (~51 lines) | `workflow.ts` | Extract `runOrganizationWorkflow` + queue handlers | +| repository | In `actions.ts` (~42 lines) | `workflow.ts` | Extract `runRepositoryWorkflow` + queue handlers | +| task | `workflow/` directory (926 lines) | `workflow/` directory — already correct | Keep as-is: `workflow/index.ts`, `workflow/queue.ts`, `workflow/common.ts`, `workflow/init.ts`, `workflow/commands.ts`, `workflow/push.ts` | +| sandbox | None (wrapper) | N/A | No custom workflow needed | + +### Pattern + +- **Small workflows** (< ~200 lines): single `workflow.ts` file +- **Large workflows** (> ~200 lines): `workflow/index.ts` holds the main loop, other files hold step groups: + - `workflow/index.ts` — main loop + handler dispatch + - `workflow/queue.ts` — queue name definitions (if many) + - `workflow/{group}.ts` — step/activity functions grouped by domain + +### CLAUDE.md update + +- **`foundry/packages/backend/CLAUDE.md`** — add constraint: "Every actor with a message queue must have its workflow logic in a dedicated `workflow.ts` file (or `workflow/index.ts` for complex actors). Do not inline workflow logic in `index.ts` or `actions.ts`. Actions are read-only handlers; workflow handlers process queue messages and perform mutations." + +--- + +--- + +## [ ] 12. Audit and remove dead code in organization actor + +**Dependencies:** item 10 + +**Rationale:** The organization actor has ~50+ actions across `app-shell.ts` and `actions.ts`. Likely some are unused or vestigial. Audit all actions and queues for dead code and remove anything that has no callers. + +### Scope + +- All actions in `organization/actions.ts` and `organization/app-shell.ts` +- All queue message types and their handlers +- Helper functions that may no longer be called +- Shared types in `packages/shared` that only served removed actions + +### Approach + +- Trace each action/queue from caller → handler to confirm it's live +- Remove any action with no callers (client, other actors, services, HTTP endpoints) +- Remove any queue handler with no senders +- Remove associated types and helpers + +--- + +## [ ] 13. Enforce coordinator pattern and fix ownership violations + +**Rationale:** The actor hierarchy follows a coordinator pattern: org → repo → task → session. The coordinator owns the index/summary of its children, handles create/destroy, and children push updates up to their coordinator. Several violations exist where levels are skipped. + +### Coordinator hierarchy (add to CLAUDE.md) + +``` +Organization (coordinator for repos) +├── Repository (coordinator for tasks) +│ └── Task (coordinator for sessions) +│ └── Session +``` + +**Rules:** +- The coordinator owns the index/summary table for its direct children +- The coordinator handles create/destroy of its direct children +- Children push summary updates UP to their direct coordinator (not skipping levels) +- Read paths go through the coordinator, not direct cross-level access +- No backwards compatibility needed — we're cleaning up + +### Violations to fix + +#### V1: Task index tables on wrong actor (HIGH) + +`taskLookup` and `taskSummaries` (item 9 merges these into `tasks`) are on the **organization** actor but should be on the **repository** actor, since repo is the coordinator for tasks. + +**Fix:** +- Move the merged `tasks` table (from item 9) to `repository/db/schema.ts` +- Repository owns task summaries, not organization +- Organization gets a `repoSummaries` table instead (repo count, latest activity, etc.) — the repo pushes its summary up to org + +#### V2: Tasks push summaries directly to org, skipping repo (HIGH) + +Task actors call `organization.applyTaskSummaryUpdate()` directly (line 464 in `actions.ts`), bypassing the repository coordinator. + +**Fix:** +- Task pushes summary to `repository.applyTaskSummaryUpdate()` instead +- Repository updates its `tasks` table, then pushes a repo summary up to organization +- Organization never receives task-level updates directly + +#### V3: Org resolves taskId → repoId from its own table (MEDIUM) + +`resolveRepoId(c, taskId)` in `organization/actions.ts` queries `taskLookup` directly. Used by `switchTask`, `attachTask`, `pushTask`, `syncTask`, `mergeTask`, `archiveTask`, `killTask` (7 actions). + +**Fix:** +- Remove `resolveRepoId()` from org actor +- Org must know the `repoId` from the caller (frontend already knows which repo a task belongs to) or query the repo actor +- Update all 7 proxy actions to require `repoId` in their input instead of looking it up + +#### V4: Duplicate task creation bookkeeping at org level (MEDIUM) + +`createTaskMutation` in org actor calls `repository.createTask()`, then independently inserts `taskLookup` and seeds `taskSummaries`. Repository already inserts its own `taskIndex` row. + +**Fix:** +- Org calls `repository.createTask()` — that's it +- Repository handles all task index bookkeeping internally +- Repository pushes the new task summary back up to org as part of its repo summary update + +### Files to change + +- **`foundry/packages/backend/src/actors/organization/db/schema.ts`** — remove `taskLookup` and `taskSummaries`, add `repoSummaries` if needed +- **`foundry/packages/backend/src/actors/repository/db/schema.ts`** — add merged `tasks` table (task summaries) +- **`foundry/packages/backend/src/actors/organization/actions.ts`** — remove `resolveRepoId()`, `applyTaskSummaryUpdate`, `removeTaskSummary`, `findTaskForGithubBranch`, `refreshTaskSummaryForGithubBranch`; update proxy actions to require `repoId` in input +- **`foundry/packages/backend/src/actors/repository/actions.ts`** — add `applyTaskSummaryUpdate` action (receives from task), push repo summary to org +- **`foundry/packages/backend/src/actors/task/workflow/common.ts`** — change summary push target from org → repo +- **`foundry/packages/shared/src/contracts.ts`** — update input types to include `repoId` where needed +- **`foundry/packages/client/`** — update calls to pass `repoId` + +### CLAUDE.md update + +- **`foundry/packages/backend/CLAUDE.md`** — add coordinator pattern rules: + ``` + ## Coordinator Pattern + + The actor hierarchy follows a strict coordinator pattern: + - Organization = coordinator for repositories + - Repository = coordinator for tasks + - Task = coordinator for sessions + + Rules: + - Each coordinator owns the index/summary table for its direct children. + - Only the coordinator handles create/destroy of its direct children. + - Children push summary updates to their direct coordinator only (never skip levels). + - Cross-level access (e.g. org directly querying task state) is not allowed — go through the coordinator. + - Proxy actions at higher levels (e.g. org.pushTask) must delegate to the correct coordinator, not bypass it. + ``` + +--- + +--- + +## [ ] 14. Standardize one event per subscription topic across all actors + +**Dependencies:** item 15 + +**Rationale:** Each subscription topic should have exactly one event type carrying the full replacement snapshot. The organization topic currently violates this with 7 subtypes. Additionally, event naming is inconsistent across actors. Standardize all of them. + +### Current state + +| Topic | Wire event name | Event type field | Subtypes | Issue | +|---|---|---|---|---| +| `app` | `appUpdated` | `type: "appUpdated"` | 1 | Name is fine | +| `organization` | `organizationUpdated` | 7 variants | **7** | Needs consolidation | +| `task` | `taskUpdated` | `type: "taskDetailUpdated"` | 1 | Wire name ≠ type name | +| `session` | `sessionUpdated` | `type: "sessionUpdated"` | 1 | Fine | +| `sandboxProcesses` | `processesUpdated` | `type: "processesUpdated"` | 1 | Fine | + +### Target state + +Every topic gets exactly one event. Wire event name = type field = `{topic}Updated`. Each carries the full snapshot for that topic. + +| Topic | Event name | Payload | +|---|---|---| +| `app` | `appUpdated` | `FoundryAppSnapshot` | +| `organization` | `organizationUpdated` | `OrganizationSummarySnapshot` | +| `task` | `taskUpdated` | `WorkbenchTaskDetail` | +| `session` | `sessionUpdated` | `WorkbenchSessionDetail` | +| `sandboxProcesses` | `processesUpdated` | `SandboxProcessSnapshot[]` | + +### Organization — consolidate 7 subtypes into 1 + +Remove the discriminated union. Replace all 7 subtypes: +- `taskSummaryUpdated`, `taskRemoved`, `repoAdded`, `repoUpdated`, `repoRemoved`, `pullRequestUpdated`, `pullRequestRemoved` + +With a single `organizationUpdated` event carrying the full `OrganizationSummarySnapshot`. The client replaces its cached state — same pattern as every other topic. + +### Task — fix event type name mismatch + +Wire event is `taskUpdated` but the type field says `taskDetailUpdated`. Rename to `taskUpdated` everywhere for consistency. + +### Files to change + +- **`foundry/packages/shared/src/realtime-events.ts`** — replace `OrganizationEvent` union with single event type; rename `TaskEvent.type` from `taskDetailUpdated` → `taskUpdated` +- **`foundry/packages/backend/src/actors/organization/actions.ts`** — update all 7 `c.broadcast("organizationUpdated", { type: "taskSummaryUpdated", ... })` calls to emit single event with full snapshot +- **`foundry/packages/backend/src/actors/organization/app-shell.ts`** — same for any broadcasts here +- **`foundry/packages/backend/src/actors/task/workbench.ts`** — rename `taskDetailUpdated` → `taskUpdated` in broadcast calls +- **`foundry/packages/client/src/subscription/topics.ts`** — simplify `applyEvent` for organization topic (no more discriminated union handling); update task event type name +- **`foundry/packages/client/src/subscription/mock-manager.ts`** — update mock event handling +- **`foundry/packages/frontend/`** — update any direct references to event type names + +### CLAUDE.md update + +- **`foundry/packages/backend/CLAUDE.md`** — add constraint: "Each subscription topic must have exactly one event type. The event carries the full replacement snapshot for that topic — no discriminated unions, no partial patches, no subtypes. Event name must match the pattern `{topic}Updated` (e.g. `organizationUpdated`, `taskUpdated`). When state changes, broadcast the full snapshot; the client replaces its cached state." + +--- + +## [ ] 15. Unify tasks and pull requests — PRs are just task data + +**Dependencies:** items 9, 13 + +**Rationale:** From the client's perspective, tasks and PRs are the same thing — a branch with work on it. The frontend already merges them into one sorted list, converting PRs to synthetic task objects with `pr:{prId}` IDs. The distinction is artificial. A "task" should represent any branch, and the task actor lazily wraps it. PR metadata is just data the task holds. + +### Current state (separate entities) + +- **Tasks**: stored in task actor SQLite, surfaced via `WorkbenchTaskSummary`, events via `taskSummaryUpdated` +- **PRs**: stored in GitHub data actor (`githubPullRequests` table), surfaced via `WorkbenchOpenPrSummary`, events via `pullRequestUpdated`/`pullRequestRemoved` +- **Frontend hack**: converts PRs to fake task objects with `pr:{prId}` IDs, merges into one list +- **Filtering logic**: org actor silently swallows `pullRequestUpdated` if a task claims the same branch — fragile coupling +- **Two separate types**: `WorkbenchTaskSummary` and `WorkbenchOpenPrSummary` with overlapping fields + +### Target state (unified) + +- **One entity**: a "task" represents a branch. Task actors are lazily created when needed (user creates one, or a PR arrives for an unclaimed branch). +- **PR data lives on the task**: the task actor stores PR metadata (number, title, state, url, isDraft, authorLogin, etc.) as part of its state, not as a separate entity +- **One type**: `WorkbenchTaskSummary` includes full PR fields (nullable). No separate `WorkbenchOpenPrSummary`. +- **One event**: `organizationUpdated` carries task summaries that include PR data. No separate PR events. +- **No synthetic IDs**: every item in the sidebar is a real task with a real taskId + +### Changes needed + +1. **Remove `WorkbenchOpenPrSummary` type** from `packages/shared/src/workbench.ts` — merge its fields into `WorkbenchTaskSummary` +2. **Expand task's `pullRequest` field** from `{ number, status }` to full PR metadata (number, title, state, url, headRefName, baseRefName, isDraft, authorLogin, updatedAtMs) +3. **Remove `openPullRequests` from `OrganizationSummarySnapshot`** — all items are tasks now +4. **Remove PR-specific events** from `realtime-events.ts`: `pullRequestUpdated`, `pullRequestRemoved` +5. **Remove PR-specific actions** from organization actor: `applyOpenPullRequestUpdate`, `removeOpenPullRequest` +6. **Remove branch-claiming filter logic** in org actor (the `if task claims branch, skip PR` check) +7. **GitHub data actor PR sync**: when PRs arrive (webhook or sync), create/update a task for that branch lazily via the repository coordinator +8. **Task actor**: store PR metadata in its DB (new columns or table), update when GitHub data pushes changes +9. **Frontend**: remove `toOpenPrTaskModel` conversion, remove `pr:` ID prefix hack, remove separate `openPullRequests` state — sidebar is just tasks +10. **Repository actor**: when a PR arrives for a branch with no task, lazily create a task actor for it (lightweight, no sandbox needed) + +### Implications for coordinator pattern (item 13) + +This reinforces: repo is the coordinator for tasks. When GitHub data detects a new PR for a branch, it tells the repo coordinator, which creates/updates the task. The task holds the PR data and pushes its summary to the repo coordinator. + +### No backwards compatibility needed + +The `authSessionIndex`, `authEmailIndex`, `authAccountIndex`, and `authVerification` tables stay on the org actor. They're routing indexes needed by the Better Auth adapter to resolve user identity before the user actor can be accessed (e.g. session token → userId lookup). Already covered in item 2 for adding comments explaining this. + +--- + +## [ ] 16. Chunk GitHub data sync and publish progress + +**Rationale:** `runFullSync` in the github-data actor fetches everything at once (all repos, branches, members, PRs), replaces all tables atomically, and has a 5-minute timeout. For large orgs this will timeout or lose all data mid-sync (replace pattern deletes everything first). Needs to be chunked with incremental progress. + +### Current state (broken for large orgs) + +- `runFullSync()` (`github-data/index.ts` line 486-538): + 1. Fetches ALL repos, branches, members, PRs in 4 sequential calls + 2. `replaceRepositories/Branches/Members/PullRequests` — deletes all rows then inserts all new rows + 3. Single 5-minute timeout wraps the entire operation + 4. No progress reporting to the client — just "Syncing GitHub data..." → "Synced N repositories" + 5. If it fails mid-sync, data is partially deleted with no recovery + +### Changes needed + +1. **Chunk the sync by repository** — sync repos first (paginated from GitHub API), then for each repo chunk, sync its branches and PRs. Members can be a separate chunk. + +2. **Incremental upsert, not replace** — don't delete-then-insert. Use upsert per row so partial sync doesn't lose data. Mark rows with a sync generation ID; after full sync completes, delete rows from previous generations. + +3. **Run in a loop, not a single step** — each chunk is a separate workflow step with its own timeout. If one chunk fails, previous chunks are persisted. + +4. **Publish progress per chunk** — after each chunk completes: + - Update `github_meta` with progress (e.g. `syncedRepos: 15/42`) + - Push progress to the organization actor + - Organization broadcasts to clients so the UI shows progress (e.g. "Syncing repositories... 15/42") + +5. **Initial sync uses the same chunked approach** — `github-data-initial-sync` step should kick off the chunked loop, not call `runFullSync` directly + +### Files to change + +- **`foundry/packages/backend/src/actors/github-data/index.ts`**: + - Refactor `runFullSync` into chunked loop + - Replace `replaceRepositories/Branches/Members/PullRequests` with upsert + generation sweep + - Add progress metadata to `github_meta` table + - Publish progress to org actor after each chunk +- **`foundry/packages/backend/src/actors/github-data/db/schema.ts`** — add sync generation column to all tables, add progress fields to `github_meta` +- **`foundry/packages/backend/src/actors/organization/actions.ts`** (or `app-shell.ts`) — handle sync progress updates and broadcast to clients +- **`foundry/packages/shared/src/app-shell.ts`** — add sync progress fields to `FoundryGithubState` (e.g. `syncProgress: { current: number; total: number } | null`) +- **`foundry/packages/frontend/`** — show sync progress in UI (e.g. "Syncing repositories... 15/42") + +--- + +--- + +# Deferred — tackle later + +## [ ] 17. Type all actor context parameters — remove `c: any` *(DEFERRED — do last)* + +**Rationale:** 272+ instances of `c: any`, `ctx: any`, `loopCtx: any` across all actor code. This eliminates type safety for DB access, state access, broadcasts, and queue operations. All context parameters should use RivetKit's proper context types. + +### Scope (by file, approximate count) + +| File | `any` contexts | +|---|---| +| `organization/app-shell.ts` | ~108 | +| `organization/actions.ts` | ~56 | +| `task/workbench.ts` | ~53 | +| `github-data/index.ts` | ~23 | +| `repository/actions.ts` | ~22 | +| `sandbox/index.ts` | ~21 | +| `handles.ts` | ~19 | +| `task/workflow/commands.ts` | ~10 | +| `task/workflow/init.ts` | ~4 | +| `auth-user/index.ts` | ~2 | +| `history/index.ts` | ~2 | +| `task/workflow/index.ts` | ~2 | +| `task/workflow/common.ts` | ~2 | +| `task/workflow/push.ts` | ~1 | +| `polling.ts` | ~1 | + +### Changes needed + +1. **Determine correct RivetKit context types** — check RivetKit exports for `ActionContext`, `ActorContextOf`, `WorkflowContext`, `LoopContext`, or equivalent. Reference `polling.ts` which already defines typed contexts (`PollingActorContext`, `WorkflowPollingActorContext`). + +2. **Define per-actor context types** — each actor has its own state shape and DB schema, so the context type should be specific (e.g. `ActionContext` or similar). + +3. **Replace all `c: any`** with the proper typed context across every file listed above. + +4. **Type workflow/loop contexts** — `ctx: any` in workflow functions and `loopCtx: any` in loop callbacks need proper types too. + +### CLAUDE.md update + +- **`foundry/packages/backend/CLAUDE.md`** — add constraint: "All actor context parameters (`c`, `ctx`, `loopCtx`) must be properly typed using RivetKit's context types. Never use `any` for actor contexts. Each actor should define or derive its context type from the actor definition." + +--- + +## [ ] 18. Final pass: remove all dead code + +**Dependencies:** all other items (do this last, after 17) + +**Rationale:** After completing all changes above, many actions, queues, SQLite tables, workflow steps, shared types, and helper functions will be orphaned. Do a full scan to find and remove everything that's dead. + +### Scope + +Scan the entire foundry codebase for: +- **Dead actions** — actions with no callers (client, other actors, services, HTTP endpoints) +- **Dead queues** — queue message types with no senders +- **Dead SQLite tables** — tables with no reads or writes +- **Dead workflow steps** — step names that are no longer referenced +- **Dead shared types** — types in `packages/shared` that are no longer imported +- **Dead helper functions** — private functions with no callers +- **Dead imports** — unused imports across all files + +### When to do this + +After all items 1–17 are complete. Not before — removing code while other items are in progress will create conflicts. + +--- + +## [ ] 19. Remove duplicate data between `c.state` and SQLite + +**Dependencies:** items 21, 24 + +**Rationale:** Several actors store the same data in both `c.state` (RivetKit durable state) and their SQLite tables. Mutable fields that exist in both can silently diverge — `c.state` becomes stale when the SQLite copy is updated. Per the existing CLAUDE.md rule, `c.state` should hold only small scalars/identifiers; anything queryable or mutable belongs in SQLite. + +### Duplicates found + +**Task actor** — `c.state` (`createState` in `task/index.ts` lines 124-139) vs `task`/`taskRuntime` tables: + +| Field | In SQLite? | Mutable? | Verdict | +|---|---|---|---| +| `organizationId` | No | No | **KEEP** — identity field | +| `repoId` | No | No | **KEEP** — identity field | +| `taskId` | No | No | **KEEP** — identity field | +| `repoRemote` | No (but org `repos` table has it) | No | **DELETE** — not needed on task, read from repo/org | +| `branchName` | Yes (`task.branch_name`) | Yes | **REMOVE from c.state** — HIGH risk, goes stale on rename | +| `title` | Yes (`task.title`) | Yes | **REMOVE from c.state** — HIGH risk, goes stale on rename | +| `task` (description) | Yes (`task.task`) | No | **REMOVE from c.state** — redundant | +| `sandboxProviderId` | Yes (`task.sandbox_provider_id`) | No | **REMOVE from c.state** — redundant | +| `agentType` | Yes (`task.agent_type`) | Yes | **DELETE entirely** — session-specific (item 21) | +| `explicitTitle` | No | No | **MOVE to SQLite** — creation metadata | +| `explicitBranchName` | No | No | **MOVE to SQLite** — creation metadata | +| `initialPrompt` | No | No | **DELETE entirely** — dead code, session-specific (item 21) | +| `initialized` | No | Yes | **DELETE entirely** — dead code, `status` already tracks init progress | +| `previousStatus` | No | No | **DELETE entirely** — never set, never read | + +**Repository actor** — `c.state` (`createState` in `repository/index.ts`) vs `repoMeta` table: + +| Field | Mutable? | Risk | +|---|---|---| +| `remoteUrl` | No | Low — redundant but safe | + +### Fix + +Remove all duplicated fields from `c.state`. Keep only identity fields needed for actor key resolution (e.g. `organizationId`, `repoId`, `taskId`). Read mutable data from SQLite. + +**Task actor `c.state` should become:** +```typescript +createState: (_c, input) => ({ + organizationId: input.organizationId, + repoId: input.repoId, + taskId: input.taskId, +}) +``` + +Fields already in SQLite (`branchName`, `title`, `task`, `sandboxProviderId`) — remove from `c.state`, read from SQLite only. Fields not yet in SQLite (`explicitTitle`, `explicitBranchName`) — add to `task` table, remove from `c.state`. Dead code to delete entirely: `agentType`, `initialPrompt` (item 21), `initialized`, `previousStatus`, `repoRemote`. + +**Repository actor `c.state` should become:** +```typescript +createState: (_c, input) => ({ + organizationId: input.organizationId, + repoId: input.repoId, +}) +``` + +`remoteUrl` is removed from repo actor `c.state` entirely. The repo actor reads `remoteUrl` from its own `repoMeta` SQLite table when needed. The org actor already stores `remoteUrl` in its `repos` table (source of truth from GitHub data). The `getOrCreateRepository()` helper in `handles.ts` currently requires `remoteUrl` as a parameter and passes it as `createWithInput` — this parameter must be removed. Every call site in `organization/actions.ts` and `organization/app-shell.ts` currently does a DB lookup for `remoteUrl` just to pass it to `getOrCreateRepository()` — all of those lookups go away. On actor creation, the repo actor should populate its `repoMeta.remoteUrl` by querying the org actor or github-data actor, not by receiving it as a create input. + +### Files to change + +- **`foundry/packages/backend/src/actors/task/index.ts`** — trim `createState`, update all `c.state.*` reads for removed fields to read from SQLite instead +- **`foundry/packages/backend/src/actors/task/workbench.ts`** — update `c.state.*` reads +- **`foundry/packages/backend/src/actors/task/workflow/*.ts`** — update `c.state.*` reads +- **`foundry/packages/backend/src/actors/repository/index.ts`** — trim `createState`, remove `remoteUrl` from input type +- **`foundry/packages/backend/src/actors/repository/actions.ts`** — update all `c.state.remoteUrl` reads to query `repoMeta` table; remove `persistRemoteUrl()` helper +- **`foundry/packages/backend/src/actors/handles.ts`** — remove `remoteUrl` parameter from `getOrCreateRepository()` +- **`foundry/packages/backend/src/actors/organization/actions.ts`** — remove all `remoteUrl` lookups done solely to pass to `getOrCreateRepository()` (~10 call sites) +- **`foundry/packages/backend/src/actors/organization/app-shell.ts`** — same cleanup for app-shell call sites + +### CLAUDE.md update + +- **`foundry/packages/backend/CLAUDE.md`** — add constraint: "Never duplicate data between `c.state` and SQLite. `c.state` holds only immutable identity fields needed for actor key resolution (e.g. `organizationId`, `repoId`, `taskId`). All mutable data and anything queryable must live exclusively in SQLite. If a field can change after actor creation, it must not be in `c.state`." + +--- + +## [ ] 20. Prefix all admin/recovery actions with `admin` + +**Rationale:** Several actions are admin-only recovery/rebuild operations but their names don't distinguish them from normal product flows. Prefix with `admin` so it's immediately clear these are not part of regular user flows. + +### Actions to rename + +**Organization actor:** + +| Current name | New name | Why it's admin | +|---|---|---| +| `reconcileWorkbenchState` | `adminReconcileWorkbenchState` | Full fan-out rebuild of task summary projection | +| `reloadGithubOrganization` | `adminReloadGithubOrganization` | Manual trigger to refetch all org GitHub data | +| `reloadGithubPullRequests` | `adminReloadGithubPullRequests` | Manual trigger to refetch all PR data | +| `reloadGithubRepository` | `adminReloadGithubRepository` | Manual trigger to refetch single repo | +| `reloadGithubPullRequest` | `adminReloadGithubPullRequest` | Manual trigger to refetch single PR | + +**GitHub Data actor:** + +| Current name | New name | Why it's admin | +|---|---|---| +| `fullSync` | `adminFullSync` | Full replace of all GitHub data — recovery operation | +| `reloadOrganization` | `adminReloadOrganization` | Triggers full sync manually | +| `reloadAllPullRequests` | `adminReloadAllPullRequests` | Triggers full sync manually | +| `clearState` | `adminClearState` | Deletes all GitHub data — recovery from lost access | + +**NOT renamed** (these are triggered by webhooks/normal flows, not manual admin actions): +- `reloadRepository` — called by push/create/delete webhooks (incremental, normal flow) +- `reloadPullRequest` — called by PR webhooks (incremental, normal flow) +- `handlePullRequestWebhook` — webhook handler (normal flow) +- `syncGithubOrganizations` — called during OAuth callback (normal flow, though also used for repair) + +### Files to change + +- **`foundry/packages/backend/src/actors/github-data/index.ts`** — rename actions +- **`foundry/packages/backend/src/actors/organization/actions.ts`** — rename actions +- **`foundry/packages/client/src/backend-client.ts`** — update method names +- **`foundry/packages/frontend/`** — update any references to renamed actions + +### CLAUDE.md update + +- **`foundry/packages/backend/CLAUDE.md`** — add constraint: "Admin-only actions (recovery, rebuild, manual resync, state reset) must be prefixed with `admin` (e.g. `adminReconcileState`, `adminClearState`). This makes it clear they are not part of normal product flows and should not be called from regular client code paths." + +--- + +## [ ] 21. Remove legacy/session-scoped fields from task table + +**Rationale:** The `task` table has fields that either belong on the session, are redundant with data from other actors, or are dead code from the removed local git clone. These should be cleaned up. + +### Fields to remove from `task` table and `c.state` + +**`agentType`** — Legacy from when task = 1 session. Only used for `defaultModelForAgent(c.state.agentType)` to pick the default model when creating a new session. Sessions already have their own `model` column in `taskWorkbenchSessions`. The default model for new sessions should come from user settings (see item 16 — starred model stored in user actor). Remove `agentType` from task table, `c.state`, `createState`, `TaskRecord`, and all `defaultModelForAgent()` call sites. Replace with user settings lookup. + +**`initialPrompt`** — Stored on `c.state` at task creation but **never read anywhere**. Completely dead code. This is also session-specific, not task-specific — the initial prompt belongs on the first session, not the task. Remove from `c.state`, `createState` input type, and `CreateTaskCommand`/`CreateTaskInput` types. Remove from `repository/actions.ts` create flow. + +**`prSubmitted`** — Redundant boolean set when `submitPullRequest` runs. PR state already flows from GitHub webhooks → github-data actor → branch name lookup. This boolean can go stale (PR closed and reopened, PR deleted, etc.). Remove entirely — PR existence is derivable from github-data by branch name (already how `enrichTaskRecord` and `buildTaskSummary` work). + +### Dead fields on `taskRuntime` table + +**`provisionStage`** — Values: `"queued"`, `"ready"`, `"error"`. Redundant with `status` — `init_complete` implies ready, `error` implies error. Never read in business logic. Delete. + +**`provisionStageUpdatedAt`** — Timestamp for `provisionStage` changes. Never read anywhere. Delete. + +### Dead fields on `TaskRecord` (in `workflow/common.ts`) + +These are always hardcoded to `null` — remnants of the removed local git clone: + +- `diffStat` — was populated from `branches` table (deleted) +- `hasUnpushed` — was populated from `branches` table (deleted) +- `conflictsWithMain` — was populated from `branches` table (deleted) +- `parentBranch` — was populated from `branches` table (deleted) + +Remove from `TaskRecord` type, `getCurrentRecord()`, and all consumers (contracts, mock client, tests, frontend). + +### Files to change + +- **`foundry/packages/backend/src/actors/task/db/schema.ts`** — remove `agentType` and `prSubmitted` columns from `task` table; remove `provisionStage` and `provisionStageUpdatedAt` from `taskRuntime` table +- **`foundry/packages/backend/src/actors/task/index.ts`** — remove `agentType`, `initialPrompt`, `initialized`, `previousStatus`, `repoRemote` from `createState` and input type +- **`foundry/packages/backend/src/actors/task/workbench.ts`** — remove `defaultModelForAgent()`, `agentTypeForModel()`, update session creation to use user settings for default model; remove `prSubmitted` set in `submitPullRequest` +- **`foundry/packages/backend/src/actors/task/workflow/common.ts`** — remove `agentType`, `prSubmitted`, `diffStat`, `hasUnpushed`, `conflictsWithMain`, `parentBranch` from `getCurrentRecord()` and `TaskRecord` construction +- **`foundry/packages/backend/src/actors/task/workflow/init.ts`** — remove `agentType` from task row inserts +- **`foundry/packages/shared/src/contracts.ts`** — remove `agentType`, `prSubmitted`, `diffStat`, `prUrl`, `hasUnpushed`, `conflictsWithMain`, `parentBranch` from `TaskRecord` schema (note: `prUrl` and `prAuthor` should stay if still populated by `enrichTaskRecord`, or move to the unified task/PR model from item 15) +- **`foundry/packages/client/src/mock/backend-client.ts`** — update mock to remove dead fields +- **`foundry/packages/client/test/view-model.test.ts`** — update test fixtures +- **`foundry/packages/frontend/src/features/tasks/model.test.ts`** — update test fixtures +- **`foundry/packages/backend/src/actors/organization/actions.ts`** — remove any references to `agentType` in task creation input +- **`foundry/packages/backend/src/actors/repository/actions.ts`** — update `enrichTaskRecord()` to stop setting dead fields + +--- + +## [ ] 22. Move per-user UI state from task actor to user actor + +**Dependencies:** item 1 + +**Rationale:** The task actor stores UI-facing state that is user-specific, not task-global. With multiplayer (multiple users viewing the same task), this breaks — each user has their own active session, their own unread state, their own drafts. These must live on the user actor, keyed by `(taskId, sessionId)`, not on the shared task actor. + +### Per-user state currently on the task actor (wrong) + +**`taskRuntime.activeSessionId`** — Which session the user is "looking at." Used to: +- Determine which session's status drives the task-level status (running/idle) — this is wrong, the task status should reflect ALL sessions, not one user's active tab +- Return a "current" session in `attachTask` responses — this is per-user +- Migration path for legacy single-session tasks in `ensureWorkbenchSeeded` + +This should move to the user actor as `activeSessionId` per `(userId, taskId)`. + +**`taskWorkbenchSessions.unread`** — Per-user unread state stored globally on the session. If user A reads a session, user B's unread state is also cleared. Move to user actor keyed by `(userId, taskId, sessionId)`. + +**`taskWorkbenchSessions.draftText` / `draftAttachmentsJson` / `draftUpdatedAt`** — Per-user draft state stored globally. If user A starts typing a draft, it overwrites user B's draft. Move to user actor keyed by `(userId, taskId, sessionId)`. + +### What stays on the task actor (correct — task-global state) + +- `taskRuntime.activeSandboxId` — which sandbox is running (global to the task) +- `taskRuntime.activeSwitchTarget` / `activeCwd` — sandbox connection state (global) +- `taskRuntime.statusMessage` — provisioning/runtime status (global) +- `taskWorkbenchSessions.model` — which model the session uses (global) +- `taskWorkbenchSessions.status` — session runtime status (global) +- `taskWorkbenchSessions.transcriptJson` — session transcript (global) + +### Fix + +Add a `userTaskState` table to the user actor: + +```typescript +export const userTaskState = sqliteTable("user_task_state", { + taskId: text("task_id").notNull(), + sessionId: text("session_id").notNull(), + activeSessionId: text("active_session_id"), // per-user active tab + unread: integer("unread").notNull().default(0), + draftText: text("draft_text").notNull().default(""), + draftAttachmentsJson: text("draft_attachments_json").notNull().default("[]"), + draftUpdatedAt: integer("draft_updated_at"), + updatedAt: integer("updated_at").notNull(), +}, (table) => ({ + pk: primaryKey(table.taskId, table.sessionId), +})); +``` + +Remove `activeSessionId` from `taskRuntime`. Remove `unread`, `draftText`, `draftAttachmentsJson`, `draftUpdatedAt` from `taskWorkbenchSessions`. + +The task-level status should be derived from ALL sessions (e.g., task is "running" if ANY session is running), not from one user's `activeSessionId`. + +### Files to change + +- **`foundry/packages/backend/src/actors/auth-user/db/schema.ts`** — add `userTaskState` table +- **`foundry/packages/backend/src/actors/task/db/schema.ts`** — remove `activeSessionId` from `taskRuntime`; remove `unread`, `draftText`, `draftAttachmentsJson`, `draftUpdatedAt` from `taskWorkbenchSessions` +- **`foundry/packages/backend/src/actors/task/workbench.ts`** — remove all `activeSessionId` reads/writes; remove draft/unread mutation functions; task status derivation should check all sessions +- **`foundry/packages/backend/src/actors/task/workflow/common.ts`** — remove `activeSessionId` from `getCurrentRecord()` +- **`foundry/packages/backend/src/actors/task/workflow/commands.ts`** — remove `activeSessionId` references in `attachTask` +- **`foundry/packages/backend/src/actors/task/workflow/init.ts`** — remove `activeSessionId` initialization +- **`foundry/packages/client/`** — draft/unread/activeSession operations route to user actor instead of task actor +- **`foundry/packages/frontend/`** — update subscription to fetch per-user state from user actor + +### CLAUDE.md update + +- **`foundry/packages/backend/CLAUDE.md`** — add constraint: "Per-user UI state (active session tab, unread counts, draft text, draft attachments) must live on the user actor, not on shared task/session actors. Task actors hold only task-global state visible to all users. This is critical for multiplayer correctness — multiple users may view the same task simultaneously with different active sessions, unread states, and in-progress drafts." + +--- + +## [ ] 23. Delete `getTaskEnriched` and `enrichTaskRecord` (dead code) + +**Rationale:** `getTaskEnriched` is dead code with zero callers from the client. It's also the worst fan-out pattern in the codebase: org → repo actor → task actor (`.get()`) → github-data actor (`listPullRequestsForRepository` fetches ALL PRs, then `.find()`s by branch name). This is exactly the pattern the coordinator model eliminates — task detail comes from `getTaskDetail` on the task actor, sidebar data comes from materialized `taskSummaries` on the org actor. + +### What to delete + +- **`enrichTaskRecord()`** — `repository/actions.ts:117-143`. Fetches all PRs for a repo to find one by branch name. Dead code. +- **`getTaskEnriched` action** — `repository/actions.ts:432-450`. Only caller of `enrichTaskRecord`. Dead code. +- **`getTaskEnriched` org proxy** — `organization/actions.ts:838-849`. Only caller of the repo action. Dead code. +- **`GetTaskEnrichedCommand` type** — wherever defined. + +### Files to change + +- **`foundry/packages/backend/src/actors/repository/actions.ts`** — delete `enrichTaskRecord()` and `getTaskEnriched` action +- **`foundry/packages/backend/src/actors/organization/actions.ts`** — delete `getTaskEnriched` proxy action + +--- + +## [ ] 24. Clean up task status tracking + +**Dependencies:** item 21 + +**Rationale:** Task status tracking is spread across `c.state`, the `task` SQLite table, and the `taskRuntime` table with redundant and dead fields. Consolidate to a single `status` enum on the `task` table. Remove `statusMessage` — human-readable status text should be derived on the client from the `status` enum, not stored on the backend. + +### Fields to delete + +| Field | Location | Why | +|---|---|---| +| `initialized` | `c.state` | Dead code — never read. `status` already tracks init progress. | +| `previousStatus` | `c.state` | Dead code — never set, never read. | +| `statusMessage` | `taskRuntime` table | Client concern — the client should derive display text from the `status` enum. The backend should not store UI copy. | +| `provisionStage` | `taskRuntime` table | Redundant — `status` already encodes provision progress (`init_bootstrap_db` → `init_enqueue_provision` → `init_complete`). | +| `provisionStageUpdatedAt` | `taskRuntime` table | Dead — never read. | + +### What remains + +- **`status`** on the `task` table — the single canonical state machine enum. Values: `init_bootstrap_db`, `init_enqueue_provision`, `init_complete`, `running`, `idle`, `error`, `archive_*`, `kill_*`, `archived`, `killed`. + +### Files to change + +- **`foundry/packages/backend/src/actors/task/db/schema.ts`** — remove `statusMessage`, `provisionStage`, `provisionStageUpdatedAt` from `taskRuntime` table +- **`foundry/packages/backend/src/actors/task/index.ts`** — remove `initialized`, `previousStatus` from `createState` +- **`foundry/packages/backend/src/actors/task/workflow/common.ts`** — remove `statusMessage` parameter from `setTaskState()`, remove it from `getCurrentRecord()` query +- **`foundry/packages/backend/src/actors/task/workflow/init.ts`** — remove `statusMessage`, `provisionStage`, `provisionStageUpdatedAt` from taskRuntime inserts/updates; remove `ensureTaskRuntimeCacheColumns()` raw ALTER TABLE for these columns +- **`foundry/packages/backend/src/actors/task/workflow/commands.ts`** — remove `statusMessage` from handler updates +- **`foundry/packages/backend/src/actors/task/workflow/push.ts`** — remove `statusMessage` updates +- **`foundry/packages/backend/src/actors/task/workbench.ts`** — remove `statusMessage` from `buildTaskDetail()`, remove `ensureTaskRuntimeCacheColumns()` for these columns +- **`foundry/packages/shared/src/workbench.ts`** — remove `statusMessage` from `WorkbenchTaskDetail` +- **`foundry/packages/frontend/`** — derive display text from `status` enum instead of reading `statusMessage` + +--- + +## [ ] 25. Remove "Workbench" prefix from all types, functions, files, and tables + +**Rationale:** "Workbench" is not a real concept in the system. It's a namespace prefix applied to every type, function, file, and table name. The actual entities are Task, Session, Repository, Sandbox, Transcript, Draft, etc. — "Workbench" adds zero information and obscures what things actually are. + +### Rename strategy + +Drop "Workbench" everywhere. If the result collides with an existing name (e.g., auth `Session`), use the domain prefix (e.g., `TaskSession` vs auth `Session`). + +### Type renames (`shared/src/workbench.ts`) + +| Before | After | +|---|---| +| `WorkbenchTaskStatus` | `TaskStatus` (already exists as base, merge) | +| `WorkbenchAgentKind` | `AgentKind` | +| `WorkbenchModelId` | `ModelId` | +| `WorkbenchSessionStatus` | `SessionStatus` | +| `WorkbenchTranscriptEvent` | `TranscriptEvent` | +| `WorkbenchComposerDraft` | `ComposerDraft` | +| `WorkbenchSessionSummary` | `SessionSummary` | +| `WorkbenchSessionDetail` | `SessionDetail` | +| `WorkbenchFileChange` | `FileChange` | +| `WorkbenchFileTreeNode` | `FileTreeNode` | +| `WorkbenchLineAttachment` | `LineAttachment` | +| `WorkbenchHistoryEvent` | `HistoryEvent` | +| `WorkbenchDiffLineKind` | `DiffLineKind` | +| `WorkbenchParsedDiffLine` | `ParsedDiffLine` | +| `WorkbenchPullRequestSummary` | `PullRequestSummary` | +| `WorkbenchOpenPrSummary` | `OpenPrSummary` | +| `WorkbenchSandboxSummary` | `SandboxSummary` | +| `WorkbenchTaskSummary` | `TaskSummary` | +| `WorkbenchTaskDetail` | `TaskDetail` | +| `WorkbenchRepositorySummary` | `RepositorySummary` | +| `WorkbenchSession` | `TaskSession` (avoids auth `Session` collision) | +| `WorkbenchTask` | `TaskSnapshot` (avoids `task` table collision) | +| `WorkbenchRepo` | `RepoSnapshot` | +| `WorkbenchRepositorySection` | `RepositorySection` | +| `TaskWorkbenchSnapshot` | `DashboardSnapshot` | +| `WorkbenchModelOption` | `ModelOption` | +| `WorkbenchModelGroup` | `ModelGroup` | +| `TaskWorkbenchSelectInput` | `SelectTaskInput` | +| `TaskWorkbenchCreateTaskInput` | `CreateTaskInput` | +| `TaskWorkbenchRenameInput` | `RenameTaskInput` | +| `TaskWorkbenchSendMessageInput` | `SendMessageInput` | +| `TaskWorkbenchSessionInput` | `SessionInput` | +| `TaskWorkbenchRenameSessionInput` | `RenameSessionInput` | +| `TaskWorkbenchChangeModelInput` | `ChangeModelInput` | +| `TaskWorkbenchUpdateDraftInput` | `UpdateDraftInput` | +| `TaskWorkbenchSetSessionUnreadInput` | `SetSessionUnreadInput` | +| `TaskWorkbenchDiffInput` | `DiffInput` | +| `TaskWorkbenchCreateTaskResponse` | `CreateTaskResponse` | +| `TaskWorkbenchAddSessionResponse` | `AddSessionResponse` | + +### File renames + +| Before | After | +|---|---| +| `shared/src/workbench.ts` | `shared/src/types.ts` (or split into `task.ts`, `session.ts`, etc.) | +| `backend/src/actors/task/workbench.ts` | `backend/src/actors/task/sessions.ts` (already planned in item 7) | +| `client/src/workbench-client.ts` | `client/src/task-client.ts` | +| `client/src/workbench-model.ts` | `client/src/model.ts` | +| `client/src/remote/workbench-client.ts` | `client/src/remote/task-client.ts` | +| `client/src/mock/workbench-client.ts` | `client/src/mock/task-client.ts` | + +### Table rename + +| Before | After | +|---|---| +| `task_workbench_sessions` | `task_sessions` | + +### Function renames (backend — drop "Workbench" infix) + +All functions in `backend/src/actors/task/workbench.ts`: +- `createWorkbenchSession` → `createSession` +- `closeWorkbenchSession` → `closeSession` +- `changeWorkbenchModel` → `changeModel` +- `sendWorkbenchMessage` → `sendMessage` +- `stopWorkbenchSession` → `stopSession` +- `renameWorkbenchBranch` → deleted (see item 26) +- `renameWorkbenchTask` → `renameTask` +- `renameWorkbenchSession` → `renameSession` +- `revertWorkbenchFile` → `revertFile` +- `publishWorkbenchPr` → `publishPr` +- `updateWorkbenchDraft` → `updateDraft` +- `setWorkbenchSessionUnread` → `setSessionUnread` +- `markWorkbenchUnread` → `markUnread` +- `syncWorkbenchSessionStatus` → `syncSessionStatus` +- `ensureWorkbenchSeeded` → `ensureSessionSeeded` + +### Queue/command type renames (backend) + +- `TaskWorkbenchValueCommand` → `TaskValueCommand` +- `TaskWorkbenchSessionTitleCommand` → `SessionTitleCommand` +- `TaskWorkbenchSessionUnreadCommand` → `SessionUnreadCommand` + +### Scope + +~420 occurrences across shared (35+ types), backend (200+ refs), client (324 refs), frontend (96 refs). Mechanical find-and-replace once the rename map is settled. + +### Files to change + +- **`foundry/packages/shared/src/workbench.ts`** — rename file, rename all exported types +- **`foundry/packages/shared/src/index.ts`** — update re-export path +- **`foundry/packages/shared/src/app-shell.ts`** — update `WorkbenchModelId` → `ModelId` import +- **`foundry/packages/shared/src/realtime-events.ts`** — update all `Workbench*` type imports +- **`foundry/packages/backend/src/actors/task/workbench.ts`** — rename file + all functions +- **`foundry/packages/backend/src/actors/task/index.ts`** — update imports and action registrations +- **`foundry/packages/backend/src/actors/task/db/schema.ts`** — rename `taskWorkbenchSessions` → `taskSessions` +- **`foundry/packages/backend/src/actors/task/workflow/`** — update all workbench references +- **`foundry/packages/backend/src/actors/organization/`** — update type imports and action names +- **`foundry/packages/backend/src/actors/repository/`** — update type imports +- **`foundry/packages/client/src/`** — rename files + update all type/function references +- **`foundry/packages/frontend/src/`** — update all type imports + +### CLAUDE.md update + +Update `foundry/packages/backend/CLAUDE.md` coordinator hierarchy diagram: `taskWorkbenchSessions` → `taskSessions`. + +--- + +## [ ] 26. Delete branch rename (branches immutable after creation) + +**Dependencies:** item 25 + +**Rationale:** Branch name is assigned once at task creation and never changes. Branch rename is unused in the frontend UI and SDK, adds ~80 lines of code, and creates a transactional consistency risk (git rename succeeds but index update fails). + +### Delete + +- **`task/workbench.ts`** — delete `renameWorkbenchBranch()` (~50 lines) +- **`task/index.ts`** — delete `renameWorkbenchBranch` action +- **`task/workflow/queue.ts`** — remove `"task.command.workbench.rename_branch"` queue type +- **`task/workflow/index.ts`** — remove `"task.command.workbench.rename_branch"` handler +- **`organization/actions.ts`** — delete `renameWorkbenchBranch` proxy action +- **`repository/actions.ts`** — delete `registerTaskBranch` action (only caller was rename flow) +- **`client/src/workbench-client.ts`** — remove `renameBranch` from interface +- **`client/src/remote/workbench-client.ts`** — delete `renameBranch()` method +- **`client/src/mock/workbench-client.ts`** — delete `renameBranch()` method +- **`client/src/backend-client.ts`** — delete `renameWorkbenchBranch` from interface + implementation +- **`client/src/mock/backend-client.ts`** — delete `renameWorkbenchBranch` implementation +- **`frontend/src/components/mock-layout.tsx`** — remove `renameBranch` from client interface, delete `onRenameBranch` callbacks and all `renameBranch` wiring (~8 refs) +- **`shared/src/workbench.ts`** — delete `TaskWorkbenchRenameInput` (if only used by branch rename; check if task title rename shares it) + +### Keep + +- `deriveFallbackTitle()` + `sanitizeBranchName()` + `resolveCreateFlowDecision()` — initial branch derivation at creation +- `registerTaskBranchMutation()` — used during task creation for `onBranch` path +- `renameWorkbenchTask()` — title rename is independent, stays +- `taskIndex` table — still the coordinator index for branch→task mapping + +--- + +## [ ] Final audit pass (run after all items above are complete) + +### Dead code scan + +Already tracked in item 18: once all changes are complete, do a full scan to find dead actions, queues, SQLite tables, and workflow steps that need to be removed. + +### Dead events audit + +Scan all event types emitted by actors (in `packages/shared/src/realtime-events.ts` and anywhere actors call `c.broadcast()` or similar). Cross-reference against all client subscribers (in `packages/client/` and `packages/frontend/`). Remove any events that are emitted but never subscribed to by any client. This includes events that may have been superseded by the consolidated single-topic-per-actor pattern (item 14). diff --git a/foundry/packages/backend/CLAUDE.md b/foundry/packages/backend/CLAUDE.md index 432bc85..f75161a 100644 --- a/foundry/packages/backend/CLAUDE.md +++ b/foundry/packages/backend/CLAUDE.md @@ -6,13 +6,13 @@ Keep the backend actor tree aligned with this shape unless we explicitly decide ```text OrganizationActor -├─ HistoryActor(organization-scoped global feed) +├─ AuditLogActor(organization-scoped global feed) ├─ GithubDataActor ├─ RepositoryActor(repo) │ └─ TaskActor(task) │ ├─ TaskSessionActor(session) × N │ │ └─ SessionStatusSyncActor(session) × 0..1 -│ └─ Task-local workbench state +│ └─ Task-local workspace state └─ SandboxInstanceActor(sandboxProviderId, sandboxId) × N ``` @@ -46,12 +46,12 @@ OrganizationActor (coordinator for repos + auth users) │ └─ TaskActor (coordinator for sessions + sandboxes) │ │ │ │ Index tables: -│ │ ├─ taskWorkbenchSessions → Session index (session metadata, transcript, draft) +│ │ ├─ taskWorkspaceSessions → Session index (session metadata, transcript, draft) │ │ └─ taskSandboxes → SandboxInstanceActor index (sandbox history) │ │ │ └─ SandboxInstanceActor (leaf) │ -├─ HistoryActor (organization-scoped audit log, not a coordinator) +├─ AuditLogActor (organization-scoped audit log, not a coordinator) └─ GithubDataActor (GitHub API cache, not a coordinator) ``` @@ -60,13 +60,13 @@ When adding a new index table, annotate it in the schema file with a doc comment ## Ownership Rules - `OrganizationActor` is the organization coordinator and lookup/index owner. -- `HistoryActor` is organization-scoped. There is one organization-level history feed. +- `AuditLogActor` is organization-scoped. There is one organization-level audit log feed. - `RepositoryActor` is the repo coordinator and owns repo-local caches/indexes. - `TaskActor` is one branch. Treat `1 task = 1 branch` once branch assignment is finalized. - `TaskActor` can have many sessions. - `TaskActor` can reference many sandbox instances historically, but should have only one active sandbox/session at a time. -- Session unread state and draft prompts are backend-owned workbench state, not frontend-local state. -- Branch rename is a real git operation, not just metadata. +- Session unread state and draft prompts are backend-owned workspace state, not frontend-local state. +- Branch names are immutable after task creation. Do not implement branch-rename flows. - `SandboxInstanceActor` stays separate from `TaskActor`; tasks/sessions reference it by identity. - The backend stores no local git state. No clones, no refs, no working trees, and no git-spice. Repository metadata comes from GitHub API data and webhook events. Any working-tree git operation runs inside a sandbox via `executeInSandbox()`. - 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. @@ -75,6 +75,11 @@ When adding a new index table, annotate it in the schema file with a doc comment - Read paths must use the coordinator's local index tables. Do not fan out to child actors on the hot read path. - Never build "enriched" read actions that chain through multiple actors (e.g., coordinator → child actor → sibling actor). If data from multiple actors is needed for a read, it should already be materialized in the coordinator's index tables via push updates. If it's not there, fix the write path to push it — do not add a fan-out read path. +## SQLite Constraints + +- Single-row tables must use an integer primary key with `CHECK (id = 1)` to enforce the singleton invariant at the database level. +- Follow the task actor pattern for metadata/profile rows and keep the fixed row id in code as `1`, not a string sentinel. + ## Multiplayer Correctness Per-user UI state must live on the user actor, not on shared task/session actors. This is critical for multiplayer — multiple users may view the same task simultaneously with different active sessions, unread states, and in-progress drafts. @@ -85,6 +90,10 @@ Per-user UI state must live on the user actor, not on shared task/session actors Do not store per-user preferences, selections, or ephemeral UI state on shared actors. If a field's value should differ between two users looking at the same task, it belongs on the user actor. +## Audit Log Maintenance + +Every new action or command handler that represents a user-visible or workflow-significant event must append to the audit log actor. The audit log must remain a comprehensive record of significant operations. + ## Maintenance - Keep this file up to date whenever actor ownership, hierarchy, or lifecycle responsibilities change. diff --git a/foundry/packages/backend/src/actors/auth-user/db/db.ts b/foundry/packages/backend/src/actors/audit-log/db/db.ts similarity index 69% rename from foundry/packages/backend/src/actors/auth-user/db/db.ts rename to foundry/packages/backend/src/actors/audit-log/db/db.ts index b434338..d808ec0 100644 --- a/foundry/packages/backend/src/actors/auth-user/db/db.ts +++ b/foundry/packages/backend/src/actors/audit-log/db/db.ts @@ -2,4 +2,4 @@ import { db } from "rivetkit/db/drizzle"; import * as schema from "./schema.js"; import migrations from "./migrations.js"; -export const authUserDb = db({ schema, migrations }); +export const auditLogDb = db({ schema, migrations }); diff --git a/foundry/packages/backend/src/actors/audit-log/db/drizzle.config.ts b/foundry/packages/backend/src/actors/audit-log/db/drizzle.config.ts new file mode 100644 index 0000000..da5e904 --- /dev/null +++ b/foundry/packages/backend/src/actors/audit-log/db/drizzle.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from "rivetkit/db/drizzle"; + +export default defineConfig({ + out: "./src/actors/audit-log/db/drizzle", + schema: "./src/actors/audit-log/db/schema.ts", +}); diff --git a/foundry/packages/backend/src/actors/history/db/drizzle/0000_fluffy_kid_colt.sql b/foundry/packages/backend/src/actors/audit-log/db/drizzle/0000_fluffy_kid_colt.sql similarity index 100% rename from foundry/packages/backend/src/actors/history/db/drizzle/0000_fluffy_kid_colt.sql rename to foundry/packages/backend/src/actors/audit-log/db/drizzle/0000_fluffy_kid_colt.sql diff --git a/foundry/packages/backend/src/actors/history/db/drizzle/meta/0000_snapshot.json b/foundry/packages/backend/src/actors/audit-log/db/drizzle/meta/0000_snapshot.json similarity index 100% rename from foundry/packages/backend/src/actors/history/db/drizzle/meta/0000_snapshot.json rename to foundry/packages/backend/src/actors/audit-log/db/drizzle/meta/0000_snapshot.json diff --git a/foundry/packages/backend/src/actors/history/db/drizzle/meta/_journal.json b/foundry/packages/backend/src/actors/audit-log/db/drizzle/meta/_journal.json similarity index 100% rename from foundry/packages/backend/src/actors/history/db/drizzle/meta/_journal.json rename to foundry/packages/backend/src/actors/audit-log/db/drizzle/meta/_journal.json diff --git a/foundry/packages/backend/src/actors/history/db/migrations.ts b/foundry/packages/backend/src/actors/audit-log/db/migrations.ts similarity index 100% rename from foundry/packages/backend/src/actors/history/db/migrations.ts rename to foundry/packages/backend/src/actors/audit-log/db/migrations.ts diff --git a/foundry/packages/backend/src/actors/history/db/schema.ts b/foundry/packages/backend/src/actors/audit-log/db/schema.ts similarity index 82% rename from foundry/packages/backend/src/actors/history/db/schema.ts rename to foundry/packages/backend/src/actors/audit-log/db/schema.ts index 80eb7f4..7ba2fa8 100644 --- a/foundry/packages/backend/src/actors/history/db/schema.ts +++ b/foundry/packages/backend/src/actors/audit-log/db/schema.ts @@ -5,7 +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. + // Structured by the audit-log event kind definitions in application code. payloadJson: text("payload_json").notNull(), createdAt: integer("created_at").notNull(), }); diff --git a/foundry/packages/backend/src/actors/history/index.ts b/foundry/packages/backend/src/actors/audit-log/index.ts similarity index 59% rename from foundry/packages/backend/src/actors/history/index.ts rename to foundry/packages/backend/src/actors/audit-log/index.ts index fa1373b..7f1715d 100644 --- a/foundry/packages/backend/src/actors/history/index.ts +++ b/foundry/packages/backend/src/actors/audit-log/index.ts @@ -2,32 +2,31 @@ import { and, desc, eq } from "drizzle-orm"; import { actor, queue } from "rivetkit"; import { Loop, workflow } from "rivetkit/workflow"; -import type { HistoryEvent } from "@sandbox-agent/foundry-shared"; -import { selfHistory } from "../handles.js"; -import { historyDb } from "./db/db.js"; +import type { AuditLogEvent } from "@sandbox-agent/foundry-shared"; +import { auditLogDb } from "./db/db.js"; import { events } from "./db/schema.js"; -export interface HistoryInput { +export interface AuditLogInput { organizationId: string; repoId: string; } -export interface AppendHistoryCommand { +export interface AppendAuditLogCommand { kind: string; taskId?: string; branchName?: string; payload: Record; } -export interface ListHistoryParams { +export interface ListAuditLogParams { branch?: string; taskId?: string; limit?: number; } -const HISTORY_QUEUE_NAMES = ["history.command.append"] as const; +export const AUDIT_LOG_QUEUE_NAMES = ["auditLog.command.append"] as const; -async function appendHistoryRow(loopCtx: any, body: AppendHistoryCommand): Promise { +async function appendAuditLogRow(loopCtx: any, body: AppendAuditLogCommand): Promise { const now = Date.now(); await loopCtx.db .insert(events) @@ -41,18 +40,18 @@ async function appendHistoryRow(loopCtx: any, body: AppendHistoryCommand): Promi .run(); } -async function runHistoryWorkflow(ctx: any): Promise { - await ctx.loop("history-command-loop", async (loopCtx: any) => { - const msg = await loopCtx.queue.next("next-history-command", { - names: [...HISTORY_QUEUE_NAMES], +async function runAuditLogWorkflow(ctx: any): Promise { + await ctx.loop("audit-log-command-loop", async (loopCtx: any) => { + const msg = await loopCtx.queue.next("next-audit-log-command", { + names: [...AUDIT_LOG_QUEUE_NAMES], completable: true, }); if (!msg) { return Loop.continue(undefined); } - if (msg.name === "history.command.append") { - await loopCtx.step("append-history-row", async () => appendHistoryRow(loopCtx, msg.body as AppendHistoryCommand)); + if (msg.name === "auditLog.command.append") { + await loopCtx.step("append-audit-log-row", async () => appendAuditLogRow(loopCtx, msg.body as AppendAuditLogCommand)); await msg.complete({ ok: true }); } @@ -60,26 +59,21 @@ async function runHistoryWorkflow(ctx: any): Promise { }); } -export const history = actor({ - db: historyDb, +export const auditLog = actor({ + db: auditLogDb, queues: { - "history.command.append": queue(), + "auditLog.command.append": queue(), }, options: { - name: "History", + name: "Audit Log", icon: "database", }, - createState: (_c, input: HistoryInput) => ({ + createState: (_c, input: AuditLogInput) => ({ organizationId: input.organizationId, repoId: input.repoId, }), actions: { - async append(c, command: AppendHistoryCommand): Promise { - const self = selfHistory(c); - await self.send("history.command.append", command, { wait: true, timeout: 15_000 }); - }, - - async list(c, params?: ListHistoryParams): Promise { + async list(c, params?: ListAuditLogParams): Promise { const whereParts = []; if (params?.taskId) { whereParts.push(eq(events.taskId, params.taskId)); @@ -111,5 +105,5 @@ export const history = actor({ })); }, }, - run: workflow(runHistoryWorkflow), + run: workflow(runAuditLogWorkflow), }); diff --git a/foundry/packages/backend/src/actors/auth-user/db/schema.ts b/foundry/packages/backend/src/actors/auth-user/db/schema.ts deleted file mode 100644 index b87567a..0000000 --- a/foundry/packages/backend/src/actors/auth-user/db/schema.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { integer, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"; - -export const authUsers = sqliteTable("user", { - id: text("id").notNull().primaryKey(), - name: text("name").notNull(), - email: text("email").notNull(), - emailVerified: integer("email_verified").notNull(), - image: text("image"), - createdAt: integer("created_at").notNull(), - updatedAt: integer("updated_at").notNull(), -}); - -export const authSessions = sqliteTable( - "session", - { - id: text("id").notNull().primaryKey(), - token: text("token").notNull(), - userId: text("user_id").notNull(), - expiresAt: integer("expires_at").notNull(), - ipAddress: text("ip_address"), - userAgent: text("user_agent"), - createdAt: integer("created_at").notNull(), - updatedAt: integer("updated_at").notNull(), - }, - (table) => ({ - tokenIdx: uniqueIndex("session_token_idx").on(table.token), - }), -); - -export const authAccounts = sqliteTable( - "account", - { - id: text("id").notNull().primaryKey(), - accountId: text("account_id").notNull(), - providerId: text("provider_id").notNull(), - userId: text("user_id").notNull(), - accessToken: text("access_token"), - refreshToken: text("refresh_token"), - idToken: text("id_token"), - accessTokenExpiresAt: integer("access_token_expires_at"), - refreshTokenExpiresAt: integer("refresh_token_expires_at"), - scope: text("scope"), - password: text("password"), - createdAt: integer("created_at").notNull(), - updatedAt: integer("updated_at").notNull(), - }, - (table) => ({ - providerAccountIdx: uniqueIndex("account_provider_account_idx").on(table.providerId, table.accountId), - }), -); - -export const userProfiles = sqliteTable("user_profiles", { - userId: text("user_id").notNull().primaryKey(), - githubAccountId: text("github_account_id"), - githubLogin: text("github_login"), - roleLabel: text("role_label").notNull(), - eligibleOrganizationIdsJson: text("eligible_organization_ids_json").notNull(), - starterRepoStatus: text("starter_repo_status").notNull(), - starterRepoStarredAt: integer("starter_repo_starred_at"), - starterRepoSkippedAt: integer("starter_repo_skipped_at"), - createdAt: integer("created_at").notNull(), - updatedAt: integer("updated_at").notNull(), -}); - -export const sessionState = sqliteTable("session_state", { - sessionId: text("session_id").notNull().primaryKey(), - activeOrganizationId: text("active_organization_id"), - createdAt: integer("created_at").notNull(), - updatedAt: integer("updated_at").notNull(), -}); diff --git a/foundry/packages/backend/src/actors/github-data/db/migrations.ts b/foundry/packages/backend/src/actors/github-data/db/migrations.ts index 87cc76f..243a181 100644 --- a/foundry/packages/backend/src/actors/github-data/db/migrations.ts +++ b/foundry/packages/backend/src/actors/github-data/db/migrations.ts @@ -32,7 +32,8 @@ export default { \`installation_id\` integer, \`last_sync_label\` text NOT NULL, \`last_sync_at\` integer, - \`updated_at\` integer NOT NULL + \`updated_at\` integer NOT NULL, + CONSTRAINT \`github_meta_singleton_id_check\` CHECK(\`id\` = 1) ); --> statement-breakpoint CREATE TABLE \`github_repositories\` ( diff --git a/foundry/packages/backend/src/actors/github-data/db/schema.ts b/foundry/packages/backend/src/actors/github-data/db/schema.ts index fe37863..b2cc52c 100644 --- a/foundry/packages/backend/src/actors/github-data/db/schema.ts +++ b/foundry/packages/backend/src/actors/github-data/db/schema.ts @@ -1,15 +1,20 @@ -import { integer, sqliteTable, text } from "rivetkit/db/drizzle"; +import { check, integer, sqliteTable, text } from "rivetkit/db/drizzle"; +import { sql } from "drizzle-orm"; -export const githubMeta = sqliteTable("github_meta", { - id: integer("id").primaryKey(), - connectedAccount: text("connected_account").notNull(), - installationStatus: text("installation_status").notNull(), - syncStatus: text("sync_status").notNull(), - installationId: integer("installation_id"), - lastSyncLabel: text("last_sync_label").notNull(), - lastSyncAt: integer("last_sync_at"), - updatedAt: integer("updated_at").notNull(), -}); +export const githubMeta = sqliteTable( + "github_meta", + { + id: integer("id").primaryKey(), + connectedAccount: text("connected_account").notNull(), + installationStatus: text("installation_status").notNull(), + syncStatus: text("sync_status").notNull(), + installationId: integer("installation_id"), + lastSyncLabel: text("last_sync_label").notNull(), + lastSyncAt: integer("last_sync_at"), + updatedAt: integer("updated_at").notNull(), + }, + (table) => [check("github_meta_singleton_id_check", sql`${table.id} = 1`)], +); export const githubRepositories = sqliteTable("github_repositories", { repoId: text("repo_id").notNull().primaryKey(), diff --git a/foundry/packages/backend/src/actors/github-data/index.ts b/foundry/packages/backend/src/actors/github-data/index.ts index 08c815d..5e26cd3 100644 --- a/foundry/packages/backend/src/actors/github-data/index.ts +++ b/foundry/packages/backend/src/actors/github-data/index.ts @@ -681,15 +681,15 @@ export const githubData = actor({ }; }, - async fullSync(c, input: FullSyncInput = {}) { + async adminFullSync(c, input: FullSyncInput = {}) { return await runFullSync(c, input); }, - async reloadOrganization(c) { + async adminReloadOrganization(c) { return await runFullSync(c, { label: "Reloading GitHub organization..." }); }, - async reloadAllPullRequests(c) { + async adminReloadAllPullRequests(c) { return await runFullSync(c, { label: "Reloading GitHub pull requests..." }); }, @@ -846,7 +846,7 @@ export const githubData = actor({ ); }, - async clearState(c, input: ClearStateInput) { + async adminClearState(c, input: ClearStateInput) { const beforeRows = await readAllPullRequestRows(c); await c.db.delete(githubPullRequests).run(); await c.db.delete(githubBranches).run(); diff --git a/foundry/packages/backend/src/actors/handles.ts b/foundry/packages/backend/src/actors/handles.ts index bd17fb0..b7576a2 100644 --- a/foundry/packages/backend/src/actors/handles.ts +++ b/foundry/packages/backend/src/actors/handles.ts @@ -1,4 +1,4 @@ -import { authUserKey, githubDataKey, historyKey, organizationKey, repositoryKey, taskKey, taskSandboxKey } from "./keys.js"; +import { auditLogKey, githubDataKey, organizationKey, repositoryKey, taskKey, taskSandboxKey, userKey } from "./keys.js"; export function actorClient(c: any) { return c.client(); @@ -10,14 +10,14 @@ export async function getOrCreateOrganization(c: any, organizationId: string) { }); } -export async function getOrCreateAuthUser(c: any, userId: string) { - return await actorClient(c).authUser.getOrCreate(authUserKey(userId), { +export async function getOrCreateUser(c: any, userId: string) { + return await actorClient(c).user.getOrCreate(userKey(userId), { createWithInput: { userId }, }); } -export function getAuthUser(c: any, userId: string) { - return actorClient(c).authUser.get(authUserKey(userId)); +export function getUser(c: any, userId: string) { + return actorClient(c).user.get(userKey(userId)); } export async function getOrCreateRepository(c: any, organizationId: string, repoId: string, remoteUrl: string) { @@ -44,8 +44,8 @@ export async function getOrCreateTask(c: any, organizationId: string, repoId: st }); } -export async function getOrCreateHistory(c: any, organizationId: string, repoId: string) { - return await actorClient(c).history.getOrCreate(historyKey(organizationId, repoId), { +export async function getOrCreateAuditLog(c: any, organizationId: string, repoId: string) { + return await actorClient(c).auditLog.getOrCreate(auditLogKey(organizationId, repoId), { createWithInput: { organizationId, repoId, @@ -75,8 +75,8 @@ export async function getOrCreateTaskSandbox(c: any, organizationId: string, san }); } -export function selfHistory(c: any) { - return actorClient(c).history.getForId(c.actorId); +export function selfAuditLog(c: any) { + return actorClient(c).auditLog.getForId(c.actorId); } export function selfTask(c: any) { @@ -91,8 +91,8 @@ export function selfRepository(c: any) { return actorClient(c).repository.getForId(c.actorId); } -export function selfAuthUser(c: any) { - return actorClient(c).authUser.getForId(c.actorId); +export function selfUser(c: any) { + return actorClient(c).user.getForId(c.actorId); } export function selfGithubData(c: any) { diff --git a/foundry/packages/backend/src/actors/history/db/drizzle.config.ts b/foundry/packages/backend/src/actors/history/db/drizzle.config.ts deleted file mode 100644 index 3b1d8bd..0000000 --- a/foundry/packages/backend/src/actors/history/db/drizzle.config.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { defineConfig } from "rivetkit/db/drizzle"; - -export default defineConfig({ - out: "./src/actors/history/db/drizzle", - schema: "./src/actors/history/db/schema.ts", -}); diff --git a/foundry/packages/backend/src/actors/index.ts b/foundry/packages/backend/src/actors/index.ts index 2f9e566..de78db0 100644 --- a/foundry/packages/backend/src/actors/index.ts +++ b/foundry/packages/backend/src/actors/index.ts @@ -1,8 +1,8 @@ -import { authUser } from "./auth-user/index.js"; +import { user } from "./user/index.js"; import { setup } from "rivetkit"; import { githubData } from "./github-data/index.js"; import { task } from "./task/index.js"; -import { history } from "./history/index.js"; +import { auditLog } from "./audit-log/index.js"; import { repository } from "./repository/index.js"; import { taskSandbox } from "./sandbox/index.js"; import { organization } from "./organization/index.js"; @@ -21,22 +21,22 @@ export const registry = setup({ baseLogger: logger, }, use: { - authUser, + user, organization, repository, task, taskSandbox, - history, + auditLog, githubData, }, }); export * from "./context.js"; export * from "./events.js"; -export * from "./auth-user/index.js"; +export * from "./audit-log/index.js"; +export * from "./user/index.js"; export * from "./github-data/index.js"; export * from "./task/index.js"; -export * from "./history/index.js"; export * from "./keys.js"; export * from "./repository/index.js"; export * from "./sandbox/index.js"; diff --git a/foundry/packages/backend/src/actors/keys.ts b/foundry/packages/backend/src/actors/keys.ts index 59e669e..537f3b2 100644 --- a/foundry/packages/backend/src/actors/keys.ts +++ b/foundry/packages/backend/src/actors/keys.ts @@ -4,7 +4,7 @@ export function organizationKey(organizationId: string): ActorKey { return ["org", organizationId]; } -export function authUserKey(userId: string): ActorKey { +export function userKey(userId: string): ActorKey { return ["org", "app", "user", userId]; } @@ -20,8 +20,8 @@ export function taskSandboxKey(organizationId: string, sandboxId: string): Actor return ["org", organizationId, "sandbox", sandboxId]; } -export function historyKey(organizationId: string, repoId: string): ActorKey { - return ["org", organizationId, "repository", repoId, "history"]; +export function auditLogKey(organizationId: string, repoId: string): ActorKey { + return ["org", organizationId, "repository", repoId, "audit-log"]; } export function githubDataKey(organizationId: string): ActorKey { diff --git a/foundry/packages/backend/src/actors/organization/actions.ts b/foundry/packages/backend/src/actors/organization/actions.ts index 70da62b..a66a49a 100644 --- a/foundry/packages/backend/src/actors/organization/actions.ts +++ b/foundry/packages/backend/src/actors/organization/actions.ts @@ -3,7 +3,7 @@ import { desc, eq } from "drizzle-orm"; import { Loop } from "rivetkit/workflow"; import type { CreateTaskInput, - HistoryEvent, + AuditLogEvent, HistoryQueryInput, ListTasksInput, SandboxProviderId, @@ -14,32 +14,30 @@ import type { SwitchResult, TaskRecord, TaskSummary, - TaskWorkbenchChangeModelInput, - TaskWorkbenchCreateTaskInput, - TaskWorkbenchDiffInput, - TaskWorkbenchRenameInput, - TaskWorkbenchRenameSessionInput, - TaskWorkbenchSelectInput, - TaskWorkbenchSetSessionUnreadInput, - TaskWorkbenchSendMessageInput, - TaskWorkbenchSessionInput, - TaskWorkbenchUpdateDraftInput, - WorkbenchOpenPrSummary, - WorkbenchRepositorySummary, - WorkbenchSessionSummary, - WorkbenchTaskSummary, + TaskWorkspaceChangeModelInput, + TaskWorkspaceCreateTaskInput, + TaskWorkspaceDiffInput, + TaskWorkspaceRenameInput, + TaskWorkspaceRenameSessionInput, + TaskWorkspaceSelectInput, + TaskWorkspaceSetSessionUnreadInput, + TaskWorkspaceSendMessageInput, + TaskWorkspaceSessionInput, + TaskWorkspaceUpdateDraftInput, + WorkspaceRepositorySummary, + WorkspaceTaskSummary, OrganizationEvent, OrganizationSummarySnapshot, OrganizationUseInput, } from "@sandbox-agent/foundry-shared"; import { getActorRuntimeContext } from "../context.js"; -import { getGithubData, getOrCreateGithubData, getTask, getOrCreateHistory, getOrCreateRepository, selfOrganization } from "../handles.js"; +import { getGithubData, getOrCreateAuditLog, getOrCreateGithubData, getTask as getTaskHandle, getOrCreateRepository, selfOrganization } from "../handles.js"; import { logActorWarning, resolveErrorMessage } from "../logging.js"; import { defaultSandboxProviderId } from "../../sandbox-config.js"; import { repoIdFromRemote } from "../../services/repo.js"; import { resolveOrganizationGithubAuth } from "../../services/github-auth.js"; -import { organizationProfile, taskLookup, repos, taskSummaries } from "./db/schema.js"; -import { agentTypeForModel } from "../task/workbench.js"; +import { organizationProfile, repos } from "./db/schema.js"; +import { agentTypeForModel } from "../task/workspace.js"; import { expectQueueResponse } from "../../services/queue.js"; import { organizationAppActions } from "./app-shell.js"; @@ -49,6 +47,7 @@ interface OrganizationState { interface GetTaskInput { organizationId: string; + repoId?: string; taskId: string; } @@ -72,7 +71,7 @@ export function organizationWorkflowQueueName(name: OrganizationQueueName): Orga return name; } -const ORGANIZATION_PROFILE_ROW_ID = "profile"; +const ORGANIZATION_PROFILE_ROW_ID = 1; function assertOrganization(c: { state: OrganizationState }, organizationId: string): void { if (organizationId !== c.state.organizationId) { @@ -80,42 +79,6 @@ function assertOrganization(c: { state: OrganizationState }, organizationId: str } } -async function resolveRepoId(c: any, taskId: string): Promise { - const row = await c.db.select({ repoId: taskLookup.repoId }).from(taskLookup).where(eq(taskLookup.taskId, taskId)).get(); - - if (!row) { - throw new Error(`Unknown task: ${taskId} (not in lookup)`); - } - - return row.repoId; -} - -async function upsertTaskLookupRow(c: any, taskId: string, repoId: string): Promise { - await c.db - .insert(taskLookup) - .values({ - taskId, - repoId, - }) - .onConflictDoUpdate({ - target: taskLookup.taskId, - set: { repoId }, - }) - .run(); -} - -function parseJsonValue(value: string | null | undefined, fallback: T): T { - if (!value) { - return fallback; - } - - try { - return JSON.parse(value) as T; - } catch { - return fallback; - } -} - async function collectAllTaskSummaries(c: any): Promise { const repoRows = await c.db.select({ repoId: repos.repoId, remoteUrl: repos.remoteUrl }).from(repos).orderBy(desc(repos.updatedAt)).all(); @@ -152,7 +115,7 @@ function repoLabelFromRemote(remoteUrl: string): string { return remoteUrl; } -function buildRepoSummary(repoRow: { repoId: string; remoteUrl: string; updatedAt: number }, taskRows: WorkbenchTaskSummary[]): WorkbenchRepositorySummary { +function buildRepoSummary(repoRow: { repoId: string; remoteUrl: string; updatedAt: number }, taskRows: WorkspaceTaskSummary[]): WorkspaceRepositorySummary { const repoTasks = taskRows.filter((task) => task.repoId === repoRow.repoId); const latestActivityMs = repoTasks.reduce((latest, task) => Math.max(latest, task.updatedAtMs), repoRow.updatedAt); @@ -164,79 +127,42 @@ function buildRepoSummary(repoRow: { repoId: string; remoteUrl: string; updatedA }; } -function taskSummaryRowFromSummary(taskSummary: WorkbenchTaskSummary) { - return { - taskId: taskSummary.id, - repoId: taskSummary.repoId, - title: taskSummary.title, - status: taskSummary.status, - repoName: taskSummary.repoName, - updatedAtMs: taskSummary.updatedAtMs, - branch: taskSummary.branch, - pullRequestJson: JSON.stringify(taskSummary.pullRequest), - sessionsSummaryJson: JSON.stringify(taskSummary.sessionsSummary), - }; +async function resolveRepositoryForTask(c: any, taskId: string, repoId?: string | null) { + if (repoId) { + const repoRow = await c.db.select({ remoteUrl: repos.remoteUrl }).from(repos).where(eq(repos.repoId, repoId)).get(); + if (!repoRow) { + throw new Error(`Unknown repo: ${repoId}`); + } + const repository = await getOrCreateRepository(c, c.state.organizationId, repoId, repoRow.remoteUrl); + return { repoId, repository }; + } + + const repoRows = await c.db.select({ repoId: repos.repoId, remoteUrl: repos.remoteUrl }).from(repos).orderBy(desc(repos.updatedAt)).all(); + for (const row of repoRows) { + const repository = await getOrCreateRepository(c, c.state.organizationId, row.repoId, row.remoteUrl); + const summaries = await repository.listTaskSummaries({ includeArchived: true }); + if (summaries.some((summary: TaskSummary) => summary.taskId === taskId)) { + return { repoId: row.repoId, repository }; + } + } + + throw new Error(`Unknown task: ${taskId}`); } -function taskSummaryFromRow(row: any): WorkbenchTaskSummary { - return { - id: row.taskId, - repoId: row.repoId, - title: row.title, - status: row.status, - repoName: row.repoName, - updatedAtMs: row.updatedAtMs, - branch: row.branch ?? null, - pullRequest: parseJsonValue(row.pullRequestJson, null), - sessionsSummary: parseJsonValue(row.sessionsSummaryJson, []), - }; -} - -async function listOpenPullRequestsSnapshot(c: any, taskRows: WorkbenchTaskSummary[]): Promise { - const githubData = getGithubData(c, c.state.organizationId); - const openPullRequests = await githubData.listOpenPullRequests({}).catch(() => []); - const claimedBranches = new Set(taskRows.filter((task) => task.branch).map((task) => `${task.repoId}:${task.branch}`)); - - return openPullRequests.filter((pullRequest: WorkbenchOpenPrSummary) => !claimedBranches.has(`${pullRequest.repoId}:${pullRequest.headRefName}`)); -} - -async function reconcileWorkbenchProjection(c: any): Promise { +async function reconcileWorkspaceProjection(c: any): Promise { const repoRows = await c.db .select({ repoId: repos.repoId, remoteUrl: repos.remoteUrl, updatedAt: repos.updatedAt }) .from(repos) .orderBy(desc(repos.updatedAt)) .all(); - const taskRows: WorkbenchTaskSummary[] = []; + const taskRows: WorkspaceTaskSummary[] = []; for (const row of repoRows) { try { const repository = await getOrCreateRepository(c, c.state.organizationId, row.repoId, row.remoteUrl); - const summaries = await repository.listTaskSummaries({ includeArchived: true }); - for (const summary of summaries) { - try { - await upsertTaskLookupRow(c, summary.taskId, row.repoId); - const task = getTask(c, c.state.organizationId, row.repoId, summary.taskId); - const taskSummary = await task.getTaskSummary({}); - taskRows.push(taskSummary); - await c.db - .insert(taskSummaries) - .values(taskSummaryRowFromSummary(taskSummary)) - .onConflictDoUpdate({ - target: taskSummaries.taskId, - set: taskSummaryRowFromSummary(taskSummary), - }) - .run(); - } catch (error) { - logActorWarning("organization", "failed collecting task summary during reconciliation", { - organizationId: c.state.organizationId, - repoId: row.repoId, - taskId: summary.taskId, - error: resolveErrorMessage(error), - }); - } - } + taskRows.push(...(await repository.listWorkspaceTaskSummaries({}))); } catch (error) { - logActorWarning("organization", "failed collecting repo during workbench reconciliation", { + logActorWarning("organization", "failed collecting repo during workspace reconciliation", { organizationId: c.state.organizationId, repoId: row.repoId, error: resolveErrorMessage(error), @@ -249,19 +175,17 @@ async function reconcileWorkbenchProjection(c: any): Promise buildRepoSummary(row, taskRows)).sort((left, right) => right.latestActivityMs - left.latestActivityMs), taskSummaries: taskRows, - openPullRequests: await listOpenPullRequestsSnapshot(c, taskRows), }; } -async function requireWorkbenchTask(c: any, taskId: string) { - const repoId = await resolveRepoId(c, taskId); - return getTask(c, c.state.organizationId, repoId, taskId); +async function requireWorkspaceTask(c: any, repoId: string, taskId: string) { + return getTaskHandle(c, c.state.organizationId, repoId, taskId); } /** - * Reads the organization sidebar snapshot from the organization actor's local SQLite - * plus the org-scoped GitHub actor for open PRs. Task actors still push - * summary updates into `task_summaries`, so the hot read path stays bounded. + * Reads the organization sidebar snapshot by fanning out one level to the + * repository coordinators. Task summaries are repository-owned; organization + * only aggregates them. */ async function getOrganizationSummarySnapshot(c: any): Promise { const repoRows = await c.db @@ -273,25 +197,33 @@ async function getOrganizationSummarySnapshot(c: any): Promise right.updatedAtMs - left.updatedAtMs); return { organizationId: c.state.organizationId, repos: repoRows.map((row) => buildRepoSummary(row, summaries)).sort((left, right) => right.latestActivityMs - left.latestActivityMs), taskSummaries: summaries, - openPullRequests: await listOpenPullRequestsSnapshot(c, summaries), }; } -async function broadcastRepoSummary( - c: any, - type: "repoAdded" | "repoUpdated", - repoRow: { repoId: string; remoteUrl: string; updatedAt: number }, -): Promise { - const matchingTaskRows = await c.db.select().from(taskSummaries).where(eq(taskSummaries.repoId, repoRow.repoId)).all(); - const repo = buildRepoSummary(repoRow, matchingTaskRows.map(taskSummaryFromRow)); - c.broadcast("organizationUpdated", { type, repo } satisfies OrganizationEvent); +async function broadcastOrganizationSnapshot(c: any): Promise { + c.broadcast("organizationUpdated", { + type: "organizationUpdated", + snapshot: await getOrganizationSummarySnapshot(c), + } satisfies OrganizationEvent); } async function createTaskMutation(c: any, input: CreateTaskInput): Promise { @@ -318,32 +250,6 @@ async function createTaskMutation(c: any, input: CreateTaskInput): Promise { - await c.db - .insert(taskSummaries) - .values(taskSummaryRowFromSummary(input.taskSummary)) - .onConflictDoUpdate({ - target: taskSummaries.taskId, - set: taskSummaryRowFromSummary(input.taskSummary), - }) - .run(); - c.broadcast("organizationUpdated", { type: "taskSummaryUpdated", taskSummary: input.taskSummary } satisfies OrganizationEvent); - }, - - async removeTaskSummary(c: any, input: { taskId: string }): Promise { - await c.db.delete(taskSummaries).where(eq(taskSummaries.taskId, input.taskId)).run(); - c.broadcast("organizationUpdated", { type: "taskRemoved", taskId: input.taskId } satisfies OrganizationEvent); - }, - - async findTaskForGithubBranch(c: any, input: { repoId: string; branchName: string }): Promise<{ taskId: string | null }> { - const summaries = await c.db.select().from(taskSummaries).where(eq(taskSummaries.repoId, input.repoId)).all(); - const existing = summaries.find((summary) => summary.branch === input.branchName); - return { taskId: existing?.taskId ?? null }; - }, - - async refreshTaskSummaryForGithubBranch(c: any, input: { repoId: string; branchName: string }): Promise { - const summaries = await c.db.select().from(taskSummaries).where(eq(taskSummaries.repoId, input.repoId)).all(); - const matches = summaries.filter((summary) => summary.branch === input.branchName); - - for (const summary of matches) { - try { - const task = getTask(c, c.state.organizationId, input.repoId, summary.taskId); - await organizationActions.applyTaskSummaryUpdate(c, { - taskSummary: await task.getTaskSummary({}), - }); - } catch (error) { - logActorWarning("organization", "failed refreshing task summary for GitHub branch", { - organizationId: c.state.organizationId, - repoId: input.repoId, - branchName: input.branchName, - taskId: summary.taskId, - error: resolveErrorMessage(error), - }); - } - } - }, - - async applyOpenPullRequestUpdate(c: any, input: { pullRequest: WorkbenchOpenPrSummary }): Promise { - const summaries = await c.db.select().from(taskSummaries).where(eq(taskSummaries.repoId, input.pullRequest.repoId)).all(); - if (summaries.some((summary) => summary.branch === input.pullRequest.headRefName)) { - return; - } - c.broadcast("organizationUpdated", { type: "pullRequestUpdated", pullRequest: input.pullRequest } satisfies OrganizationEvent); - }, - - async removeOpenPullRequest(c: any, input: { prId: string }): Promise { - c.broadcast("organizationUpdated", { type: "pullRequestRemoved", prId: input.prId } satisfies OrganizationEvent); + async refreshOrganizationSnapshot(c: any): Promise { + await broadcastOrganizationSnapshot(c); }, async applyGithubRepositoryProjection(c: any, input: { repoId: string; remoteUrl: string }): Promise { @@ -533,11 +380,7 @@ export const organizationActions = { }, }) .run(); - await broadcastRepoSummary(c, existing ? "repoUpdated" : "repoAdded", { - repoId: input.repoId, - remoteUrl: input.remoteUrl, - updatedAt: now, - }); + await broadcastOrganizationSnapshot(c); }, async applyGithubDataProjection( @@ -576,11 +419,7 @@ export const organizationActions = { }, }) .run(); - await broadcastRepoSummary(c, existingById.has(repoId) ? "repoUpdated" : "repoAdded", { - repoId, - remoteUrl: repository.cloneUrl, - updatedAt: now, - }); + await broadcastOrganizationSnapshot(c); } for (const repo of existingRepos) { @@ -588,7 +427,7 @@ export const organizationActions = { continue; } await c.db.delete(repos).where(eq(repos.repoId, repo.repoId)).run(); - c.broadcast("organizationUpdated", { type: "repoRemoved", repoId: repo.repoId } satisfies OrganizationEvent); + await broadcastOrganizationSnapshot(c); } const profile = await c.db @@ -648,12 +487,12 @@ export const organizationActions = { return await getOrganizationSummarySnapshot(c); }, - async reconcileWorkbenchState(c: any, input: OrganizationUseInput): Promise { + async adminReconcileWorkspaceState(c: any, input: OrganizationUseInput): Promise { assertOrganization(c, input.organizationId); - return await reconcileWorkbenchProjection(c); + return await reconcileWorkspaceProjection(c); }, - async createWorkbenchTask(c: any, input: TaskWorkbenchCreateTaskInput): Promise<{ taskId: string; sessionId?: string }> { + async createWorkspaceTask(c: any, input: TaskWorkspaceCreateTaskInput): Promise<{ taskId: string; sessionId?: string }> { // Step 1: Create the task record (wait: true — local state mutations only). const created = await organizationActions.createTask(c, { organizationId: c.state.organizationId, @@ -668,8 +507,8 @@ export const organizationActions = { // The task workflow creates the session record and sends the message in // the background. The client observes progress via push events on the // task subscription topic. - const task = await requireWorkbenchTask(c, created.taskId); - await task.createWorkbenchSessionAndSend({ + const task = await requireWorkspaceTask(c, input.repoId, created.taskId); + await task.createWorkspaceSessionAndSend({ model: input.model, text: input.task, }); @@ -677,84 +516,79 @@ export const organizationActions = { return { taskId: created.taskId }; }, - async markWorkbenchUnread(c: any, input: TaskWorkbenchSelectInput): Promise { - const task = await requireWorkbenchTask(c, input.taskId); - await task.markWorkbenchUnread({}); + async markWorkspaceUnread(c: any, input: TaskWorkspaceSelectInput): Promise { + const task = await requireWorkspaceTask(c, input.repoId, input.taskId); + await task.markWorkspaceUnread({}); }, - async renameWorkbenchTask(c: any, input: TaskWorkbenchRenameInput): Promise { - const task = await requireWorkbenchTask(c, input.taskId); - await task.renameWorkbenchTask(input); + async renameWorkspaceTask(c: any, input: TaskWorkspaceRenameInput): Promise { + const task = await requireWorkspaceTask(c, input.repoId, input.taskId); + await task.renameWorkspaceTask(input); }, - async renameWorkbenchBranch(c: any, input: TaskWorkbenchRenameInput): Promise { - const task = await requireWorkbenchTask(c, input.taskId); - await task.renameWorkbenchBranch(input); + async createWorkspaceSession(c: any, input: TaskWorkspaceSelectInput & { model?: string }): Promise<{ sessionId: string }> { + const task = await requireWorkspaceTask(c, input.repoId, input.taskId); + return await task.createWorkspaceSession({ ...(input.model ? { model: input.model } : {}) }); }, - async createWorkbenchSession(c: any, input: TaskWorkbenchSelectInput & { model?: string }): Promise<{ sessionId: string }> { - const task = await requireWorkbenchTask(c, input.taskId); - return await task.createWorkbenchSession({ ...(input.model ? { model: input.model } : {}) }); + async renameWorkspaceSession(c: any, input: TaskWorkspaceRenameSessionInput): Promise { + const task = await requireWorkspaceTask(c, input.repoId, input.taskId); + await task.renameWorkspaceSession(input); }, - async renameWorkbenchSession(c: any, input: TaskWorkbenchRenameSessionInput): Promise { - const task = await requireWorkbenchTask(c, input.taskId); - await task.renameWorkbenchSession(input); + async setWorkspaceSessionUnread(c: any, input: TaskWorkspaceSetSessionUnreadInput): Promise { + const task = await requireWorkspaceTask(c, input.repoId, input.taskId); + await task.setWorkspaceSessionUnread(input); }, - async setWorkbenchSessionUnread(c: any, input: TaskWorkbenchSetSessionUnreadInput): Promise { - const task = await requireWorkbenchTask(c, input.taskId); - await task.setWorkbenchSessionUnread(input); + async updateWorkspaceDraft(c: any, input: TaskWorkspaceUpdateDraftInput): Promise { + const task = await requireWorkspaceTask(c, input.repoId, input.taskId); + await task.updateWorkspaceDraft(input); }, - async updateWorkbenchDraft(c: any, input: TaskWorkbenchUpdateDraftInput): Promise { - const task = await requireWorkbenchTask(c, input.taskId); - await task.updateWorkbenchDraft(input); + async changeWorkspaceModel(c: any, input: TaskWorkspaceChangeModelInput): Promise { + const task = await requireWorkspaceTask(c, input.repoId, input.taskId); + await task.changeWorkspaceModel(input); }, - async changeWorkbenchModel(c: any, input: TaskWorkbenchChangeModelInput): Promise { - const task = await requireWorkbenchTask(c, input.taskId); - await task.changeWorkbenchModel(input); + async sendWorkspaceMessage(c: any, input: TaskWorkspaceSendMessageInput): Promise { + const task = await requireWorkspaceTask(c, input.repoId, input.taskId); + await task.sendWorkspaceMessage(input); }, - async sendWorkbenchMessage(c: any, input: TaskWorkbenchSendMessageInput): Promise { - const task = await requireWorkbenchTask(c, input.taskId); - await task.sendWorkbenchMessage(input); + async stopWorkspaceSession(c: any, input: TaskWorkspaceSessionInput): Promise { + const task = await requireWorkspaceTask(c, input.repoId, input.taskId); + await task.stopWorkspaceSession(input); }, - async stopWorkbenchSession(c: any, input: TaskWorkbenchSessionInput): Promise { - const task = await requireWorkbenchTask(c, input.taskId); - await task.stopWorkbenchSession(input); + async closeWorkspaceSession(c: any, input: TaskWorkspaceSessionInput): Promise { + const task = await requireWorkspaceTask(c, input.repoId, input.taskId); + await task.closeWorkspaceSession(input); }, - async closeWorkbenchSession(c: any, input: TaskWorkbenchSessionInput): Promise { - const task = await requireWorkbenchTask(c, input.taskId); - await task.closeWorkbenchSession(input); + async publishWorkspacePr(c: any, input: TaskWorkspaceSelectInput): Promise { + const task = await requireWorkspaceTask(c, input.repoId, input.taskId); + await task.publishWorkspacePr({}); }, - async publishWorkbenchPr(c: any, input: TaskWorkbenchSelectInput): Promise { - const task = await requireWorkbenchTask(c, input.taskId); - await task.publishWorkbenchPr({}); + async revertWorkspaceFile(c: any, input: TaskWorkspaceDiffInput): Promise { + const task = await requireWorkspaceTask(c, input.repoId, input.taskId); + await task.revertWorkspaceFile(input); }, - async revertWorkbenchFile(c: any, input: TaskWorkbenchDiffInput): Promise { - const task = await requireWorkbenchTask(c, input.taskId); - await task.revertWorkbenchFile(input); + async adminReloadGithubOrganization(c: any): Promise { + await getOrCreateGithubData(c, c.state.organizationId).adminReloadOrganization({}); }, - async reloadGithubOrganization(c: any): Promise { - await getOrCreateGithubData(c, c.state.organizationId).reloadOrganization({}); + async adminReloadGithubPullRequests(c: any): Promise { + await getOrCreateGithubData(c, c.state.organizationId).adminReloadAllPullRequests({}); }, - async reloadGithubPullRequests(c: any): Promise { - await getOrCreateGithubData(c, c.state.organizationId).reloadAllPullRequests({}); - }, - - async reloadGithubRepository(c: any, input: { repoId: string }): Promise { + async adminReloadGithubRepository(c: any, input: { repoId: string }): Promise { await getOrCreateGithubData(c, c.state.organizationId).reloadRepository(input); }, - async reloadGithubPullRequest(c: any, input: { repoId: string; prNumber: number }): Promise { + async adminReloadGithubPullRequest(c: any, input: { repoId: string; prNumber: number }): Promise { await getOrCreateGithubData(c, c.state.organizationId).reloadPullRequest(input); }, @@ -786,39 +620,39 @@ export const organizationActions = { return await repository.getRepoOverview({}); }, - async switchTask(c: any, taskId: string): Promise { - const repoId = await resolveRepoId(c, taskId); - const h = getTask(c, c.state.organizationId, repoId, taskId); + async switchTask(c: any, input: { repoId?: string; taskId: string }): Promise { + const { repoId } = await resolveRepositoryForTask(c, input.taskId, input.repoId); + const h = getTaskHandle(c, c.state.organizationId, repoId, input.taskId); const record = await h.get(); const switched = await h.switch(); return { organizationId: c.state.organizationId, - taskId, + taskId: input.taskId, sandboxProviderId: record.sandboxProviderId, switchTarget: switched.switchTarget, }; }, - async history(c: any, input: HistoryQueryInput): Promise { + async auditLog(c: any, input: HistoryQueryInput): Promise { assertOrganization(c, input.organizationId); const limit = input.limit ?? 20; const repoRows = await c.db.select({ repoId: repos.repoId }).from(repos).all(); - const allEvents: HistoryEvent[] = []; + const allEvents: AuditLogEvent[] = []; for (const row of repoRows) { try { - const hist = await getOrCreateHistory(c, c.state.organizationId, row.repoId); - const items = await hist.list({ + const auditLog = await getOrCreateAuditLog(c, c.state.organizationId, row.repoId); + const items = await auditLog.list({ branch: input.branch, taskId: input.taskId, limit, }); allEvents.push(...items); } catch (error) { - logActorWarning("organization", "history lookup failed for repo", { + logActorWarning("organization", "audit log lookup failed for repo", { organizationId: c.state.organizationId, repoId: row.repoId, error: resolveErrorMessage(error), @@ -832,57 +666,49 @@ export const organizationActions = { async getTask(c: any, input: GetTaskInput): Promise { assertOrganization(c, input.organizationId); - - const repoId = await resolveRepoId(c, input.taskId); - - const repoRow = await c.db.select({ remoteUrl: repos.remoteUrl }).from(repos).where(eq(repos.repoId, repoId)).get(); - if (!repoRow) { - throw new Error(`Unknown repo: ${repoId}`); - } - - const repository = await getOrCreateRepository(c, c.state.organizationId, repoId, repoRow.remoteUrl); - return await repository.getTaskEnriched({ taskId: input.taskId }); + const { repoId } = await resolveRepositoryForTask(c, input.taskId, input.repoId); + return await getTaskHandle(c, c.state.organizationId, repoId, input.taskId).get(); }, async attachTask(c: any, input: TaskProxyActionInput): Promise<{ target: string; sessionId: string | null }> { assertOrganization(c, input.organizationId); - const repoId = await resolveRepoId(c, input.taskId); - const h = getTask(c, c.state.organizationId, repoId, input.taskId); + const { repoId } = await resolveRepositoryForTask(c, input.taskId, input.repoId); + const h = getTaskHandle(c, c.state.organizationId, repoId, input.taskId); return await h.attach({ reason: input.reason }); }, async pushTask(c: any, input: TaskProxyActionInput): Promise { assertOrganization(c, input.organizationId); - const repoId = await resolveRepoId(c, input.taskId); - const h = getTask(c, c.state.organizationId, repoId, input.taskId); + const { repoId } = await resolveRepositoryForTask(c, input.taskId, input.repoId); + const h = getTaskHandle(c, c.state.organizationId, repoId, input.taskId); await h.push({ reason: input.reason }); }, async syncTask(c: any, input: TaskProxyActionInput): Promise { assertOrganization(c, input.organizationId); - const repoId = await resolveRepoId(c, input.taskId); - const h = getTask(c, c.state.organizationId, repoId, input.taskId); + const { repoId } = await resolveRepositoryForTask(c, input.taskId, input.repoId); + const h = getTaskHandle(c, c.state.organizationId, repoId, input.taskId); await h.sync({ reason: input.reason }); }, async mergeTask(c: any, input: TaskProxyActionInput): Promise { assertOrganization(c, input.organizationId); - const repoId = await resolveRepoId(c, input.taskId); - const h = getTask(c, c.state.organizationId, repoId, input.taskId); + const { repoId } = await resolveRepositoryForTask(c, input.taskId, input.repoId); + const h = getTaskHandle(c, c.state.organizationId, repoId, input.taskId); await h.merge({ reason: input.reason }); }, async archiveTask(c: any, input: TaskProxyActionInput): Promise { assertOrganization(c, input.organizationId); - const repoId = await resolveRepoId(c, input.taskId); - const h = getTask(c, c.state.organizationId, repoId, input.taskId); + const { repoId } = await resolveRepositoryForTask(c, input.taskId, input.repoId); + const h = getTaskHandle(c, c.state.organizationId, repoId, input.taskId); await h.archive({ reason: input.reason }); }, async killTask(c: any, input: TaskProxyActionInput): Promise { assertOrganization(c, input.organizationId); - const repoId = await resolveRepoId(c, input.taskId); - const h = getTask(c, c.state.organizationId, repoId, input.taskId); + const { repoId } = await resolveRepositoryForTask(c, input.taskId, input.repoId); + const h = getTaskHandle(c, c.state.organizationId, repoId, input.taskId); await h.kill({ reason: input.reason }); }, }; diff --git a/foundry/packages/backend/src/actors/organization/app-shell.ts b/foundry/packages/backend/src/actors/organization/app-shell.ts index 3339590..942f212 100644 --- a/foundry/packages/backend/src/actors/organization/app-shell.ts +++ b/foundry/packages/backend/src/actors/organization/app-shell.ts @@ -8,6 +8,7 @@ import type { FoundryOrganizationMember, FoundryUser, UpdateFoundryOrganizationProfileInput, + WorkspaceModelId, } from "@sandbox-agent/foundry-shared"; import { getActorRuntimeContext } from "../context.js"; import { getOrCreateGithubData, getOrCreateOrganization, selfOrganization } from "../handles.js"; @@ -98,7 +99,7 @@ const githubWebhookLogger = logger.child({ scope: "github-webhook", }); -const PROFILE_ROW_ID = "profile"; +const PROFILE_ROW_ID = 1; function roundDurationMs(start: number): number { return Math.round((performance.now() - start) * 100) / 100; @@ -359,6 +360,7 @@ async function buildAppSnapshot(c: any, sessionId: string, allowOrganizationRepa githubLogin: profile?.githubLogin ?? "", roleLabel: profile?.roleLabel ?? "GitHub user", eligibleOrganizationIds, + defaultModel: profile?.defaultModel ?? "claude-sonnet-4", } : null; @@ -685,7 +687,6 @@ async function buildOrganizationStateFromRow(c: any, row: any, startedAt: number slug: row.slug, primaryDomain: row.primaryDomain, seatAccrualMode: "first_prompt", - defaultModel: row.defaultModel, autoImportRepos: row.autoImportRepos === 1, }, github: { @@ -1078,6 +1079,15 @@ export const organizationAppActions = { return await buildAppSnapshot(c, input.sessionId); }, + async setAppDefaultModel(c: any, input: { sessionId: string; defaultModel: WorkspaceModelId }): Promise { + assertAppOrganization(c); + const session = await requireSignedInSession(c, input.sessionId); + await getBetterAuthService().upsertUserProfile(session.authUserId, { + defaultModel: input.defaultModel, + }); + return await buildAppSnapshot(c, input.sessionId); + }, + async updateAppOrganizationProfile( c: any, input: { sessionId: string; organizationId: string } & UpdateFoundryOrganizationProfileInput, @@ -1393,14 +1403,14 @@ export const organizationAppActions = { "installation_event", ); if (body.action === "deleted") { - await githubData.clearState({ + await githubData.adminClearState({ connectedAccount: accountLogin, installationStatus: "install_required", installationId: null, label: "GitHub App installation removed", }); } else if (body.action === "created") { - await githubData.fullSync({ + await githubData.adminFullSync({ connectedAccount: accountLogin, installationStatus: "connected", installationId: body.installation?.id ?? null, @@ -1409,14 +1419,14 @@ export const organizationAppActions = { label: "Syncing GitHub data from installation webhook...", }); } else if (body.action === "suspend") { - await githubData.clearState({ + await githubData.adminClearState({ connectedAccount: accountLogin, installationStatus: "reconnect_required", installationId: body.installation?.id ?? null, label: "GitHub App installation suspended", }); } else if (body.action === "unsuspend") { - await githubData.fullSync({ + await githubData.adminFullSync({ connectedAccount: accountLogin, installationStatus: "connected", installationId: body.installation?.id ?? null, @@ -1440,7 +1450,7 @@ export const organizationAppActions = { }, "repository_membership_changed", ); - await githubData.fullSync({ + await githubData.adminFullSync({ connectedAccount: accountLogin, installationStatus: "connected", installationId: body.installation?.id ?? null, @@ -1578,7 +1588,6 @@ export const organizationAppActions = { displayName: input.displayName, slug, primaryDomain: existing?.primaryDomain ?? (input.kind === "personal" ? "personal" : `${slug}.github`), - defaultModel: existing?.defaultModel ?? "claude-sonnet-4", autoImportRepos: existing?.autoImportRepos ?? 1, repoImportStatus: existing?.repoImportStatus ?? "not_started", githubConnectedAccount: input.githubLogin, diff --git a/foundry/packages/backend/src/actors/organization/db/migrations.ts b/foundry/packages/backend/src/actors/organization/db/migrations.ts index b3e09f1..6db33f3 100644 --- a/foundry/packages/backend/src/actors/organization/db/migrations.ts +++ b/foundry/packages/backend/src/actors/organization/db/migrations.ts @@ -10,24 +10,6 @@ const journal = { tag: "0000_melted_viper", breakpoints: true, }, - { - idx: 1, - when: 1773638400000, - tag: "0001_auth_index_tables", - breakpoints: true, - }, - { - idx: 2, - when: 1773720000000, - tag: "0002_task_summaries", - breakpoints: true, - }, - { - idx: 3, - when: 1773810001000, - tag: "0003_drop_provider_profiles", - breakpoints: true, - }, ], } as const; @@ -73,7 +55,7 @@ CREATE TABLE \`organization_members\` ( ); --> statement-breakpoint CREATE TABLE \`organization_profile\` ( - \`id\` text PRIMARY KEY NOT NULL, + \`id\` integer PRIMARY KEY NOT NULL, \`kind\` text NOT NULL, \`github_account_id\` text NOT NULL, \`github_login\` text NOT NULL, @@ -81,7 +63,6 @@ CREATE TABLE \`organization_profile\` ( \`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, @@ -102,7 +83,8 @@ CREATE TABLE \`organization_profile\` ( \`billing_renewal_at\` text, \`billing_payment_method_label\` text NOT NULL, \`created_at\` integer NOT NULL, - \`updated_at\` integer NOT NULL + \`updated_at\` integer NOT NULL, + CONSTRAINT \`organization_profile_singleton_id_check\` CHECK(\`id\` = 1) ); --> statement-breakpoint CREATE TABLE \`repos\` ( @@ -122,56 +104,6 @@ CREATE TABLE \`stripe_lookup\` ( \`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 -); -`, - m0001: `CREATE TABLE IF NOT EXISTS \`auth_session_index\` ( - \`session_id\` text PRIMARY KEY NOT NULL, - \`session_token\` text NOT NULL, - \`user_id\` text NOT NULL, - \`created_at\` integer NOT NULL, - \`updated_at\` integer NOT NULL -); ---> statement-breakpoint -CREATE TABLE IF NOT EXISTS \`auth_email_index\` ( - \`email\` text PRIMARY KEY NOT NULL, - \`user_id\` text NOT NULL, - \`updated_at\` integer NOT NULL -); ---> statement-breakpoint -CREATE TABLE IF NOT EXISTS \`auth_account_index\` ( - \`id\` text PRIMARY KEY NOT NULL, - \`provider_id\` text NOT NULL, - \`account_id\` text NOT NULL, - \`user_id\` text NOT NULL, - \`updated_at\` integer NOT NULL -); ---> statement-breakpoint -CREATE TABLE IF NOT EXISTS \`auth_verification\` ( - \`id\` text PRIMARY KEY NOT NULL, - \`identifier\` text NOT NULL, - \`value\` text NOT NULL, - \`expires_at\` integer NOT NULL, - \`created_at\` integer NOT NULL, - \`updated_at\` integer NOT NULL -); -`, - m0002: `CREATE TABLE IF NOT EXISTS \`task_summaries\` ( - \`task_id\` text PRIMARY KEY NOT NULL, - \`repo_id\` text NOT NULL, - \`title\` text NOT NULL, - \`status\` text NOT NULL, - \`repo_name\` text NOT NULL, - \`updated_at_ms\` integer NOT NULL, - \`branch\` text, - \`pull_request_json\` text, - \`sessions_summary_json\` text DEFAULT '[]' NOT NULL -); -`, - m0003: `DROP TABLE IF EXISTS \`provider_profiles\`; `, } as const, }; diff --git a/foundry/packages/backend/src/actors/organization/db/schema.ts b/foundry/packages/backend/src/actors/organization/db/schema.ts index dd4fa40..e3ccb71 100644 --- a/foundry/packages/backend/src/actors/organization/db/schema.ts +++ b/foundry/packages/backend/src/actors/organization/db/schema.ts @@ -1,4 +1,5 @@ -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 organization actor instance, so no organizationId column needed. @@ -14,66 +15,41 @@ export const repos = sqliteTable("repos", { updatedAt: integer("updated_at").notNull(), }); -/** - * Coordinator index of TaskActor instances. - * Fast taskId → repoId lookup so the organization can route requests - * to the correct RepositoryActor without scanning all repos. - */ -export const taskLookup = sqliteTable("task_lookup", { - taskId: text("task_id").notNull().primaryKey(), - repoId: text("repo_id").notNull(), -}); - -/** - * Coordinator index of TaskActor instances — materialized sidebar projection. - * Task actors push summary updates to the organization actor via - * applyTaskSummaryUpdate(). Source of truth lives on each TaskActor; - * this table exists so organization reads stay local without fan-out. - */ -export const taskSummaries = sqliteTable("task_summaries", { - taskId: text("task_id").notNull().primaryKey(), - repoId: text("repo_id").notNull(), - title: text("title").notNull(), - status: text("status").notNull(), - repoName: text("repo_name").notNull(), - updatedAtMs: integer("updated_at_ms").notNull(), - branch: text("branch"), - pullRequestJson: text("pull_request_json"), - sessionsSummaryJson: text("sessions_summary_json").notNull().default("[]"), -}); - -export const organizationProfile = sqliteTable("organization_profile", { - id: text("id").notNull().primaryKey(), - kind: text("kind").notNull(), - githubAccountId: text("github_account_id").notNull(), - githubLogin: text("github_login").notNull(), - githubAccountType: text("github_account_type").notNull(), - displayName: text("display_name").notNull(), - slug: text("slug").notNull(), - primaryDomain: text("primary_domain").notNull(), - defaultModel: text("default_model").notNull(), - autoImportRepos: integer("auto_import_repos").notNull(), - repoImportStatus: text("repo_import_status").notNull(), - githubConnectedAccount: text("github_connected_account").notNull(), - githubInstallationStatus: text("github_installation_status").notNull(), - githubSyncStatus: text("github_sync_status").notNull(), - githubInstallationId: integer("github_installation_id"), - githubLastSyncLabel: text("github_last_sync_label").notNull(), - githubLastSyncAt: integer("github_last_sync_at"), - githubLastWebhookAt: integer("github_last_webhook_at"), - githubLastWebhookEvent: text("github_last_webhook_event"), - stripeCustomerId: text("stripe_customer_id"), - stripeSubscriptionId: text("stripe_subscription_id"), - stripePriceId: text("stripe_price_id"), - billingPlanId: text("billing_plan_id").notNull(), - billingStatus: text("billing_status").notNull(), - billingSeatsIncluded: integer("billing_seats_included").notNull(), - billingTrialEndsAt: text("billing_trial_ends_at"), - billingRenewalAt: text("billing_renewal_at"), - billingPaymentMethodLabel: text("billing_payment_method_label").notNull(), - createdAt: integer("created_at").notNull(), - updatedAt: integer("updated_at").notNull(), -}); +export const organizationProfile = sqliteTable( + "organization_profile", + { + id: integer("id").primaryKey(), + kind: text("kind").notNull(), + githubAccountId: text("github_account_id").notNull(), + githubLogin: text("github_login").notNull(), + githubAccountType: text("github_account_type").notNull(), + displayName: text("display_name").notNull(), + slug: text("slug").notNull(), + primaryDomain: text("primary_domain").notNull(), + autoImportRepos: integer("auto_import_repos").notNull(), + repoImportStatus: text("repo_import_status").notNull(), + githubConnectedAccount: text("github_connected_account").notNull(), + githubInstallationStatus: text("github_installation_status").notNull(), + githubSyncStatus: text("github_sync_status").notNull(), + githubInstallationId: integer("github_installation_id"), + githubLastSyncLabel: text("github_last_sync_label").notNull(), + githubLastSyncAt: integer("github_last_sync_at"), + githubLastWebhookAt: integer("github_last_webhook_at"), + githubLastWebhookEvent: text("github_last_webhook_event"), + stripeCustomerId: text("stripe_customer_id"), + stripeSubscriptionId: text("stripe_subscription_id"), + stripePriceId: text("stripe_price_id"), + billingPlanId: text("billing_plan_id").notNull(), + billingStatus: text("billing_status").notNull(), + billingSeatsIncluded: integer("billing_seats_included").notNull(), + billingTrialEndsAt: text("billing_trial_ends_at"), + billingRenewalAt: text("billing_renewal_at"), + billingPaymentMethodLabel: text("billing_payment_method_label").notNull(), + createdAt: integer("created_at").notNull(), + updatedAt: integer("updated_at").notNull(), + }, + (table) => [check("organization_profile_singleton_id_check", sql`${table.id} = 1`)], +); export const organizationMembers = sqliteTable("organization_members", { id: text("id").notNull().primaryKey(), @@ -133,6 +109,7 @@ export const authAccountIndex = sqliteTable("auth_account_index", { updatedAt: integer("updated_at").notNull(), }); +/** Better Auth core model — schema defined at https://better-auth.com/docs/concepts/database */ export const authVerification = sqliteTable("auth_verification", { id: text("id").notNull().primaryKey(), identifier: text("identifier").notNull(), diff --git a/foundry/packages/backend/src/actors/repository/actions.ts b/foundry/packages/backend/src/actors/repository/actions.ts index 9ef8e75..6f8c7c9 100644 --- a/foundry/packages/backend/src/actors/repository/actions.ts +++ b/foundry/packages/backend/src/actors/repository/actions.ts @@ -2,12 +2,21 @@ import { randomUUID } from "node:crypto"; import { and, desc, eq, isNotNull, ne } from "drizzle-orm"; import { Loop } from "rivetkit/workflow"; -import type { AgentType, RepoOverview, SandboxProviderId, TaskRecord, TaskSummary } from "@sandbox-agent/foundry-shared"; -import { getGithubData, getOrCreateHistory, getOrCreateTask, getTask, selfRepository } from "../handles.js"; +import type { + AgentType, + RepoOverview, + SandboxProviderId, + TaskRecord, + TaskSummary, + WorkspacePullRequestSummary, + WorkspaceSessionSummary, + WorkspaceTaskSummary, +} from "@sandbox-agent/foundry-shared"; +import { getGithubData, getOrCreateAuditLog, getOrCreateOrganization, getOrCreateTask, getTask, selfRepository } from "../handles.js"; import { deriveFallbackTitle, resolveCreateFlowDecision } from "../../services/create-flow.js"; import { expectQueueResponse } from "../../services/queue.js"; import { isActorNotFoundError, logActorWarning, resolveErrorMessage } from "../logging.js"; -import { repoMeta, taskIndex } from "./db/schema.js"; +import { repoMeta, taskIndex, tasks } from "./db/schema.js"; interface CreateTaskCommand { task: string; @@ -29,10 +38,6 @@ interface ListTaskSummariesCommand { includeArchived?: boolean; } -interface GetTaskEnrichedCommand { - taskId: string; -} - interface GetPullRequestForBranchCommand { branchName: string; } @@ -52,6 +57,61 @@ function isStaleTaskReferenceError(error: unknown): boolean { return isActorNotFoundError(error) || message.startsWith("Task not found:"); } +function parseJsonValue(value: string | null | undefined, fallback: T): T { + if (!value) { + return fallback; + } + + try { + return JSON.parse(value) as T; + } catch { + return fallback; + } +} + +function taskSummaryRowFromSummary(taskSummary: WorkspaceTaskSummary) { + return { + taskId: taskSummary.id, + title: taskSummary.title, + status: taskSummary.status, + repoName: taskSummary.repoName, + updatedAtMs: taskSummary.updatedAtMs, + branch: taskSummary.branch, + pullRequestJson: JSON.stringify(taskSummary.pullRequest), + sessionsSummaryJson: JSON.stringify(taskSummary.sessionsSummary), + }; +} + +function taskSummaryFromRow(c: any, row: any): WorkspaceTaskSummary { + return { + id: row.taskId, + repoId: c.state.repoId, + title: row.title, + status: row.status, + repoName: row.repoName, + updatedAtMs: row.updatedAtMs, + branch: row.branch ?? null, + pullRequest: parseJsonValue(row.pullRequestJson, null), + sessionsSummary: parseJsonValue(row.sessionsSummaryJson, []), + }; +} + +async function upsertTaskSummary(c: any, taskSummary: WorkspaceTaskSummary): Promise { + await c.db + .insert(tasks) + .values(taskSummaryRowFromSummary(taskSummary)) + .onConflictDoUpdate({ + target: tasks.taskId, + set: taskSummaryRowFromSummary(taskSummary), + }) + .run(); +} + +async function notifyOrganizationSnapshotChanged(c: any): Promise { + const organization = await getOrCreateOrganization(c, c.state.organizationId); + await organization.refreshOrganizationSnapshot({}); +} + async function persistRemoteUrl(c: any, remoteUrl: string): Promise { c.state.remoteUrl = remoteUrl; await c.db @@ -104,6 +164,46 @@ async function listKnownTaskBranches(c: any): Promise { return rows.map((row) => row.branchName).filter((value): value is string => typeof value === "string" && value.trim().length > 0); } +function parseJsonValue(value: string | null | undefined, fallback: T): T { + if (!value) { + return fallback; + } + + try { + return JSON.parse(value) as T; + } catch { + return fallback; + } +} + +function taskSummaryRowFromSummary(taskSummary: WorkspaceTaskSummary) { + return { + taskId: taskSummary.id, + repoId: taskSummary.repoId, + title: taskSummary.title, + status: taskSummary.status, + repoName: taskSummary.repoName, + updatedAtMs: taskSummary.updatedAtMs, + branch: taskSummary.branch, + pullRequestJson: JSON.stringify(taskSummary.pullRequest), + sessionsSummaryJson: JSON.stringify(taskSummary.sessionsSummary), + }; +} + +function workspaceTaskSummaryFromRow(row: any): WorkspaceTaskSummary { + return { + id: row.taskId, + repoId: row.repoId, + title: row.title, + status: row.status, + repoName: row.repoName, + updatedAtMs: row.updatedAtMs, + branch: row.branch ?? null, + pullRequest: parseJsonValue(row.pullRequestJson, null), + sessionsSummary: parseJsonValue(row.sessionsSummaryJson, []), + }; +} + async function resolveGitHubRepository(c: any) { const githubData = getGithubData(c, c.state.organizationId); return await githubData.getRepository({ repoId: c.state.repoId }).catch(() => null); @@ -114,34 +214,6 @@ async function listGitHubBranches(c: any): Promise []); } -async function enrichTaskRecord(c: any, record: TaskRecord): Promise { - const branchName = record.branchName?.trim() || null; - if (!branchName) { - return record; - } - - const pr = - branchName != null - ? await getGithubData(c, c.state.organizationId) - .listPullRequestsForRepository({ repoId: c.state.repoId }) - .then((rows: any[]) => rows.find((row) => row.headRefName === branchName) ?? null) - .catch(() => null) - : null; - - return { - ...record, - prUrl: pr?.url ?? null, - prAuthor: pr?.authorLogin ?? null, - ciStatus: null, - reviewStatus: null, - reviewer: pr?.authorLogin ?? null, - diffStat: record.diffStat ?? null, - hasUnpushed: record.hasUnpushed ?? null, - conflictsWithMain: record.conflictsWithMain ?? null, - parentBranch: record.parentBranch ?? null, - }; -} - async function createTaskMutation(c: any, cmd: CreateTaskCommand): Promise { const organizationId = c.state.organizationId; const repoId = c.state.repoId; @@ -213,19 +285,60 @@ async function createTaskMutation(c: any, cmd: CreateTaskCommand): Promise { + await c.db + .insert(tasks) + .values(taskSummaryRowFromSummary(taskSummary)) + .onConflictDoUpdate({ + target: tasks.taskId, + set: taskSummaryRowFromSummary(taskSummary), + }) + .run(); +} + async function registerTaskBranchMutation(c: any, cmd: RegisterTaskBranchCommand): Promise<{ branchName: string; headSha: string }> { const branchName = cmd.branchName.trim(); if (!branchName) { @@ -289,40 +402,23 @@ async function registerTaskBranchMutation(c: any, cmd: RegisterTaskBranchCommand } async function listTaskSummaries(c: any, includeArchived = false): Promise { - const taskRows = await c.db.select({ taskId: taskIndex.taskId }).from(taskIndex).orderBy(desc(taskIndex.updatedAt)).all(); - const records: TaskSummary[] = []; + const rows = await c.db.select().from(tasks).orderBy(desc(tasks.updatedAtMs)).all(); + return rows + .map((row) => ({ + organizationId: c.state.organizationId, + repoId: c.state.repoId, + taskId: row.taskId, + branchName: row.branch ?? null, + title: row.title, + status: row.status, + updatedAt: row.updatedAtMs, + })) + .filter((row) => includeArchived || row.status !== "archived"); +} - for (const row of taskRows) { - try { - const record = await getTask(c, c.state.organizationId, c.state.repoId, row.taskId).get(); - if (!includeArchived && record.status === "archived") { - continue; - } - records.push({ - organizationId: record.organizationId, - repoId: record.repoId, - taskId: record.taskId, - branchName: record.branchName, - title: record.title, - status: record.status, - updatedAt: record.updatedAt, - }); - } catch (error) { - if (isStaleTaskReferenceError(error)) { - await deleteStaleTaskIndexRow(c, row.taskId); - continue; - } - logActorWarning("repository", "failed loading task summary row", { - organizationId: c.state.organizationId, - repoId: c.state.repoId, - taskId: row.taskId, - error: resolveErrorMessage(error), - }); - } - } - - records.sort((a, b) => b.updatedAt - a.updatedAt); - return records; +async function listWorkspaceTaskSummaries(c: any): Promise { + const rows = await c.db.select().from(tasks).orderBy(desc(tasks.updatedAtMs)).all(); + return rows.map(workspaceTaskSummaryFromRow); } function sortOverviewBranches( @@ -415,38 +511,12 @@ export const repositoryActions = { return await listKnownTaskBranches(c); }, - async registerTaskBranch(c: any, cmd: RegisterTaskBranchCommand): Promise<{ branchName: string; headSha: string }> { - const self = selfRepository(c); - return expectQueueResponse<{ branchName: string; headSha: string }>( - await self.send(repositoryWorkflowQueueName("repository.command.registerTaskBranch"), cmd, { - wait: true, - timeout: 10_000, - }), - ); - }, - async listTaskSummaries(c: any, cmd?: ListTaskSummariesCommand): Promise { return await listTaskSummaries(c, cmd?.includeArchived === true); }, - async getTaskEnriched(c: any, cmd: GetTaskEnrichedCommand): Promise { - const row = await c.db.select({ taskId: taskIndex.taskId }).from(taskIndex).where(eq(taskIndex.taskId, cmd.taskId)).get(); - if (!row) { - const record = await getTask(c, c.state.organizationId, c.state.repoId, cmd.taskId).get(); - await reinsertTaskIndexRow(c, cmd.taskId, record.branchName ?? null, record.updatedAt ?? Date.now()); - return await enrichTaskRecord(c, record); - } - - try { - const record = await getTask(c, c.state.organizationId, c.state.repoId, cmd.taskId).get(); - return await enrichTaskRecord(c, record); - } catch (error) { - if (isStaleTaskReferenceError(error)) { - await deleteStaleTaskIndexRow(c, cmd.taskId); - throw new Error(`Unknown task in repo ${c.state.repoId}: ${cmd.taskId}`); - } - throw error; - } + async listWorkspaceTaskSummaries(c: any): Promise { + return await listWorkspaceTaskSummaries(c); }, async getRepositoryMetadata(c: any): Promise<{ defaultBranch: string | null; fullName: string | null; remoteUrl: string }> { @@ -468,34 +538,23 @@ export const repositoryActions = { const prRows = await githubData.listPullRequestsForRepository({ repoId: c.state.repoId }).catch(() => []); const prByBranch = new Map(prRows.map((row) => [row.headRefName, row])); - const taskRows = await c.db - .select({ - taskId: taskIndex.taskId, - branchName: taskIndex.branchName, - updatedAt: taskIndex.updatedAt, - }) - .from(taskIndex) - .all(); + const taskRows = await c.db.select().from(tasks).all(); - const taskMetaByBranch = new Map(); + const taskMetaByBranch = new Map< + string, + { taskId: string; title: string | null; status: TaskRecord["status"] | null; updatedAt: number; pullRequest: WorkspacePullRequestSummary | null } + >(); for (const row of taskRows) { - if (!row.branchName) { + if (!row.branch) { continue; } - try { - const record = await getTask(c, c.state.organizationId, c.state.repoId, row.taskId).get(); - taskMetaByBranch.set(row.branchName, { - taskId: row.taskId, - title: record.title ?? null, - status: record.status, - updatedAt: record.updatedAt, - }); - } catch (error) { - if (isStaleTaskReferenceError(error)) { - await deleteStaleTaskIndexRow(c, row.taskId); - continue; - } - } + taskMetaByBranch.set(row.branch, { + taskId: row.taskId, + title: row.title ?? null, + status: row.status, + updatedAt: row.updatedAtMs, + pullRequest: parseJsonValue(row.pullRequestJson, null), + }); } const branchMap = new Map(); @@ -514,7 +573,7 @@ export const repositoryActions = { const branches = sortOverviewBranches( [...branchMap.values()].map((branch) => { const taskMeta = taskMetaByBranch.get(branch.branchName); - const pr = prByBranch.get(branch.branchName); + const pr = taskMeta?.pullRequest ?? prByBranch.get(branch.branchName) ?? null; return { branchName: branch.branchName, commitSha: branch.commitSha, @@ -522,10 +581,10 @@ export const repositoryActions = { taskTitle: taskMeta?.title ?? null, taskStatus: taskMeta?.status ?? null, prNumber: pr?.number ?? null, - prState: pr?.state ?? null, - prUrl: pr?.url ?? null, + prState: "state" in (pr ?? {}) ? pr.state : null, + prUrl: "url" in (pr ?? {}) ? pr.url : null, ciStatus: null, - reviewStatus: null, + reviewStatus: pr && "isDraft" in pr ? (pr.isDraft ? "draft" : "ready") : null, reviewer: pr?.authorLogin ?? null, updatedAt: Math.max(taskMeta?.updatedAt ?? 0, pr?.updatedAtMs ?? 0, now), }; @@ -543,15 +602,51 @@ export const repositoryActions = { }; }, - async getPullRequestForBranch(c: any, cmd: GetPullRequestForBranchCommand): Promise<{ number: number; status: "draft" | "ready" } | null> { + async applyTaskSummaryUpdate(c: any, input: { taskSummary: WorkspaceTaskSummary }): Promise { + await upsertTaskSummary(c, input.taskSummary); + await notifyOrganizationSnapshotChanged(c); + }, + + async removeTaskSummary(c: any, input: { taskId: string }): Promise { + await c.db.delete(tasks).where(eq(tasks.taskId, input.taskId)).run(); + await notifyOrganizationSnapshotChanged(c); + }, + + async findTaskForGithubBranch(c: any, input: { branchName: string }): Promise<{ taskId: string | null }> { + const row = await c.db.select({ taskId: tasks.taskId }).from(tasks).where(eq(tasks.branch, input.branchName)).get(); + return { taskId: row?.taskId ?? null }; + }, + + async refreshTaskSummaryForGithubBranch(c: any, input: { branchName: string }): Promise { + const rows = await c.db.select({ taskId: tasks.taskId }).from(tasks).where(eq(tasks.branch, input.branchName)).all(); + + for (const row of rows) { + try { + const task = getTask(c, c.state.organizationId, c.state.repoId, row.taskId); + await upsertTaskSummary(c, await task.getTaskSummary({})); + } catch (error) { + logActorWarning("repository", "failed refreshing task summary for branch", { + organizationId: c.state.organizationId, + repoId: c.state.repoId, + branchName: input.branchName, + taskId: row.taskId, + error: resolveErrorMessage(error), + }); + } + } + + await notifyOrganizationSnapshotChanged(c); + }, + + async getPullRequestForBranch(c: any, cmd: GetPullRequestForBranchCommand): Promise { const branchName = cmd.branchName?.trim(); if (!branchName) { return null; } const githubData = getGithubData(c, c.state.organizationId); - return await githubData.getPullRequestForBranch({ + const rows = await githubData.listPullRequestsForRepository({ repoId: c.state.repoId, - branchName, }); + return rows.find((candidate: WorkspacePullRequestSummary) => candidate.headRefName === branchName) ?? null; }, }; diff --git a/foundry/packages/backend/src/actors/repository/db/migrations.ts b/foundry/packages/backend/src/actors/repository/db/migrations.ts index ebdb167..2bd8fa0 100644 --- a/foundry/packages/backend/src/actors/repository/db/migrations.ts +++ b/foundry/packages/backend/src/actors/repository/db/migrations.ts @@ -10,12 +10,6 @@ const journal = { tag: "0000_useful_la_nuit", breakpoints: true, }, - { - idx: 1, - when: 1778900000000, - tag: "0001_remove_local_git_state", - breakpoints: true, - }, ], } as const; @@ -23,21 +17,30 @@ export default { journal, migrations: { m0000: `CREATE TABLE \`repo_meta\` ( -\t\`id\` integer PRIMARY KEY NOT NULL, -\t\`remote_url\` text NOT NULL, -\t\`updated_at\` integer NOT NULL + \`id\` integer PRIMARY KEY NOT NULL, + \`remote_url\` text NOT NULL, + \`updated_at\` integer NOT NULL, + CONSTRAINT \`repo_meta_singleton_id_check\` CHECK(\`id\` = 1) ); --> statement-breakpoint CREATE TABLE \`task_index\` ( -\t\`task_id\` text PRIMARY KEY NOT NULL, -\t\`branch_name\` text, -\t\`created_at\` integer NOT NULL, -\t\`updated_at\` integer NOT NULL + \`task_id\` text PRIMARY KEY NOT NULL, + \`branch_name\` text, + \`created_at\` integer NOT NULL, + \`updated_at\` integer NOT NULL ); -`, - m0001: `DROP TABLE IF EXISTS \`branches\`; --> statement-breakpoint -DROP TABLE IF EXISTS \`repo_action_jobs\`; +CREATE TABLE \`tasks\` ( + \`task_id\` text PRIMARY KEY NOT NULL, + \`repo_id\` text NOT NULL, + \`title\` text NOT NULL, + \`status\` text NOT NULL, + \`repo_name\` text NOT NULL, + \`updated_at_ms\` integer NOT NULL, + \`branch\` text, + \`pull_request_json\` text, + \`sessions_summary_json\` text DEFAULT '[]' NOT NULL +); `, } as const, }; diff --git a/foundry/packages/backend/src/actors/repository/db/schema.ts b/foundry/packages/backend/src/actors/repository/db/schema.ts index 2f597e8..5c6c7ee 100644 --- a/foundry/packages/backend/src/actors/repository/db/schema.ts +++ b/foundry/packages/backend/src/actors/repository/db/schema.ts @@ -1,19 +1,23 @@ -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 repository actor instance (organizationId+repoId). -export const repoMeta = sqliteTable("repo_meta", { - id: integer("id").primaryKey(), - remoteUrl: text("remote_url").notNull(), - updatedAt: integer("updated_at").notNull(), -}); +export const repoMeta = sqliteTable( + "repo_meta", + { + id: integer("id").primaryKey(), + remoteUrl: text("remote_url").notNull(), + updatedAt: integer("updated_at").notNull(), + }, + (table) => [check("repo_meta_singleton_id_check", sql`${table.id} = 1`)], +); /** * Coordinator index of TaskActor instances. * The repository actor is the coordinator for tasks. Each row maps a - * taskId to its branch name. Used for branch conflict checking and - * task-by-branch lookups. Rows are inserted at task creation and - * updated on branch rename. + * taskId to its immutable branch name. Used for branch conflict checking + * and task-by-branch lookups. Rows are inserted at task creation. */ export const taskIndex = sqliteTable("task_index", { taskId: text("task_id").notNull().primaryKey(), @@ -21,3 +25,35 @@ export const taskIndex = sqliteTable("task_index", { createdAt: integer("created_at").notNull(), updatedAt: integer("updated_at").notNull(), }); + +/** + * Repository-owned materialized task summary projection. + * Task actors push summary updates to their direct repository coordinator, + * which keeps this table local for fast list/lookups without fan-out. + */ +export const tasks = sqliteTable("tasks", { + taskId: text("task_id").notNull().primaryKey(), + title: text("title").notNull(), + status: text("status").notNull(), + repoName: text("repo_name").notNull(), + updatedAtMs: integer("updated_at_ms").notNull(), + branch: text("branch"), + pullRequestJson: text("pull_request_json"), + sessionsSummaryJson: text("sessions_summary_json").notNull().default("[]"), +}); + +/** + * Materialized task summary projection owned by the repository coordinator. + * Task actors push updates here; organization reads fan in through repositories. + */ +export const tasks = sqliteTable("tasks", { + taskId: text("task_id").notNull().primaryKey(), + repoId: text("repo_id").notNull(), + title: text("title").notNull(), + status: text("status").notNull(), + repoName: text("repo_name").notNull(), + updatedAtMs: integer("updated_at_ms").notNull(), + branch: text("branch"), + pullRequestJson: text("pull_request_json"), + sessionsSummaryJson: text("sessions_summary_json").notNull().default("[]"), +}); diff --git a/foundry/packages/backend/src/actors/task/db/drizzle/0000_charming_maestro.sql b/foundry/packages/backend/src/actors/task/db/drizzle/0000_charming_maestro.sql index b9ef95a..5d2c028 100644 --- a/foundry/packages/backend/src/actors/task/db/drizzle/0000_charming_maestro.sql +++ b/foundry/packages/backend/src/actors/task/db/drizzle/0000_charming_maestro.sql @@ -3,7 +3,7 @@ CREATE TABLE `task` ( `branch_name` text, `title` text, `task` text NOT NULL, - `provider_id` text NOT NULL, + `sandbox_provider_id` text NOT NULL, `status` text NOT NULL, `agent_type` text DEFAULT 'claude', `pr_submitted` integer DEFAULT 0, @@ -19,13 +19,17 @@ CREATE TABLE `task_runtime` ( `active_switch_target` text, `active_cwd` text, `status_message` text, + `git_state_json` text, + `git_state_updated_at` integer, + `provision_stage` text, + `provision_stage_updated_at` integer, `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_provider_id` text NOT NULL, `sandbox_actor_id` text, `switch_target` text NOT NULL, `cwd` text, @@ -34,10 +38,15 @@ CREATE TABLE `task_sandboxes` ( `updated_at` integer NOT NULL ); --> statement-breakpoint -CREATE TABLE `task_workbench_sessions` ( +CREATE TABLE `task_workspace_sessions` ( `session_id` text PRIMARY KEY NOT NULL, + `sandbox_session_id` text, `session_name` text NOT NULL, `model` text NOT NULL, + `status` text DEFAULT 'ready' NOT NULL, + `error_message` text, + `transcript_json` text DEFAULT '[]' NOT NULL, + `transcript_updated_at` integer, `unread` integer DEFAULT 0 NOT NULL, `draft_text` text DEFAULT '' NOT NULL, `draft_attachments_json` text DEFAULT '[]' NOT NULL, diff --git a/foundry/packages/backend/src/actors/task/db/drizzle/meta/0000_snapshot.json b/foundry/packages/backend/src/actors/task/db/drizzle/meta/0000_snapshot.json index b8a5879..be587c4 100644 --- a/foundry/packages/backend/src/actors/task/db/drizzle/meta/0000_snapshot.json +++ b/foundry/packages/backend/src/actors/task/db/drizzle/meta/0000_snapshot.json @@ -221,8 +221,8 @@ "uniqueConstraints": {}, "checkConstraints": {} }, - "task_workbench_sessions": { - "name": "task_workbench_sessions", + "task_workspace_sessions": { + "name": "task_workspace_sessions", "columns": { "session_id": { "name": "session_id", diff --git a/foundry/packages/backend/src/actors/task/db/migrations.ts b/foundry/packages/backend/src/actors/task/db/migrations.ts index dc3193e..141786e 100644 --- a/foundry/packages/backend/src/actors/task/db/migrations.ts +++ b/foundry/packages/backend/src/actors/task/db/migrations.ts @@ -10,12 +10,6 @@ const journal = { tag: "0000_charming_maestro", breakpoints: true, }, - { - idx: 1, - when: 1773810000000, - tag: "0001_sandbox_provider_columns", - breakpoints: true, - }, ], } as const; @@ -27,10 +21,8 @@ export default { \`branch_name\` text, \`title\` text, \`task\` text NOT NULL, - \`provider_id\` text NOT NULL, + \`sandbox_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) @@ -39,17 +31,17 @@ export default { 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, + \`git_state_json\` text, + \`git_state_updated_at\` integer, \`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_provider_id\` text NOT NULL, \`sandbox_actor_id\` text, \`switch_target\` text NOT NULL, \`cwd\` text, @@ -58,24 +50,21 @@ CREATE TABLE \`task_sandboxes\` ( \`updated_at\` integer NOT NULL ); --> statement-breakpoint -CREATE TABLE \`task_workbench_sessions\` ( +CREATE TABLE \`task_workspace_sessions\` ( \`session_id\` text PRIMARY KEY NOT NULL, + \`sandbox_session_id\` text, \`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, + \`status\` text DEFAULT 'ready' NOT NULL, + \`error_message\` text, + \`transcript_json\` text DEFAULT '[]' NOT NULL, + \`transcript_updated_at\` integer, \`created\` integer DEFAULT 1 NOT NULL, \`closed\` integer DEFAULT 0 NOT NULL, \`thinking_since_ms\` integer, -\`created_at\` integer NOT NULL, + \`created_at\` integer NOT NULL, \`updated_at\` integer NOT NULL ); -`, - m0001: `ALTER TABLE \`task\` RENAME COLUMN \`provider_id\` TO \`sandbox_provider_id\`; ---> statement-breakpoint -ALTER TABLE \`task_sandboxes\` RENAME COLUMN \`provider_id\` TO \`sandbox_provider_id\`; `, } as const, }; diff --git a/foundry/packages/backend/src/actors/task/db/schema.ts b/foundry/packages/backend/src/actors/task/db/schema.ts index 889aa31..8dd5e6a 100644 --- a/foundry/packages/backend/src/actors/task/db/schema.ts +++ b/foundry/packages/backend/src/actors/task/db/schema.ts @@ -11,8 +11,6 @@ export const task = sqliteTable( task: text("task").notNull(), sandboxProviderId: text("sandbox_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(), }, @@ -24,14 +22,10 @@ export const taskRuntime = sqliteTable( { 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"), gitStateJson: text("git_state_json"), gitStateUpdatedAt: integer("git_state_updated_at"), - provisionStage: text("provision_stage"), - provisionStageUpdatedAt: integer("provision_stage_updated_at"), updatedAt: integer("updated_at").notNull(), }, (table) => [check("task_runtime_singleton_id_check", sql`${table.id} = 1`)], @@ -54,12 +48,12 @@ export const taskSandboxes = sqliteTable("task_sandboxes", { }); /** - * Coordinator index of workbench sessions within this task. + * Coordinator index of workspace sessions within this task. * The task actor is the coordinator for sessions. Each row holds session * metadata, model, status, transcript, and draft state. Sessions are * sub-entities of the task — no separate session actor in the DB. */ -export const taskWorkbenchSessions = sqliteTable("task_workbench_sessions", { +export const taskWorkspaceSessions = sqliteTable("task_workspace_sessions", { sessionId: text("session_id").notNull().primaryKey(), sandboxSessionId: text("sandbox_session_id"), sessionName: text("session_name").notNull(), @@ -68,11 +62,6 @@ export const taskWorkbenchSessions = sqliteTable("task_workbench_sessions", { errorMessage: text("error_message"), transcriptJson: text("transcript_json").notNull().default("[]"), transcriptUpdatedAt: integer("transcript_updated_at"), - 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), closed: integer("closed").notNull().default(0), thinkingSinceMs: integer("thinking_since_ms"), diff --git a/foundry/packages/backend/src/actors/task/index.ts b/foundry/packages/backend/src/actors/task/index.ts index f2b9e51..e961e90 100644 --- a/foundry/packages/backend/src/actors/task/index.ts +++ b/foundry/packages/backend/src/actors/task/index.ts @@ -1,14 +1,13 @@ import { actor, queue } from "rivetkit"; import { workflow } from "rivetkit/workflow"; import type { - AgentType, TaskRecord, - TaskWorkbenchChangeModelInput, - TaskWorkbenchRenameInput, - TaskWorkbenchRenameSessionInput, - TaskWorkbenchSetSessionUnreadInput, - TaskWorkbenchSendMessageInput, - TaskWorkbenchUpdateDraftInput, + TaskWorkspaceChangeModelInput, + TaskWorkspaceRenameInput, + TaskWorkspaceRenameSessionInput, + TaskWorkspaceSetSessionUnreadInput, + TaskWorkspaceSendMessageInput, + TaskWorkspaceUpdateDraftInput, SandboxProviderId, } from "@sandbox-agent/foundry-shared"; import { expectQueueResponse } from "../../services/queue.js"; @@ -16,24 +15,23 @@ import { selfTask } from "../handles.js"; import { taskDb } from "./db/db.js"; import { getCurrentRecord } from "./workflow/common.js"; import { - changeWorkbenchModel, - closeWorkbenchSession, - createWorkbenchSession, + changeWorkspaceModel, + closeWorkspaceSession, + createWorkspaceSession, getSessionDetail, getTaskDetail, getTaskSummary, - markWorkbenchUnread, - publishWorkbenchPr, - renameWorkbenchBranch, - renameWorkbenchTask, - renameWorkbenchSession, - revertWorkbenchFile, - sendWorkbenchMessage, - syncWorkbenchSessionStatus, - setWorkbenchSessionUnread, - stopWorkbenchSession, - updateWorkbenchDraft, -} from "./workbench.js"; + markWorkspaceUnread, + publishWorkspacePr, + renameWorkspaceTask, + renameWorkspaceSession, + revertWorkspaceFile, + sendWorkspaceMessage, + syncWorkspaceSessionStatus, + setWorkspaceSessionUnread, + stopWorkspaceSession, + updateWorkspaceDraft, +} from "./workspace.js"; import { TASK_QUEUE_NAMES, taskWorkflowQueueName, runTaskWorkflow } from "./workflow/index.js"; export interface TaskInput { @@ -45,10 +43,8 @@ export interface TaskInput { title: string | null; task: string; sandboxProviderId: SandboxProviderId; - agentType: AgentType | null; explicitTitle: string | null; explicitBranchName: string | null; - initialPrompt: string | null; } interface InitializeCommand { @@ -69,48 +65,57 @@ interface TaskStatusSyncCommand { at: number; } -interface TaskWorkbenchValueCommand { +interface TaskWorkspaceValueCommand { value: string; + authSessionId?: string; } -interface TaskWorkbenchSessionTitleCommand { +interface TaskWorkspaceSessionTitleCommand { sessionId: string; title: string; + authSessionId?: string; } -interface TaskWorkbenchSessionUnreadCommand { +interface TaskWorkspaceSessionUnreadCommand { sessionId: string; unread: boolean; + authSessionId?: string; } -interface TaskWorkbenchUpdateDraftCommand { +interface TaskWorkspaceUpdateDraftCommand { sessionId: string; text: string; attachments: Array; + authSessionId?: string; } -interface TaskWorkbenchChangeModelCommand { +interface TaskWorkspaceChangeModelCommand { sessionId: string; model: string; + authSessionId?: string; } -interface TaskWorkbenchSendMessageCommand { +interface TaskWorkspaceSendMessageCommand { sessionId: string; text: string; attachments: Array; + authSessionId?: string; } -interface TaskWorkbenchCreateSessionCommand { +interface TaskWorkspaceCreateSessionCommand { model?: string; + authSessionId?: string; } -interface TaskWorkbenchCreateSessionAndSendCommand { +interface TaskWorkspaceCreateSessionAndSendCommand { model?: string; text: string; + authSessionId?: string; } -interface TaskWorkbenchSessionCommand { +interface TaskWorkspaceSessionCommand { sessionId: string; + authSessionId?: string; } export const task = actor({ @@ -126,16 +131,6 @@ export const task = actor({ repoId: input.repoId, taskId: input.taskId, repoRemote: input.repoRemote, - branchName: input.branchName, - title: input.title, - task: input.task, - sandboxProviderId: input.sandboxProviderId, - agentType: input.agentType, - explicitTitle: input.explicitTitle, - explicitBranchName: input.explicitBranchName, - initialPrompt: input.initialPrompt, - initialized: false, - previousStatus: null as string | null, }), actions: { async initialize(c, cmd: InitializeCommand): Promise { @@ -220,19 +215,19 @@ export const task = actor({ return await getTaskSummary(c); }, - async getTaskDetail(c) { - return await getTaskDetail(c); + async getTaskDetail(c, input?: { authSessionId?: string }) { + return await getTaskDetail(c, input?.authSessionId); }, - async getSessionDetail(c, input: { sessionId: string }) { - return await getSessionDetail(c, input.sessionId); + async getSessionDetail(c, input: { sessionId: string; authSessionId?: string }) { + return await getSessionDetail(c, input.sessionId, input.authSessionId); }, - async markWorkbenchUnread(c): Promise { + async markWorkspaceUnread(c, input?: { authSessionId?: string }): Promise { const self = selfTask(c); await self.send( - taskWorkflowQueueName("task.command.workbench.mark_unread"), - {}, + taskWorkflowQueueName("task.command.workspace.mark_unread"), + { authSessionId: input?.authSessionId }, { wait: true, timeout: 10_000, @@ -240,26 +235,26 @@ export const task = actor({ ); }, - async renameWorkbenchTask(c, input: TaskWorkbenchRenameInput): Promise { + async renameWorkspaceTask(c, input: TaskWorkspaceRenameInput): Promise { const self = selfTask(c); - await self.send(taskWorkflowQueueName("task.command.workbench.rename_task"), { value: input.value } satisfies TaskWorkbenchValueCommand, { - wait: true, - timeout: 20_000, - }); + await self.send( + taskWorkflowQueueName("task.command.workspace.rename_task"), + { value: input.value, authSessionId: input.authSessionId } satisfies TaskWorkspaceValueCommand, + { + wait: true, + timeout: 20_000, + }, + ); }, - async renameWorkbenchBranch(c, input: TaskWorkbenchRenameInput): Promise { - const self = selfTask(c); - await self.send(taskWorkflowQueueName("task.command.workbench.rename_branch"), { value: input.value } satisfies TaskWorkbenchValueCommand, { - wait: false, - }); - }, - - async createWorkbenchSession(c, input?: { model?: string }): Promise<{ sessionId: string }> { + async createWorkspaceSession(c, input?: { model?: string; authSessionId?: string }): Promise<{ sessionId: string }> { const self = selfTask(c); const result = await self.send( - taskWorkflowQueueName("task.command.workbench.create_session"), - { ...(input?.model ? { model: input.model } : {}) } satisfies TaskWorkbenchCreateSessionCommand, + taskWorkflowQueueName("task.command.workspace.create_session"), + { + ...(input?.model ? { model: input.model } : {}), + ...(input?.authSessionId ? { authSessionId: input.authSessionId } : {}), + } satisfies TaskWorkspaceCreateSessionCommand, { wait: true, timeout: 10_000, @@ -269,23 +264,23 @@ export const task = actor({ }, /** - * Fire-and-forget: creates a workbench session and sends the initial message. - * Used by createWorkbenchTask so the caller doesn't block on session creation. + * Fire-and-forget: creates a session and sends the initial message. + * Used by createWorkspaceTask so the caller doesn't block on session creation. */ - async createWorkbenchSessionAndSend(c, input: { model?: string; text: string }): Promise { + async createWorkspaceSessionAndSend(c, input: { model?: string; text: string; authSessionId?: string }): Promise { const self = selfTask(c); await self.send( - taskWorkflowQueueName("task.command.workbench.create_session_and_send"), - { model: input.model, text: input.text } satisfies TaskWorkbenchCreateSessionAndSendCommand, + taskWorkflowQueueName("task.command.workspace.create_session_and_send"), + { model: input.model, text: input.text, authSessionId: input.authSessionId } satisfies TaskWorkspaceCreateSessionAndSendCommand, { wait: false }, ); }, - async renameWorkbenchSession(c, input: TaskWorkbenchRenameSessionInput): Promise { + async renameWorkspaceSession(c, input: TaskWorkspaceRenameSessionInput): Promise { const self = selfTask(c); await self.send( - taskWorkflowQueueName("task.command.workbench.rename_session"), - { sessionId: input.sessionId, title: input.title } satisfies TaskWorkbenchSessionTitleCommand, + taskWorkflowQueueName("task.command.workspace.rename_session"), + { sessionId: input.sessionId, title: input.title, authSessionId: input.authSessionId } satisfies TaskWorkspaceSessionTitleCommand, { wait: true, timeout: 10_000, @@ -293,11 +288,11 @@ export const task = actor({ ); }, - async setWorkbenchSessionUnread(c, input: TaskWorkbenchSetSessionUnreadInput): Promise { + async setWorkspaceSessionUnread(c, input: TaskWorkspaceSetSessionUnreadInput): Promise { const self = selfTask(c); await self.send( - taskWorkflowQueueName("task.command.workbench.set_session_unread"), - { sessionId: input.sessionId, unread: input.unread } satisfies TaskWorkbenchSessionUnreadCommand, + taskWorkflowQueueName("task.command.workspace.set_session_unread"), + { sessionId: input.sessionId, unread: input.unread, authSessionId: input.authSessionId } satisfies TaskWorkspaceSessionUnreadCommand, { wait: true, timeout: 10_000, @@ -305,26 +300,27 @@ export const task = actor({ ); }, - async updateWorkbenchDraft(c, input: TaskWorkbenchUpdateDraftInput): Promise { + async updateWorkspaceDraft(c, input: TaskWorkspaceUpdateDraftInput): Promise { const self = selfTask(c); await self.send( - taskWorkflowQueueName("task.command.workbench.update_draft"), + taskWorkflowQueueName("task.command.workspace.update_draft"), { sessionId: input.sessionId, text: input.text, attachments: input.attachments, - } satisfies TaskWorkbenchUpdateDraftCommand, + authSessionId: input.authSessionId, + } satisfies TaskWorkspaceUpdateDraftCommand, { wait: false, }, ); }, - async changeWorkbenchModel(c, input: TaskWorkbenchChangeModelInput): Promise { + async changeWorkspaceModel(c, input: TaskWorkspaceChangeModelInput): Promise { const self = selfTask(c); await self.send( - taskWorkflowQueueName("task.command.workbench.change_model"), - { sessionId: input.sessionId, model: input.model } satisfies TaskWorkbenchChangeModelCommand, + taskWorkflowQueueName("task.command.workspace.change_model"), + { sessionId: input.sessionId, model: input.model, authSessionId: input.authSessionId } satisfies TaskWorkspaceChangeModelCommand, { wait: true, timeout: 10_000, @@ -332,47 +328,56 @@ export const task = actor({ ); }, - async sendWorkbenchMessage(c, input: TaskWorkbenchSendMessageInput): Promise { + async sendWorkspaceMessage(c, input: TaskWorkspaceSendMessageInput): Promise { const self = selfTask(c); await self.send( - taskWorkflowQueueName("task.command.workbench.send_message"), + taskWorkflowQueueName("task.command.workspace.send_message"), { sessionId: input.sessionId, text: input.text, attachments: input.attachments, - } satisfies TaskWorkbenchSendMessageCommand, + authSessionId: input.authSessionId, + } satisfies TaskWorkspaceSendMessageCommand, { wait: false, }, ); }, - async stopWorkbenchSession(c, input: TaskSessionCommand): Promise { + async stopWorkspaceSession(c, input: TaskSessionCommand): Promise { const self = selfTask(c); - await self.send(taskWorkflowQueueName("task.command.workbench.stop_session"), { sessionId: input.sessionId } satisfies TaskWorkbenchSessionCommand, { - wait: false, - }); + await self.send( + taskWorkflowQueueName("task.command.workspace.stop_session"), + { sessionId: input.sessionId, authSessionId: input.authSessionId } satisfies TaskWorkspaceSessionCommand, + { + wait: false, + }, + ); }, - async syncWorkbenchSessionStatus(c, input: TaskStatusSyncCommand): Promise { + async syncWorkspaceSessionStatus(c, input: TaskStatusSyncCommand): Promise { const self = selfTask(c); - await self.send(taskWorkflowQueueName("task.command.workbench.sync_session_status"), input, { + await self.send(taskWorkflowQueueName("task.command.workspace.sync_session_status"), input, { wait: true, timeout: 20_000, }); }, - async closeWorkbenchSession(c, input: TaskSessionCommand): Promise { - const self = selfTask(c); - await self.send(taskWorkflowQueueName("task.command.workbench.close_session"), { sessionId: input.sessionId } satisfies TaskWorkbenchSessionCommand, { - wait: false, - }); - }, - - async publishWorkbenchPr(c): Promise { + async closeWorkspaceSession(c, input: TaskSessionCommand): Promise { const self = selfTask(c); await self.send( - taskWorkflowQueueName("task.command.workbench.publish_pr"), + taskWorkflowQueueName("task.command.workspace.close_session"), + { sessionId: input.sessionId, authSessionId: input.authSessionId } satisfies TaskWorkspaceSessionCommand, + { + wait: false, + }, + ); + }, + + async publishWorkspacePr(c): Promise { + const self = selfTask(c); + await self.send( + taskWorkflowQueueName("task.command.workspace.publish_pr"), {}, { wait: false, @@ -380,9 +385,9 @@ export const task = actor({ ); }, - async revertWorkbenchFile(c, input: { path: string }): Promise { + async revertWorkspaceFile(c, input: { path: string }): Promise { const self = selfTask(c); - await self.send(taskWorkflowQueueName("task.command.workbench.revert_file"), input, { + await self.send(taskWorkflowQueueName("task.command.workspace.revert_file"), input, { wait: false, }); }, diff --git a/foundry/packages/backend/src/actors/task/workflow/commands.ts b/foundry/packages/backend/src/actors/task/workflow/commands.ts index d03ade1..d963df0 100644 --- a/foundry/packages/backend/src/actors/task/workflow/commands.ts +++ b/foundry/packages/backend/src/actors/task/workflow/commands.ts @@ -2,8 +2,8 @@ import { eq } from "drizzle-orm"; import { getTaskSandbox } from "../../handles.js"; import { logActorWarning, resolveErrorMessage } from "../../logging.js"; -import { task as taskTable, taskRuntime } from "../db/schema.js"; -import { TASK_ROW_ID, appendHistory, getCurrentRecord, setTaskState } from "./common.js"; +import { task as taskTable } from "../db/schema.js"; +import { TASK_ROW_ID, appendAuditLog, getCurrentRecord, setTaskState } from "./common.js"; import { pushActiveBranchActivity } from "./push.js"; async function withTimeout(promise: Promise, timeoutMs: number, label: string): Promise { @@ -25,6 +25,7 @@ async function withTimeout(promise: Promise, timeoutMs: number, label: str export async function handleAttachActivity(loopCtx: any, msg: any): Promise { const record = await getCurrentRecord(loopCtx); let target = record.sandboxes.find((sandbox: any) => sandbox.sandboxId === record.activeSandboxId)?.switchTarget ?? ""; + const sessionId = msg.body?.sessionId ?? null; if (record.activeSandboxId) { try { @@ -38,14 +39,14 @@ export async function handleAttachActivity(loopCtx: any, msg: any): Promise await msg.complete({ ok: true }); } -export async function handleSimpleCommandActivity(loopCtx: any, msg: any, statusMessage: string, historyKind: string): Promise { - const db = loopCtx.db; - await db.update(taskRuntime).set({ statusMessage, updatedAt: Date.now() }).where(eq(taskRuntime.id, TASK_ROW_ID)).run(); - - await appendHistory(loopCtx, historyKind, { reason: msg.body?.reason ?? null }); +export async function handleSimpleCommandActivity(loopCtx: any, msg: any, _statusMessage: string, historyKind: string): Promise { + await appendAuditLog(loopCtx, historyKind, { reason: msg.body?.reason ?? null }); await msg.complete({ ok: true }); } export async function handleArchiveActivity(loopCtx: any, msg: any): Promise { - await setTaskState(loopCtx, "archive_stop_status_sync", "stopping status sync"); + await setTaskState(loopCtx, "archive_stop_status_sync"); const record = await getCurrentRecord(loopCtx); if (record.activeSandboxId) { - await setTaskState(loopCtx, "archive_release_sandbox", "releasing sandbox"); + await setTaskState(loopCtx, "archive_release_sandbox"); void withTimeout(getTaskSandbox(loopCtx, loopCtx.state.organizationId, record.activeSandboxId).destroy(), 45_000, "sandbox destroy").catch((error) => { logActorWarning("task.commands", "failed to release sandbox during archive", { organizationId: loopCtx.state.organizationId, @@ -90,17 +88,15 @@ export async function handleArchiveActivity(loopCtx: any, msg: any): Promise { - await setTaskState(loopCtx, "kill_destroy_sandbox", "destroying sandbox"); + await setTaskState(loopCtx, "kill_destroy_sandbox"); const record = await getCurrentRecord(loopCtx); if (!record.activeSandboxId) { return; @@ -110,13 +106,11 @@ export async function killDestroySandboxActivity(loopCtx: any): Promise { } export async function killWriteDbActivity(loopCtx: any, msg: any): Promise { - await setTaskState(loopCtx, "kill_finalize", "finalizing kill"); + await setTaskState(loopCtx, "kill_finalize"); const db = loopCtx.db; await db.update(taskTable).set({ status: "killed", updatedAt: Date.now() }).where(eq(taskTable.id, TASK_ROW_ID)).run(); - await db.update(taskRuntime).set({ statusMessage: "killed", updatedAt: Date.now() }).where(eq(taskRuntime.id, TASK_ROW_ID)).run(); - - await appendHistory(loopCtx, "task.kill", { reason: msg.body?.reason ?? null }); + await appendAuditLog(loopCtx, "task.kill", { reason: msg.body?.reason ?? null }); await msg.complete({ ok: true }); } diff --git a/foundry/packages/backend/src/actors/task/workflow/common.ts b/foundry/packages/backend/src/actors/task/workflow/common.ts index ae1e8dd..8a35071 100644 --- a/foundry/packages/backend/src/actors/task/workflow/common.ts +++ b/foundry/packages/backend/src/actors/task/workflow/common.ts @@ -2,8 +2,8 @@ import { eq } from "drizzle-orm"; import type { TaskRecord, TaskStatus } from "@sandbox-agent/foundry-shared"; import { task as taskTable, taskRuntime, taskSandboxes } from "../db/schema.js"; -import { historyKey } from "../../keys.js"; -import { broadcastTaskUpdate } from "../workbench.js"; +import { getOrCreateAuditLog } from "../../handles.js"; +import { broadcastTaskUpdate } from "../workspace.js"; export const TASK_ROW_ID = 1; @@ -56,33 +56,11 @@ export function buildAgentPrompt(task: string): string { return task.trim(); } -export async function setTaskState(ctx: any, status: TaskStatus, statusMessage?: string): Promise { +export async function setTaskState(ctx: any, status: TaskStatus): Promise { const now = Date.now(); const db = ctx.db; await db.update(taskTable).set({ status, updatedAt: now }).where(eq(taskTable.id, TASK_ROW_ID)).run(); - if (statusMessage != null) { - await db - .insert(taskRuntime) - .values({ - id: TASK_ROW_ID, - activeSandboxId: null, - activeSessionId: null, - activeSwitchTarget: null, - activeCwd: null, - statusMessage, - updatedAt: now, - }) - .onConflictDoUpdate({ - target: taskRuntime.id, - set: { - statusMessage, - updatedAt: now, - }, - }) - .run(); - } - await broadcastTaskUpdate(ctx); } @@ -95,11 +73,7 @@ export async function getCurrentRecord(ctx: any): Promise { task: taskTable.task, sandboxProviderId: taskTable.sandboxProviderId, status: taskTable.status, - statusMessage: taskRuntime.statusMessage, activeSandboxId: taskRuntime.activeSandboxId, - activeSessionId: taskRuntime.activeSessionId, - agentType: taskTable.agentType, - prSubmitted: taskTable.prSubmitted, createdAt: taskTable.createdAt, updatedAt: taskTable.updatedAt, }) @@ -135,9 +109,7 @@ export async function getCurrentRecord(ctx: any): Promise { task: row.task, sandboxProviderId: row.sandboxProviderId, status: row.status, - statusMessage: row.statusMessage ?? null, activeSandboxId: row.activeSandboxId ?? null, - activeSessionId: row.activeSessionId ?? null, sandboxes: sandboxes.map((sb) => ({ sandboxId: sb.sandboxId, sandboxProviderId: sb.sandboxProviderId, @@ -147,12 +119,7 @@ export async function getCurrentRecord(ctx: any): Promise { createdAt: sb.createdAt, updatedAt: sb.updatedAt, })), - agentType: row.agentType ?? null, - prSubmitted: Boolean(row.prSubmitted), diffStat: null, - hasUnpushed: null, - conflictsWithMain: null, - parentBranch: null, prUrl: null, prAuthor: null, ciStatus: null, @@ -163,17 +130,20 @@ export async function getCurrentRecord(ctx: any): Promise { } as TaskRecord; } -export async function appendHistory(ctx: any, kind: string, payload: Record): Promise { - const client = ctx.client(); - const history = await client.history.getOrCreate(historyKey(ctx.state.organizationId, ctx.state.repoId), { - createWithInput: { organizationId: ctx.state.organizationId, repoId: ctx.state.repoId }, - }); - await history.append({ - kind, - taskId: ctx.state.taskId, - branchName: ctx.state.branchName, - payload, - }); +export async function appendAuditLog(ctx: any, kind: string, payload: Record): Promise { + const auditLog = await getOrCreateAuditLog(ctx, ctx.state.organizationId, ctx.state.repoId); + await auditLog.send( + "auditLog.command.append", + { + kind, + taskId: ctx.state.taskId, + branchName: ctx.state.branchName, + payload, + }, + { + wait: false, + }, + ); await broadcastTaskUpdate(ctx); } diff --git a/foundry/packages/backend/src/actors/task/workflow/index.ts b/foundry/packages/backend/src/actors/task/workflow/index.ts index f6ffd10..2c7c02e 100644 --- a/foundry/packages/backend/src/actors/task/workflow/index.ts +++ b/foundry/packages/backend/src/actors/task/workflow/index.ts @@ -14,24 +14,23 @@ import { } from "./commands.js"; import { TASK_QUEUE_NAMES } from "./queue.js"; import { - changeWorkbenchModel, - closeWorkbenchSession, - createWorkbenchSession, - ensureWorkbenchSession, - refreshWorkbenchDerivedState, - refreshWorkbenchSessionTranscript, - markWorkbenchUnread, - publishWorkbenchPr, - renameWorkbenchBranch, - renameWorkbenchTask, - renameWorkbenchSession, - revertWorkbenchFile, - sendWorkbenchMessage, - setWorkbenchSessionUnread, - stopWorkbenchSession, - syncWorkbenchSessionStatus, - updateWorkbenchDraft, -} from "../workbench.js"; + changeWorkspaceModel, + closeWorkspaceSession, + createWorkspaceSession, + ensureWorkspaceSession, + refreshWorkspaceDerivedState, + refreshWorkspaceSessionTranscript, + markWorkspaceUnread, + publishWorkspacePr, + renameWorkspaceTask, + renameWorkspaceSession, + revertWorkspaceFile, + sendWorkspaceMessage, + setWorkspaceSessionUnread, + stopWorkspaceSession, + syncWorkspaceSessionStatus, + updateWorkspaceDraft, +} from "../workspace.js"; export { TASK_QUEUE_NAMES, taskWorkflowQueueName } from "./queue.js"; @@ -113,31 +112,22 @@ const commandHandlers: Record = { await loopCtx.step("handle-get", async () => handleGetActivity(loopCtx, msg)); }, - "task.command.workbench.mark_unread": async (loopCtx, msg) => { - await loopCtx.step("workbench-mark-unread", async () => markWorkbenchUnread(loopCtx)); + "task.command.workspace.mark_unread": async (loopCtx, msg) => { + await loopCtx.step("workspace-mark-unread", async () => markWorkspaceUnread(loopCtx)); await msg.complete({ ok: true }); }, - "task.command.workbench.rename_task": async (loopCtx, msg) => { - await loopCtx.step("workbench-rename-task", async () => renameWorkbenchTask(loopCtx, msg.body.value)); + "task.command.workspace.rename_task": async (loopCtx, msg) => { + await loopCtx.step("workspace-rename-task", async () => renameWorkspaceTask(loopCtx, msg.body.value)); await msg.complete({ ok: true }); }, - "task.command.workbench.rename_branch": async (loopCtx, msg) => { - await loopCtx.step({ - name: "workbench-rename-branch", - timeout: 5 * 60_000, - run: async () => renameWorkbenchBranch(loopCtx, msg.body.value), - }); - await msg.complete({ ok: true }); - }, - - "task.command.workbench.create_session": async (loopCtx, msg) => { + "task.command.workspace.create_session": async (loopCtx, msg) => { try { const created = await loopCtx.step({ - name: "workbench-create-session", + name: "workspace-create-session", timeout: 5 * 60_000, - run: async () => createWorkbenchSession(loopCtx, msg.body?.model), + run: async () => createWorkspaceSession(loopCtx, msg.body?.model), }); await msg.complete(created); } catch (error) { @@ -145,17 +135,17 @@ const commandHandlers: Record = { } }, - "task.command.workbench.create_session_and_send": async (loopCtx, msg) => { + "task.command.workspace.create_session_and_send": async (loopCtx, msg) => { try { const created = await loopCtx.step({ - name: "workbench-create-session-for-send", + name: "workspace-create-session-for-send", timeout: 5 * 60_000, - run: async () => createWorkbenchSession(loopCtx, msg.body?.model), + run: async () => createWorkspaceSession(loopCtx, msg.body?.model), }); await loopCtx.step({ - name: "workbench-send-initial-message", + name: "workspace-send-initial-message", timeout: 5 * 60_000, - run: async () => sendWorkbenchMessage(loopCtx, created.sessionId, msg.body.text, []), + run: async () => sendWorkspaceMessage(loopCtx, created.sessionId, msg.body.text, []), }); } catch (error) { logActorWarning("task.workflow", "create_session_and_send failed", { @@ -165,41 +155,41 @@ const commandHandlers: Record = { await msg.complete({ ok: true }); }, - "task.command.workbench.ensure_session": async (loopCtx, msg) => { + "task.command.workspace.ensure_session": async (loopCtx, msg) => { await loopCtx.step({ - name: "workbench-ensure-session", + name: "workspace-ensure-session", timeout: 5 * 60_000, - run: async () => ensureWorkbenchSession(loopCtx, msg.body.sessionId, msg.body?.model), + run: async () => ensureWorkspaceSession(loopCtx, msg.body.sessionId, msg.body?.model), }); await msg.complete({ ok: true }); }, - "task.command.workbench.rename_session": async (loopCtx, msg) => { - await loopCtx.step("workbench-rename-session", async () => renameWorkbenchSession(loopCtx, msg.body.sessionId, msg.body.title)); + "task.command.workspace.rename_session": async (loopCtx, msg) => { + await loopCtx.step("workspace-rename-session", async () => renameWorkspaceSession(loopCtx, msg.body.sessionId, msg.body.title)); await msg.complete({ ok: true }); }, - "task.command.workbench.set_session_unread": async (loopCtx, msg) => { - await loopCtx.step("workbench-set-session-unread", async () => setWorkbenchSessionUnread(loopCtx, msg.body.sessionId, msg.body.unread)); + "task.command.workspace.set_session_unread": async (loopCtx, msg) => { + await loopCtx.step("workspace-set-session-unread", async () => setWorkspaceSessionUnread(loopCtx, msg.body.sessionId, msg.body.unread)); await msg.complete({ ok: true }); }, - "task.command.workbench.update_draft": async (loopCtx, msg) => { - await loopCtx.step("workbench-update-draft", async () => updateWorkbenchDraft(loopCtx, msg.body.sessionId, msg.body.text, msg.body.attachments)); + "task.command.workspace.update_draft": async (loopCtx, msg) => { + await loopCtx.step("workspace-update-draft", async () => updateWorkspaceDraft(loopCtx, msg.body.sessionId, msg.body.text, msg.body.attachments)); await msg.complete({ ok: true }); }, - "task.command.workbench.change_model": async (loopCtx, msg) => { - await loopCtx.step("workbench-change-model", async () => changeWorkbenchModel(loopCtx, msg.body.sessionId, msg.body.model)); + "task.command.workspace.change_model": async (loopCtx, msg) => { + await loopCtx.step("workspace-change-model", async () => changeWorkspaceModel(loopCtx, msg.body.sessionId, msg.body.model)); await msg.complete({ ok: true }); }, - "task.command.workbench.send_message": async (loopCtx, msg) => { + "task.command.workspace.send_message": async (loopCtx, msg) => { try { await loopCtx.step({ - name: "workbench-send-message", + name: "workspace-send-message", timeout: 10 * 60_000, - run: async () => sendWorkbenchMessage(loopCtx, msg.body.sessionId, msg.body.text, msg.body.attachments), + run: async () => sendWorkspaceMessage(loopCtx, msg.body.sessionId, msg.body.text, msg.body.attachments), }); await msg.complete({ ok: true }); } catch (error) { @@ -207,61 +197,61 @@ const commandHandlers: Record = { } }, - "task.command.workbench.stop_session": async (loopCtx, msg) => { + "task.command.workspace.stop_session": async (loopCtx, msg) => { await loopCtx.step({ - name: "workbench-stop-session", + name: "workspace-stop-session", timeout: 5 * 60_000, - run: async () => stopWorkbenchSession(loopCtx, msg.body.sessionId), + run: async () => stopWorkspaceSession(loopCtx, msg.body.sessionId), }); await msg.complete({ ok: true }); }, - "task.command.workbench.sync_session_status": async (loopCtx, msg) => { - await loopCtx.step("workbench-sync-session-status", async () => syncWorkbenchSessionStatus(loopCtx, msg.body.sessionId, msg.body.status, msg.body.at)); + "task.command.workspace.sync_session_status": async (loopCtx, msg) => { + await loopCtx.step("workspace-sync-session-status", async () => syncWorkspaceSessionStatus(loopCtx, msg.body.sessionId, msg.body.status, msg.body.at)); await msg.complete({ ok: true }); }, - "task.command.workbench.refresh_derived": async (loopCtx, msg) => { + "task.command.workspace.refresh_derived": async (loopCtx, msg) => { await loopCtx.step({ - name: "workbench-refresh-derived", + name: "workspace-refresh-derived", timeout: 5 * 60_000, - run: async () => refreshWorkbenchDerivedState(loopCtx), + run: async () => refreshWorkspaceDerivedState(loopCtx), }); await msg.complete({ ok: true }); }, - "task.command.workbench.refresh_session_transcript": async (loopCtx, msg) => { + "task.command.workspace.refresh_session_transcript": async (loopCtx, msg) => { await loopCtx.step({ - name: "workbench-refresh-session-transcript", + name: "workspace-refresh-session-transcript", timeout: 60_000, - run: async () => refreshWorkbenchSessionTranscript(loopCtx, msg.body.sessionId), + run: async () => refreshWorkspaceSessionTranscript(loopCtx, msg.body.sessionId), }); await msg.complete({ ok: true }); }, - "task.command.workbench.close_session": async (loopCtx, msg) => { + "task.command.workspace.close_session": async (loopCtx, msg) => { await loopCtx.step({ - name: "workbench-close-session", + name: "workspace-close-session", timeout: 5 * 60_000, - run: async () => closeWorkbenchSession(loopCtx, msg.body.sessionId), + run: async () => closeWorkspaceSession(loopCtx, msg.body.sessionId), }); await msg.complete({ ok: true }); }, - "task.command.workbench.publish_pr": async (loopCtx, msg) => { + "task.command.workspace.publish_pr": async (loopCtx, msg) => { await loopCtx.step({ - name: "workbench-publish-pr", + name: "workspace-publish-pr", timeout: 10 * 60_000, - run: async () => publishWorkbenchPr(loopCtx), + run: async () => publishWorkspacePr(loopCtx), }); await msg.complete({ ok: true }); }, - "task.command.workbench.revert_file": async (loopCtx, msg) => { + "task.command.workspace.revert_file": async (loopCtx, msg) => { await loopCtx.step({ - name: "workbench-revert-file", + name: "workspace-revert-file", timeout: 5 * 60_000, - run: async () => revertWorkbenchFile(loopCtx, msg.body.path), + run: async () => revertWorkspaceFile(loopCtx, msg.body.path), }); await msg.complete({ ok: true }); }, diff --git a/foundry/packages/backend/src/actors/task/workflow/init.ts b/foundry/packages/backend/src/actors/task/workflow/init.ts index 8a9962d..68eca21 100644 --- a/foundry/packages/backend/src/actors/task/workflow/init.ts +++ b/foundry/packages/backend/src/actors/task/workflow/init.ts @@ -1,27 +1,18 @@ // @ts-nocheck import { eq } from "drizzle-orm"; import { getActorRuntimeContext } from "../../context.js"; -import { getOrCreateHistory, selfTask } from "../../handles.js"; +import { selfTask } from "../../handles.js"; import { resolveErrorMessage } from "../../logging.js"; import { defaultSandboxProviderId } from "../../../sandbox-config.js"; import { task as taskTable, taskRuntime } from "../db/schema.js"; -import { TASK_ROW_ID, appendHistory, collectErrorMessages, resolveErrorDetail, setTaskState } from "./common.js"; +import { TASK_ROW_ID, appendAuditLog, collectErrorMessages, resolveErrorDetail, setTaskState } from "./common.js"; import { taskWorkflowQueueName } from "./queue.js"; -async function ensureTaskRuntimeCacheColumns(db: any): Promise { - await db.execute(`ALTER TABLE task_runtime ADD COLUMN git_state_json text`).catch(() => {}); - await db.execute(`ALTER TABLE task_runtime ADD COLUMN git_state_updated_at integer`).catch(() => {}); - await db.execute(`ALTER TABLE task_runtime ADD COLUMN provision_stage text`).catch(() => {}); - await db.execute(`ALTER TABLE task_runtime ADD COLUMN provision_stage_updated_at integer`).catch(() => {}); -} - export async function initBootstrapDbActivity(loopCtx: any, body: any): Promise { const { config } = getActorRuntimeContext(); - const sandboxProviderId = body?.sandboxProviderId ?? loopCtx.state.sandboxProviderId ?? defaultSandboxProviderId(config); + const sandboxProviderId = body?.sandboxProviderId ?? defaultSandboxProviderId(config); const now = Date.now(); - await ensureTaskRuntimeCacheColumns(loopCtx.db); - await loopCtx.db .insert(taskTable) .values({ @@ -31,7 +22,6 @@ export async function initBootstrapDbActivity(loopCtx: any, body: any): Promise< task: loopCtx.state.task, sandboxProviderId, status: "init_bootstrap_db", - agentType: loopCtx.state.agentType ?? config.default_agent, createdAt: now, updatedAt: now, }) @@ -43,7 +33,6 @@ export async function initBootstrapDbActivity(loopCtx: any, body: any): Promise< task: loopCtx.state.task, sandboxProviderId, status: "init_bootstrap_db", - agentType: loopCtx.state.agentType ?? config.default_agent, updatedAt: now, }, }) @@ -54,26 +43,18 @@ export async function initBootstrapDbActivity(loopCtx: any, body: any): Promise< .values({ id: TASK_ROW_ID, activeSandboxId: null, - activeSessionId: null, activeSwitchTarget: null, activeCwd: null, - statusMessage: "provisioning", gitStateJson: null, gitStateUpdatedAt: null, - provisionStage: "queued", - provisionStageUpdatedAt: now, updatedAt: now, }) .onConflictDoUpdate({ target: taskRuntime.id, set: { activeSandboxId: null, - activeSessionId: null, activeSwitchTarget: null, activeCwd: null, - statusMessage: "provisioning", - provisionStage: "queued", - provisionStageUpdatedAt: now, updatedAt: now, }, }) @@ -81,16 +62,7 @@ export async function initBootstrapDbActivity(loopCtx: any, body: any): Promise< } export async function initEnqueueProvisionActivity(loopCtx: any, body: any): Promise { - await setTaskState(loopCtx, "init_enqueue_provision", "provision queued"); - await loopCtx.db - .update(taskRuntime) - .set({ - provisionStage: "queued", - provisionStageUpdatedAt: Date.now(), - updatedAt: Date.now(), - }) - .where(eq(taskRuntime.id, TASK_ROW_ID)) - .run(); + await setTaskState(loopCtx, "init_enqueue_provision"); const self = selfTask(loopCtx); try { @@ -111,29 +83,20 @@ export async function initEnqueueProvisionActivity(loopCtx: any, body: any): Pro export async function initCompleteActivity(loopCtx: any, body: any): Promise { const now = Date.now(); const { config } = getActorRuntimeContext(); - const sandboxProviderId = body?.sandboxProviderId ?? loopCtx.state.sandboxProviderId ?? defaultSandboxProviderId(config); + const sandboxProviderId = body?.sandboxProviderId ?? defaultSandboxProviderId(config); - await setTaskState(loopCtx, "init_complete", "task initialized"); + await setTaskState(loopCtx, "init_complete"); await loopCtx.db .update(taskRuntime) .set({ - statusMessage: "ready", - provisionStage: "ready", - provisionStageUpdatedAt: now, updatedAt: now, }) .where(eq(taskRuntime.id, TASK_ROW_ID)) .run(); - const history = await getOrCreateHistory(loopCtx, loopCtx.state.organizationId, loopCtx.state.repoId); - await history.append({ - kind: "task.initialized", - taskId: loopCtx.state.taskId, - branchName: loopCtx.state.branchName, + await appendAuditLog(loopCtx, "task.initialized", { payload: { sandboxProviderId }, }); - - loopCtx.state.initialized = true; } export async function initFailedActivity(loopCtx: any, error: unknown): Promise { @@ -141,7 +104,7 @@ export async function initFailedActivity(loopCtx: any, error: unknown): Promise< const detail = resolveErrorDetail(error); const messages = collectErrorMessages(error); const { config } = getActorRuntimeContext(); - const sandboxProviderId = loopCtx.state.sandboxProviderId ?? defaultSandboxProviderId(config); + const sandboxProviderId = defaultSandboxProviderId(config); await loopCtx.db .insert(taskTable) @@ -152,7 +115,6 @@ export async function initFailedActivity(loopCtx: any, error: unknown): Promise< task: loopCtx.state.task, sandboxProviderId, status: "error", - agentType: loopCtx.state.agentType ?? config.default_agent, createdAt: now, updatedAt: now, }) @@ -164,7 +126,6 @@ export async function initFailedActivity(loopCtx: any, error: unknown): Promise< task: loopCtx.state.task, sandboxProviderId, status: "error", - agentType: loopCtx.state.agentType ?? config.default_agent, updatedAt: now, }, }) @@ -175,30 +136,22 @@ export async function initFailedActivity(loopCtx: any, error: unknown): Promise< .values({ id: TASK_ROW_ID, activeSandboxId: null, - activeSessionId: null, activeSwitchTarget: null, activeCwd: null, - statusMessage: detail, - provisionStage: "error", - provisionStageUpdatedAt: now, updatedAt: now, }) .onConflictDoUpdate({ target: taskRuntime.id, set: { activeSandboxId: null, - activeSessionId: null, activeSwitchTarget: null, activeCwd: null, - statusMessage: detail, - provisionStage: "error", - provisionStageUpdatedAt: now, updatedAt: now, }, }) .run(); - await appendHistory(loopCtx, "task.error", { + await appendAuditLog(loopCtx, "task.error", { detail, messages, }); diff --git a/foundry/packages/backend/src/actors/task/workflow/push.ts b/foundry/packages/backend/src/actors/task/workflow/push.ts index c525ebe..c6aad6a 100644 --- a/foundry/packages/backend/src/actors/task/workflow/push.ts +++ b/foundry/packages/backend/src/actors/task/workflow/push.ts @@ -2,8 +2,8 @@ import { eq } from "drizzle-orm"; import { getTaskSandbox } from "../../handles.js"; import { resolveOrganizationGithubAuth } from "../../../services/github-auth.js"; -import { taskRuntime, taskSandboxes } from "../db/schema.js"; -import { TASK_ROW_ID, appendHistory, getCurrentRecord } from "./common.js"; +import { taskSandboxes } from "../db/schema.js"; +import { appendAuditLog, getCurrentRecord } from "./common.js"; export interface PushActiveBranchOptions { reason?: string | null; @@ -29,12 +29,6 @@ export async function pushActiveBranchActivity(loopCtx: any, options: PushActive } const now = Date.now(); - await loopCtx.db - .update(taskRuntime) - .set({ statusMessage: `pushing branch ${branchName}`, updatedAt: now }) - .where(eq(taskRuntime.id, TASK_ROW_ID)) - .run(); - await loopCtx.db .update(taskSandboxes) .set({ statusMessage: `pushing branch ${branchName}`, updatedAt: now }) @@ -69,19 +63,13 @@ export async function pushActiveBranchActivity(loopCtx: any, options: PushActive } const updatedAt = Date.now(); - await loopCtx.db - .update(taskRuntime) - .set({ statusMessage: `push complete for ${branchName}`, updatedAt }) - .where(eq(taskRuntime.id, TASK_ROW_ID)) - .run(); - await loopCtx.db .update(taskSandboxes) .set({ statusMessage: `push complete for ${branchName}`, updatedAt }) .where(eq(taskSandboxes.sandboxId, activeSandboxId)) .run(); - await appendHistory(loopCtx, options.historyKind ?? "task.push", { + await appendAuditLog(loopCtx, options.historyKind ?? "task.push", { reason: options.reason ?? null, branchName, sandboxId: activeSandboxId, diff --git a/foundry/packages/backend/src/actors/task/workflow/queue.ts b/foundry/packages/backend/src/actors/task/workflow/queue.ts index 3e613e2..29a38a4 100644 --- a/foundry/packages/backend/src/actors/task/workflow/queue.ts +++ b/foundry/packages/backend/src/actors/task/workflow/queue.ts @@ -9,24 +9,23 @@ export const TASK_QUEUE_NAMES = [ "task.command.archive", "task.command.kill", "task.command.get", - "task.command.workbench.mark_unread", - "task.command.workbench.rename_task", - "task.command.workbench.rename_branch", - "task.command.workbench.create_session", - "task.command.workbench.create_session_and_send", - "task.command.workbench.ensure_session", - "task.command.workbench.rename_session", - "task.command.workbench.set_session_unread", - "task.command.workbench.update_draft", - "task.command.workbench.change_model", - "task.command.workbench.send_message", - "task.command.workbench.stop_session", - "task.command.workbench.sync_session_status", - "task.command.workbench.refresh_derived", - "task.command.workbench.refresh_session_transcript", - "task.command.workbench.close_session", - "task.command.workbench.publish_pr", - "task.command.workbench.revert_file", + "task.command.workspace.mark_unread", + "task.command.workspace.rename_task", + "task.command.workspace.create_session", + "task.command.workspace.create_session_and_send", + "task.command.workspace.ensure_session", + "task.command.workspace.rename_session", + "task.command.workspace.set_session_unread", + "task.command.workspace.update_draft", + "task.command.workspace.change_model", + "task.command.workspace.send_message", + "task.command.workspace.stop_session", + "task.command.workspace.sync_session_status", + "task.command.workspace.refresh_derived", + "task.command.workspace.refresh_session_transcript", + "task.command.workspace.close_session", + "task.command.workspace.publish_pr", + "task.command.workspace.revert_file", ] as const; export function taskWorkflowQueueName(name: string): string { diff --git a/foundry/packages/backend/src/actors/task/workbench.ts b/foundry/packages/backend/src/actors/task/workspace.ts similarity index 81% rename from foundry/packages/backend/src/actors/task/workbench.ts rename to foundry/packages/backend/src/actors/task/workspace.ts index d6698ca..0777f25 100644 --- a/foundry/packages/backend/src/actors/task/workbench.ts +++ b/foundry/packages/backend/src/actors/task/workspace.ts @@ -3,12 +3,12 @@ import { randomUUID } from "node:crypto"; import { basename, dirname } from "node:path"; import { asc, eq } from "drizzle-orm"; import { getActorRuntimeContext } from "../context.js"; -import { getOrCreateRepository, getOrCreateTaskSandbox, getOrCreateOrganization, getTaskSandbox, selfTask } from "../handles.js"; +import { getOrCreateRepository, getOrCreateTaskSandbox, getTaskSandbox, selfTask } from "../handles.js"; import { SANDBOX_REPO_CWD } from "../sandbox/index.js"; import { resolveSandboxProviderId } from "../../sandbox-config.js"; import { resolveOrganizationGithubAuth } from "../../services/github-auth.js"; import { githubRepoFullNameFromRemote } from "../../services/repo.js"; -import { task as taskTable, taskRuntime, taskSandboxes, taskWorkbenchSessions } from "./db/schema.js"; +import { task as taskTable, taskRuntime, taskSandboxes, taskWorkspaceSessions } from "./db/schema.js"; import { getCurrentRecord } from "./workflow/common.js"; function emptyGitState() { @@ -20,42 +20,6 @@ function emptyGitState() { }; } -async function ensureWorkbenchSessionTable(c: any): Promise { - await c.db.execute(` - CREATE TABLE IF NOT EXISTS task_workbench_sessions ( - session_id text PRIMARY KEY NOT NULL, - sandbox_session_id text, - session_name text NOT NULL, - model text NOT NULL, - status text DEFAULT 'ready' NOT NULL, - error_message text, - transcript_json text DEFAULT '[]' NOT NULL, - transcript_updated_at integer, - 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 - ) - `); - await c.db.execute(`ALTER TABLE task_workbench_sessions ADD COLUMN sandbox_session_id text`).catch(() => {}); - await c.db.execute(`ALTER TABLE task_workbench_sessions ADD COLUMN status text DEFAULT 'ready' NOT NULL`).catch(() => {}); - await c.db.execute(`ALTER TABLE task_workbench_sessions ADD COLUMN error_message text`).catch(() => {}); - await c.db.execute(`ALTER TABLE task_workbench_sessions ADD COLUMN transcript_json text DEFAULT '[]' NOT NULL`).catch(() => {}); - await c.db.execute(`ALTER TABLE task_workbench_sessions ADD COLUMN transcript_updated_at integer`).catch(() => {}); -} - -async function ensureTaskRuntimeCacheColumns(c: any): Promise { - await c.db.execute(`ALTER TABLE task_runtime ADD COLUMN git_state_json text`).catch(() => {}); - await c.db.execute(`ALTER TABLE task_runtime ADD COLUMN git_state_updated_at integer`).catch(() => {}); - await c.db.execute(`ALTER TABLE task_runtime ADD COLUMN provision_stage text`).catch(() => {}); - await c.db.execute(`ALTER TABLE task_runtime ADD COLUMN provision_stage_updated_at integer`).catch(() => {}); -} - function defaultModelForAgent(agentType: string | null | undefined) { return agentType === "codex" ? "gpt-5.3-codex" : "claude-sonnet-4"; } @@ -168,8 +132,7 @@ export function shouldRecreateSessionForModelChange(meta: { } async function listSessionMetaRows(c: any, options?: { includeClosed?: boolean }): Promise> { - await ensureWorkbenchSessionTable(c); - const rows = await c.db.select().from(taskWorkbenchSessions).orderBy(asc(taskWorkbenchSessions.createdAt)).all(); + const rows = await c.db.select().from(taskWorkspaceSessions).orderBy(asc(taskWorkspaceSessions.createdAt)).all(); const mapped = rows.map((row: any) => ({ ...row, id: row.sessionId, @@ -199,8 +162,7 @@ async function nextSessionName(c: any): Promise { } async function readSessionMeta(c: any, sessionId: string): Promise { - await ensureWorkbenchSessionTable(c); - const row = await c.db.select().from(taskWorkbenchSessions).where(eq(taskWorkbenchSessions.sessionId, sessionId)).get(); + const row = await c.db.select().from(taskWorkspaceSessions).where(eq(taskWorkspaceSessions.sessionId, sessionId)).get(); if (!row) { return null; @@ -236,7 +198,6 @@ async function ensureSessionMeta( errorMessage?: string | null; }, ): Promise { - await ensureWorkbenchSessionTable(c); const existing = await readSessionMeta(c, params.sessionId); if (existing) { return existing; @@ -248,7 +209,7 @@ async function ensureSessionMeta( const unread = params.unread ?? false; await c.db - .insert(taskWorkbenchSessions) + .insert(taskWorkspaceSessions) .values({ sessionId: params.sessionId, sandboxSessionId: params.sandboxSessionId ?? null, @@ -276,19 +237,18 @@ async function ensureSessionMeta( async function updateSessionMeta(c: any, sessionId: string, values: Record): Promise { await ensureSessionMeta(c, { sessionId }); await c.db - .update(taskWorkbenchSessions) + .update(taskWorkspaceSessions) .set({ ...values, updatedAt: Date.now(), }) - .where(eq(taskWorkbenchSessions.sessionId, sessionId)) + .where(eq(taskWorkspaceSessions.sessionId, sessionId)) .run(); return await readSessionMeta(c, sessionId); } async function readSessionMetaBySandboxSessionId(c: any, sandboxSessionId: string): Promise { - await ensureWorkbenchSessionTable(c); - const row = await c.db.select().from(taskWorkbenchSessions).where(eq(taskWorkbenchSessions.sandboxSessionId, sandboxSessionId)).get(); + const row = await c.db.select().from(taskWorkspaceSessions).where(eq(taskWorkspaceSessions.sandboxSessionId, sandboxSessionId)).get(); if (!row) { return null; } @@ -298,17 +258,17 @@ async function readSessionMetaBySandboxSessionId(c: any, sandboxSessionId: strin async function requireReadySessionMeta(c: any, sessionId: string): Promise { const meta = await readSessionMeta(c, sessionId); if (!meta) { - throw new Error(`Unknown workbench session: ${sessionId}`); + throw new Error(`Unknown workspace session: ${sessionId}`); } if (meta.status !== "ready" || !meta.sandboxSessionId) { - throw new Error(meta.errorMessage ?? "This workbench session is still preparing"); + throw new Error(meta.errorMessage ?? "This workspace session is still preparing"); } return meta; } export function requireSendableSessionMeta(meta: any, sessionId: string): any { if (!meta) { - throw new Error(`Unknown workbench session: ${sessionId}`); + throw new Error(`Unknown workspace session: ${sessionId}`); } if (meta.status !== "ready" || !meta.sandboxSessionId) { throw new Error(`Session is not ready (status: ${meta.status}). Wait for session provisioning to complete.`); @@ -389,7 +349,7 @@ async function getTaskSandboxRuntime( /** * Track whether the sandbox repo has been fully prepared (cloned + fetched + checked out) * for the current actor lifecycle. Subsequent calls can skip the expensive `git fetch` - * when `skipFetch` is true (used by sendWorkbenchMessage to avoid blocking on every prompt). + * when `skipFetch` is true (used by sendWorkspaceMessage to avoid blocking on every prompt). */ let sandboxRepoPrepared = false; @@ -452,7 +412,7 @@ async function executeInSandbox( label: string; }, ): Promise<{ exitCode: number; result: string }> { - const record = await ensureWorkbenchSeeded(c); + const record = await ensureWorkspaceSeeded(c); const runtime = await getTaskSandboxRuntime(c, record); await ensureSandboxRepo(c, runtime.sandbox, record); const response = await runtime.sandbox.runProcess({ @@ -555,7 +515,7 @@ function buildFileTree(paths: string[]): Array { return sortNodes(root.children.values()); } -async function collectWorkbenchGitState(c: any, record: any) { +async function collectWorkspaceGitState(c: any, record: any) { const activeSandboxId = record.activeSandboxId; const activeSandbox = activeSandboxId != null ? ((record.sandboxes ?? []).find((candidate: any) => candidate.sandboxId === activeSandboxId) ?? null) : null; const cwd = activeSandbox?.cwd ?? record.sandboxes?.[0]?.cwd ?? null; @@ -628,7 +588,6 @@ async function collectWorkbenchGitState(c: any, record: any) { } async function readCachedGitState(c: any): Promise<{ fileChanges: Array; diffs: Record; fileTree: Array; updatedAt: number | null }> { - await ensureTaskRuntimeCacheColumns(c); const row = await c.db .select({ gitStateJson: taskRuntime.gitStateJson, @@ -645,7 +604,6 @@ async function readCachedGitState(c: any): Promise<{ fileChanges: Array; di } async function writeCachedGitState(c: any, gitState: { fileChanges: Array; diffs: Record; fileTree: Array }): Promise { - await ensureTaskRuntimeCacheColumns(c); const now = Date.now(); await c.db .update(taskRuntime) @@ -687,19 +645,19 @@ async function writeSessionTranscript(c: any, sessionId: string, transcript: Arr }); } -async function enqueueWorkbenchRefresh( +async function enqueueWorkspaceRefresh( c: any, - command: "task.command.workbench.refresh_derived" | "task.command.workbench.refresh_session_transcript", + command: "task.command.workspace.refresh_derived" | "task.command.workspace.refresh_session_transcript", body: Record, ): Promise { const self = selfTask(c); await self.send(command, body, { wait: false }); } -async function enqueueWorkbenchEnsureSession(c: any, sessionId: string): Promise { +async function enqueueWorkspaceEnsureSession(c: any, sessionId: string): Promise { const self = selfTask(c); await self.send( - "task.command.workbench.ensure_session", + "task.command.workspace.ensure_session", { sessionId, }, @@ -709,21 +667,21 @@ async function enqueueWorkbenchEnsureSession(c: any, sessionId: string): Promise ); } -function pendingWorkbenchSessionStatus(record: any): "pending_provision" | "pending_session_create" { +function pendingWorkspaceSessionStatus(record: any): "pending_provision" | "pending_session_create" { return record.activeSandboxId ? "pending_session_create" : "pending_provision"; } -async function maybeScheduleWorkbenchRefreshes(c: any, record: any, sessions: Array): Promise { +async function maybeScheduleWorkspaceRefreshes(c: any, record: any, sessions: Array): Promise { const gitState = await readCachedGitState(c); if (record.activeSandboxId && !gitState.updatedAt) { - await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_derived", {}); + await enqueueWorkspaceRefresh(c, "task.command.workspace.refresh_derived", {}); } for (const session of sessions) { if (session.closed || session.status !== "ready" || !session.sandboxSessionId || session.transcriptUpdatedAt) { continue; } - await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_session_transcript", { + await enqueueWorkspaceRefresh(c, "task.command.workspace.refresh_session_transcript", { sessionId: session.sandboxSessionId, }); } @@ -756,8 +714,7 @@ async function readPullRequestSummary(c: any, branchName: string | null) { } } -export async function ensureWorkbenchSeeded(c: any): Promise { - await ensureTaskRuntimeCacheColumns(c); +export async function ensureWorkspaceSeeded(c: any): Promise { const record = await getCurrentRecord({ db: c.db, state: c.state }); if (record.activeSessionId) { await ensureSessionMeta(c, { @@ -826,13 +783,13 @@ function buildSessionDetailFromMeta(record: any, meta: any): any { } /** - * Builds a WorkbenchTaskSummary from local task actor state. Task actors push + * Builds a WorkspaceTaskSummary from local task actor state. Task actors push * this to the parent organization actor so organization sidebar reads stay local. */ export async function buildTaskSummary(c: any): Promise { - const record = await ensureWorkbenchSeeded(c); + const record = await ensureWorkspaceSeeded(c); const sessions = await listSessionMetaRows(c); - await maybeScheduleWorkbenchRefreshes(c, record, sessions); + await maybeScheduleWorkspaceRefreshes(c, record, sessions); return { id: c.state.taskId, @@ -848,14 +805,14 @@ export async function buildTaskSummary(c: any): Promise { } /** - * Builds a WorkbenchTaskDetail from local task actor state for direct task + * Builds a WorkspaceTaskDetail from local task actor state for direct task * subscribers. This is a full replacement payload, not a patch. */ export async function buildTaskDetail(c: any): Promise { - const record = await ensureWorkbenchSeeded(c); + const record = await ensureWorkspaceSeeded(c); const gitState = await readCachedGitState(c); const sessions = await listSessionMetaRows(c); - await maybeScheduleWorkbenchRefreshes(c, record, sessions); + await maybeScheduleWorkspaceRefreshes(c, record, sessions); const summary = await buildTaskSummary(c); return { @@ -882,13 +839,13 @@ export async function buildTaskDetail(c: any): Promise { } /** - * Builds a WorkbenchSessionDetail for a specific session. + * Builds a WorkspaceSessionDetail for a specific session. */ export async function buildSessionDetail(c: any, sessionId: string): Promise { - const record = await ensureWorkbenchSeeded(c); + const record = await ensureWorkspaceSeeded(c); const meta = await readSessionMeta(c, sessionId); if (!meta || meta.closed) { - throw new Error(`Unknown workbench session: ${sessionId}`); + throw new Error(`Unknown workspace session: ${sessionId}`); } if (!meta.sandboxSessionId) { @@ -925,7 +882,7 @@ export async function getSessionDetail(c: any, sessionId: string): Promise } /** - * Replaces the old notifyWorkbenchUpdated pattern. + * Replaces the old notifyWorkspaceUpdated pattern. * * The task actor emits two kinds of updates: * - Push summary state up to the parent organization actor so the sidebar @@ -933,10 +890,10 @@ export async function getSessionDetail(c: any, sessionId: string): Promise * - Broadcast full detail/session payloads down to direct task subscribers. */ export async function broadcastTaskUpdate(c: any, options?: { sessionId?: string }): Promise { - const organization = await getOrCreateOrganization(c, c.state.organizationId); - await organization.applyTaskSummaryUpdate({ taskSummary: await buildTaskSummary(c) }); + const repository = await getOrCreateRepository(c, c.state.organizationId, c.state.repoId, c.state.repoRemote); + await repository.applyTaskSummaryUpdate({ taskSummary: await buildTaskSummary(c) }); c.broadcast("taskUpdated", { - type: "taskDetailUpdated", + type: "taskUpdated", detail: await buildTaskDetail(c), }); @@ -948,15 +905,15 @@ export async function broadcastTaskUpdate(c: any, options?: { sessionId?: string } } -export async function refreshWorkbenchDerivedState(c: any): Promise { - const record = await ensureWorkbenchSeeded(c); - const gitState = await collectWorkbenchGitState(c, record); +export async function refreshWorkspaceDerivedState(c: any): Promise { + const record = await ensureWorkspaceSeeded(c); + const gitState = await collectWorkspaceGitState(c, record); await writeCachedGitState(c, gitState); await broadcastTaskUpdate(c); } -export async function refreshWorkbenchSessionTranscript(c: any, sessionId: string): Promise { - const record = await ensureWorkbenchSeeded(c); +export async function refreshWorkspaceSessionTranscript(c: any, sessionId: string): Promise { + const record = await ensureWorkspaceSeeded(c); const meta = (await readSessionMetaBySandboxSessionId(c, sessionId)) ?? (await readSessionMeta(c, sessionId)); if (!meta?.sandboxSessionId) { return; @@ -967,7 +924,7 @@ export async function refreshWorkbenchSessionTranscript(c: any, sessionId: strin await broadcastTaskUpdate(c, { sessionId: meta.sessionId }); } -export async function renameWorkbenchTask(c: any, value: string): Promise { +export async function renameWorkspaceTask(c: any, value: string): Promise { const nextTitle = value.trim(); if (!nextTitle) { throw new Error("task title is required"); @@ -985,81 +942,30 @@ export async function renameWorkbenchTask(c: any, value: string): Promise await broadcastTaskUpdate(c); } -export async function renameWorkbenchBranch(c: any, value: string): Promise { - const nextBranch = value.trim(); - if (!nextBranch) { - throw new Error("branch name is required"); - } - - const record = await ensureWorkbenchSeeded(c); - if (!record.branchName) { - throw new Error("cannot rename branch before task branch exists"); - } - if (!record.activeSandboxId) { - throw new Error("cannot rename branch without an active sandbox"); - } - const activeSandbox = (record.sandboxes ?? []).find((candidate: any) => candidate.sandboxId === record.activeSandboxId) ?? null; - if (!activeSandbox?.cwd) { - throw new Error("cannot rename branch without a sandbox cwd"); - } - - const renameResult = await executeInSandbox(c, { - sandboxId: record.activeSandboxId, - cwd: activeSandbox.cwd, - command: [ - `git branch -m ${JSON.stringify(record.branchName)} ${JSON.stringify(nextBranch)}`, - `if git ls-remote --exit-code --heads origin ${JSON.stringify(record.branchName)} >/dev/null 2>&1; then git push origin :${JSON.stringify(record.branchName)}; fi`, - `git push origin ${JSON.stringify(nextBranch)}`, - `git branch --set-upstream-to=${JSON.stringify(`origin/${nextBranch}`)} ${JSON.stringify(nextBranch)} || git push --set-upstream origin ${JSON.stringify(nextBranch)}`, - ].join(" && "), - label: `git branch -m ${record.branchName} ${nextBranch}`, - }); - if (renameResult.exitCode !== 0) { - throw new Error(`branch rename failed (${renameResult.exitCode}): ${renameResult.result}`); - } - - await c.db - .update(taskTable) - .set({ - branchName: nextBranch, - updatedAt: Date.now(), - }) - .where(eq(taskTable.id, 1)) - .run(); - c.state.branchName = nextBranch; - - const repository = await getOrCreateRepository(c, c.state.organizationId, c.state.repoId, c.state.repoRemote); - await repository.registerTaskBranch({ - taskId: c.state.taskId, - branchName: nextBranch, - }); - await broadcastTaskUpdate(c); -} - -export async function createWorkbenchSession(c: any, model?: string): Promise<{ sessionId: string }> { +export async function createWorkspaceSession(c: any, model?: string): Promise<{ sessionId: string }> { const sessionId = `session-${randomUUID()}`; - const record = await ensureWorkbenchSeeded(c); + const record = await ensureWorkspaceSeeded(c); await ensureSessionMeta(c, { sessionId, model: model ?? defaultModelForAgent(record.agentType), sandboxSessionId: null, - status: pendingWorkbenchSessionStatus(record), + status: pendingWorkspaceSessionStatus(record), created: false, }); await broadcastTaskUpdate(c, { sessionId: sessionId }); - await enqueueWorkbenchEnsureSession(c, sessionId); + await enqueueWorkspaceEnsureSession(c, sessionId); return { sessionId }; } -export async function ensureWorkbenchSession(c: any, sessionId: string, model?: string): Promise { +export async function ensureWorkspaceSession(c: any, sessionId: string, model?: string): Promise { const meta = await readSessionMeta(c, sessionId); if (!meta || meta.closed) { return; } - const record = await ensureWorkbenchSeeded(c); + const record = await ensureWorkspaceSeeded(c); if (meta.sandboxSessionId && meta.status === "ready") { - await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_session_transcript", { + await enqueueWorkspaceRefresh(c, "task.command.workspace.refresh_session_transcript", { sessionId: meta.sandboxSessionId, }); await broadcastTaskUpdate(c, { sessionId: sessionId }); @@ -1089,7 +995,7 @@ export async function ensureWorkbenchSession(c: any, sessionId: string, model?: status: "ready", errorMessage: null, }); - await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_session_transcript", { + await enqueueWorkspaceRefresh(c, "task.command.workspace.refresh_session_transcript", { sessionId: meta.sandboxSessionId ?? sessionId, }); } catch (error) { @@ -1102,7 +1008,7 @@ export async function ensureWorkbenchSession(c: any, sessionId: string, model?: await broadcastTaskUpdate(c, { sessionId: sessionId }); } -export async function enqueuePendingWorkbenchSessions(c: any): Promise { +export async function enqueuePendingWorkspaceSessions(c: any): Promise { const self = selfTask(c); const pending = (await listSessionMetaRows(c, { includeClosed: true })).filter( (row) => row.closed !== true && row.status !== "ready" && row.status !== "error", @@ -1110,7 +1016,7 @@ export async function enqueuePendingWorkbenchSessions(c: any): Promise { for (const row of pending) { await self.send( - "task.command.workbench.ensure_session", + "task.command.workspace.ensure_session", { sessionId: row.sessionId, model: row.model, @@ -1122,7 +1028,7 @@ export async function enqueuePendingWorkbenchSessions(c: any): Promise { } } -export async function renameWorkbenchSession(c: any, sessionId: string, title: string): Promise { +export async function renameWorkspaceSession(c: any, sessionId: string, title: string): Promise { const trimmed = title.trim(); if (!trimmed) { throw new Error("session title is required"); @@ -1133,14 +1039,14 @@ export async function renameWorkbenchSession(c: any, sessionId: string, title: s await broadcastTaskUpdate(c, { sessionId }); } -export async function setWorkbenchSessionUnread(c: any, sessionId: string, unread: boolean): Promise { +export async function setWorkspaceSessionUnread(c: any, sessionId: string, unread: boolean): Promise { await updateSessionMeta(c, sessionId, { unread: unread ? 1 : 0, }); await broadcastTaskUpdate(c, { sessionId }); } -export async function updateWorkbenchDraft(c: any, sessionId: string, text: string, attachments: Array): Promise { +export async function updateWorkspaceDraft(c: any, sessionId: string, text: string, attachments: Array): Promise { await updateSessionMeta(c, sessionId, { draftText: text, draftAttachmentsJson: JSON.stringify(attachments), @@ -1149,7 +1055,7 @@ export async function updateWorkbenchDraft(c: any, sessionId: string, text: stri await broadcastTaskUpdate(c, { sessionId }); } -export async function changeWorkbenchModel(c: any, sessionId: string, model: string): Promise { +export async function changeWorkspaceModel(c: any, sessionId: string, model: string): Promise { const meta = await readSessionMeta(c, sessionId); if (!meta || meta.closed) { return; @@ -1159,7 +1065,7 @@ export async function changeWorkbenchModel(c: any, sessionId: string, model: str return; } - const record = await ensureWorkbenchSeeded(c); + const record = await ensureWorkspaceSeeded(c); let nextMeta = await updateSessionMeta(c, sessionId, { model, }); @@ -1170,7 +1076,7 @@ export async function changeWorkbenchModel(c: any, sessionId: string, model: str await sandbox.destroySession(nextMeta.sandboxSessionId); nextMeta = await updateSessionMeta(c, sessionId, { sandboxSessionId: null, - status: pendingWorkbenchSessionStatus(record), + status: pendingWorkspaceSessionStatus(record), errorMessage: null, transcriptJson: "[]", transcriptUpdatedAt: null, @@ -1191,20 +1097,20 @@ export async function changeWorkbenchModel(c: any, sessionId: string, model: str } } else if (nextMeta.status !== "ready") { nextMeta = await updateSessionMeta(c, sessionId, { - status: pendingWorkbenchSessionStatus(record), + status: pendingWorkspaceSessionStatus(record), errorMessage: null, }); } if (shouldEnsure) { - await enqueueWorkbenchEnsureSession(c, sessionId); + await enqueueWorkspaceEnsureSession(c, sessionId); } await broadcastTaskUpdate(c, { sessionId }); } -export async function sendWorkbenchMessage(c: any, sessionId: string, text: string, attachments: Array): Promise { +export async function sendWorkspaceMessage(c: any, sessionId: string, text: string, attachments: Array): Promise { const meta = requireSendableSessionMeta(await readSessionMeta(c, sessionId), sessionId); - const record = await ensureWorkbenchSeeded(c); + const record = await ensureWorkspaceSeeded(c); const runtime = await getTaskSandboxRuntime(c, record); // Skip git fetch on subsequent messages — the repo was already prepared during session // creation. This avoids a 5-30s network round-trip to GitHub on every prompt. @@ -1234,25 +1140,25 @@ export async function sendWorkbenchMessage(c: any, sessionId: string, text: stri .where(eq(taskRuntime.id, 1)) .run(); - await syncWorkbenchSessionStatus(c, meta.sandboxSessionId, "running", Date.now()); + await syncWorkspaceSessionStatus(c, meta.sandboxSessionId, "running", Date.now()); try { await runtime.sandbox.sendPrompt({ sessionId: meta.sandboxSessionId, prompt: prompt.join("\n\n"), }); - await syncWorkbenchSessionStatus(c, meta.sandboxSessionId, "idle", Date.now()); + await syncWorkspaceSessionStatus(c, meta.sandboxSessionId, "idle", Date.now()); } catch (error) { await updateSessionMeta(c, sessionId, { status: "error", errorMessage: error instanceof Error ? error.message : String(error), }); - await syncWorkbenchSessionStatus(c, meta.sandboxSessionId, "error", Date.now()); + await syncWorkspaceSessionStatus(c, meta.sandboxSessionId, "error", Date.now()); throw error; } } -export async function stopWorkbenchSession(c: any, sessionId: string): Promise { +export async function stopWorkspaceSession(c: any, sessionId: string): Promise { const meta = await requireReadySessionMeta(c, sessionId); const sandbox = getTaskSandbox(c, c.state.organizationId, stableSandboxId(c)); await sandbox.destroySession(meta.sandboxSessionId); @@ -1262,8 +1168,8 @@ export async function stopWorkbenchSession(c: any, sessionId: string): Promise { - const record = await ensureWorkbenchSeeded(c); +export async function syncWorkspaceSessionStatus(c: any, sessionId: string, status: "running" | "idle" | "error", at: number): Promise { + const record = await ensureWorkspaceSeeded(c); const meta = (await readSessionMetaBySandboxSessionId(c, sessionId)) ?? (await ensureSessionMeta(c, { sessionId: sessionId, sandboxSessionId: sessionId })); let changed = false; @@ -1318,18 +1224,18 @@ export async function syncWorkbenchSessionStatus(c: any, sessionId: string, stat } if (changed) { - await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_session_transcript", { + await enqueueWorkspaceRefresh(c, "task.command.workspace.refresh_session_transcript", { sessionId, }); if (status !== "running") { - await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_derived", {}); + await enqueueWorkspaceRefresh(c, "task.command.workspace.refresh_derived", {}); } await broadcastTaskUpdate(c, { sessionId: meta.sessionId }); } } -export async function closeWorkbenchSession(c: any, sessionId: string): Promise { - const record = await ensureWorkbenchSeeded(c); +export async function closeWorkspaceSession(c: any, sessionId: string): Promise { + const record = await ensureWorkspaceSeeded(c); const sessions = await listSessionMetaRows(c); if (sessions.filter((candidate) => candidate.closed !== true).length <= 1) { return; @@ -1360,7 +1266,7 @@ export async function closeWorkbenchSession(c: any, sessionId: string): Promise< await broadcastTaskUpdate(c); } -export async function markWorkbenchUnread(c: any): Promise { +export async function markWorkspaceUnread(c: any): Promise { const sessions = await listSessionMetaRows(c); const latest = sessions[sessions.length - 1]; if (!latest) { @@ -1372,8 +1278,8 @@ export async function markWorkbenchUnread(c: any): Promise { await broadcastTaskUpdate(c, { sessionId: latest.sessionId }); } -export async function publishWorkbenchPr(c: any): Promise { - const record = await ensureWorkbenchSeeded(c); +export async function publishWorkspacePr(c: any): Promise { + const record = await ensureWorkspaceSeeded(c); if (!record.branchName) { throw new Error("cannot publish PR without a branch"); } @@ -1400,8 +1306,8 @@ export async function publishWorkbenchPr(c: any): Promise { await broadcastTaskUpdate(c); } -export async function revertWorkbenchFile(c: any, path: string): Promise { - const record = await ensureWorkbenchSeeded(c); +export async function revertWorkspaceFile(c: any, path: string): Promise { + const record = await ensureWorkspaceSeeded(c); if (!record.activeSandboxId) { throw new Error("cannot revert file without an active sandbox"); } @@ -1419,6 +1325,6 @@ export async function revertWorkbenchFile(c: any, path: string): Promise { if (result.exitCode !== 0) { throw new Error(`file revert failed (${result.exitCode}): ${result.result}`); } - await enqueueWorkbenchRefresh(c, "task.command.workbench.refresh_derived", {}); + await enqueueWorkspaceRefresh(c, "task.command.workspace.refresh_derived", {}); await broadcastTaskUpdate(c); } diff --git a/foundry/packages/backend/src/actors/history/db/db.ts b/foundry/packages/backend/src/actors/user/db/db.ts similarity index 70% rename from foundry/packages/backend/src/actors/history/db/db.ts rename to foundry/packages/backend/src/actors/user/db/db.ts index ef76e36..a864893 100644 --- a/foundry/packages/backend/src/actors/history/db/db.ts +++ b/foundry/packages/backend/src/actors/user/db/db.ts @@ -2,4 +2,4 @@ import { db } from "rivetkit/db/drizzle"; import * as schema from "./schema.js"; import migrations from "./migrations.js"; -export const historyDb = db({ schema, migrations }); +export const userDb = db({ schema, migrations }); diff --git a/foundry/packages/backend/src/actors/auth-user/db/migrations.ts b/foundry/packages/backend/src/actors/user/db/migrations.ts similarity index 72% rename from foundry/packages/backend/src/actors/auth-user/db/migrations.ts rename to foundry/packages/backend/src/actors/user/db/migrations.ts index be7cb17..06ab1d2 100644 --- a/foundry/packages/backend/src/actors/auth-user/db/migrations.ts +++ b/foundry/packages/backend/src/actors/user/db/migrations.ts @@ -10,6 +10,12 @@ const journal = { tag: "0000_auth_user", breakpoints: true, }, + { + idx: 1, + when: 1773532800000, + tag: "0001_user_task_state", + breakpoints: true, + }, ], } as const; @@ -58,23 +64,39 @@ CREATE TABLE \`account\` ( CREATE UNIQUE INDEX \`account_provider_account_idx\` ON \`account\` (\`provider_id\`, \`account_id\`); --> statement-breakpoint CREATE TABLE \`user_profiles\` ( - \`user_id\` text PRIMARY KEY NOT NULL, + \`id\` integer PRIMARY KEY NOT NULL, + \`user_id\` text NOT NULL, \`github_account_id\` text, \`github_login\` text, \`role_label\` text NOT NULL, + \`default_model\` text DEFAULT 'claude-sonnet-4' NOT NULL, \`eligible_organization_ids_json\` text NOT NULL, \`starter_repo_status\` text NOT NULL, \`starter_repo_starred_at\` integer, \`starter_repo_skipped_at\` integer, \`created_at\` integer NOT NULL, - \`updated_at\` integer NOT NULL + \`updated_at\` integer NOT NULL, + CONSTRAINT \`user_profiles_singleton_id_check\` CHECK(\`id\` = 1) ); --> statement-breakpoint +CREATE UNIQUE INDEX \`user_profiles_user_id_idx\` ON \`user_profiles\` (\`user_id\`); +--> statement-breakpoint CREATE TABLE \`session_state\` ( \`session_id\` text PRIMARY KEY NOT NULL, \`active_organization_id\` text, \`created_at\` integer NOT NULL, \`updated_at\` integer NOT NULL +);`, + m0001: `CREATE TABLE \`user_task_state\` ( + \`task_id\` text NOT NULL, + \`session_id\` text NOT NULL, + \`active_session_id\` text, + \`unread\` integer DEFAULT 0 NOT NULL, + \`draft_text\` text DEFAULT '' NOT NULL, + \`draft_attachments_json\` text DEFAULT '[]' NOT NULL, + \`draft_updated_at\` integer, + \`updated_at\` integer NOT NULL, + PRIMARY KEY(\`task_id\`, \`session_id\`) );`, } as const, }; diff --git a/foundry/packages/backend/src/actors/user/db/schema.ts b/foundry/packages/backend/src/actors/user/db/schema.ts new file mode 100644 index 0000000..81cf64e --- /dev/null +++ b/foundry/packages/backend/src/actors/user/db/schema.ts @@ -0,0 +1,103 @@ +import { check, integer, primaryKey, sqliteTable, text, uniqueIndex } from "drizzle-orm/sqlite-core"; +import { sql } from "drizzle-orm"; + +/** Better Auth core model — schema defined at https://better-auth.com/docs/concepts/database */ +export const authUsers = sqliteTable("user", { + id: text("id").notNull().primaryKey(), + name: text("name").notNull(), + email: text("email").notNull(), + emailVerified: integer("email_verified").notNull(), + image: text("image"), + createdAt: integer("created_at").notNull(), + updatedAt: integer("updated_at").notNull(), +}); + +/** Better Auth core model — schema defined at https://better-auth.com/docs/concepts/database */ +export const authSessions = sqliteTable( + "session", + { + id: text("id").notNull().primaryKey(), + token: text("token").notNull(), + userId: text("user_id").notNull(), + expiresAt: integer("expires_at").notNull(), + ipAddress: text("ip_address"), + userAgent: text("user_agent"), + createdAt: integer("created_at").notNull(), + updatedAt: integer("updated_at").notNull(), + }, + (table) => ({ + tokenIdx: uniqueIndex("session_token_idx").on(table.token), + }), +); + +/** Better Auth core model — schema defined at https://better-auth.com/docs/concepts/database */ +export const authAccounts = sqliteTable( + "account", + { + id: text("id").notNull().primaryKey(), + accountId: text("account_id").notNull(), + providerId: text("provider_id").notNull(), + userId: text("user_id").notNull(), + accessToken: text("access_token"), + refreshToken: text("refresh_token"), + idToken: text("id_token"), + accessTokenExpiresAt: integer("access_token_expires_at"), + refreshTokenExpiresAt: integer("refresh_token_expires_at"), + scope: text("scope"), + password: text("password"), + createdAt: integer("created_at").notNull(), + updatedAt: integer("updated_at").notNull(), + }, + (table) => ({ + providerAccountIdx: uniqueIndex("account_provider_account_idx").on(table.providerId, table.accountId), + }), +); + +/** Custom Foundry table — not part of Better Auth. */ +export const userProfiles = sqliteTable( + "user_profiles", + { + id: integer("id").primaryKey(), + userId: text("user_id").notNull(), + githubAccountId: text("github_account_id"), + githubLogin: text("github_login"), + roleLabel: text("role_label").notNull(), + defaultModel: text("default_model").notNull().default("claude-sonnet-4"), + eligibleOrganizationIdsJson: text("eligible_organization_ids_json").notNull(), + starterRepoStatus: text("starter_repo_status").notNull(), + starterRepoStarredAt: integer("starter_repo_starred_at"), + starterRepoSkippedAt: integer("starter_repo_skipped_at"), + createdAt: integer("created_at").notNull(), + updatedAt: integer("updated_at").notNull(), + }, + (table) => ({ + userIdIdx: uniqueIndex("user_profiles_user_id_idx").on(table.userId), + singletonCheck: check("user_profiles_singleton_id_check", sql`${table.id} = 1`), + }), +); + +/** Custom Foundry table — not part of Better Auth. */ +export const sessionState = sqliteTable("session_state", { + sessionId: text("session_id").notNull().primaryKey(), + activeOrganizationId: text("active_organization_id"), + createdAt: integer("created_at").notNull(), + updatedAt: integer("updated_at").notNull(), +}); + +/** Custom Foundry table — not part of Better Auth. Stores per-user task/session UI state. */ +export const userTaskState = sqliteTable( + "user_task_state", + { + taskId: text("task_id").notNull(), + sessionId: text("session_id").notNull(), + activeSessionId: text("active_session_id"), + unread: integer("unread").notNull().default(0), + draftText: text("draft_text").notNull().default(""), + draftAttachmentsJson: text("draft_attachments_json").notNull().default("[]"), + draftUpdatedAt: integer("draft_updated_at"), + updatedAt: integer("updated_at").notNull(), + }, + (table) => ({ + pk: primaryKey({ columns: [table.taskId, table.sessionId] }), + }), +); diff --git a/foundry/packages/backend/src/actors/auth-user/index.ts b/foundry/packages/backend/src/actors/user/index.ts similarity index 68% rename from foundry/packages/backend/src/actors/auth-user/index.ts rename to foundry/packages/backend/src/actors/user/index.ts index a77635a..41d1663 100644 --- a/foundry/packages/backend/src/actors/auth-user/index.ts +++ b/foundry/packages/backend/src/actors/user/index.ts @@ -1,7 +1,7 @@ import { and, asc, count as sqlCount, desc, eq, gt, gte, inArray, isNotNull, isNull, like, lt, lte, ne, notInArray, or } from "drizzle-orm"; import { actor } from "rivetkit"; -import { authUserDb } from "./db/db.js"; -import { authAccounts, authSessions, authUsers, sessionState, userProfiles } from "./db/schema.js"; +import { userDb } from "./db/db.js"; +import { authAccounts, authSessions, authUsers, sessionState, userProfiles, userTaskState } from "./db/schema.js"; const tables = { user: authUsers, @@ -9,12 +9,13 @@ const tables = { account: authAccounts, userProfiles, sessionState, + userTaskState, } as const; function tableFor(model: string) { const table = tables[model as keyof typeof tables]; if (!table) { - throw new Error(`Unsupported auth user model: ${model}`); + throw new Error(`Unsupported user model: ${model}`); } return table as any; } @@ -22,7 +23,7 @@ function tableFor(model: string) { function columnFor(table: any, field: string) { const column = table[field]; if (!column) { - throw new Error(`Unsupported auth user field: ${field}`); + throw new Error(`Unsupported user field: ${field}`); } return column; } @@ -150,10 +151,10 @@ async function applyJoinToRows(c: any, model: string, rows: any[], join: any) { return rows; } -export const authUser = actor({ - db: authUserDb, +export const user = actor({ + db: userDb, options: { - name: "Auth User", + name: "User", icon: "shield", actionTimeout: 60_000, }, @@ -161,6 +162,8 @@ export const authUser = actor({ userId: input.userId, }), actions: { + // Better Auth adapter action — called by the Better Auth adapter in better-auth.ts. + // Schema and behavior are constrained by Better Auth. async createAuthRecord(c, input: { model: string; data: Record }) { const table = tableFor(input.model); await c.db @@ -174,6 +177,8 @@ export const authUser = actor({ .get(); }, + // Better Auth adapter action — called by the Better Auth adapter in better-auth.ts. + // Schema and behavior are constrained by Better Auth. async findOneAuthRecord(c, input: { model: string; where: any[]; join?: any }) { const table = tableFor(input.model); const predicate = buildWhere(table, input.where); @@ -181,6 +186,8 @@ export const authUser = actor({ return await applyJoinToRow(c, input.model, row ?? null, input.join); }, + // Better Auth adapter action — called by the Better Auth adapter in better-auth.ts. + // Schema and behavior are constrained by Better Auth. async findManyAuthRecords(c, input: { model: string; where?: any[]; limit?: number; offset?: number; sortBy?: any; join?: any }) { const table = tableFor(input.model); const predicate = buildWhere(table, input.where); @@ -202,6 +209,8 @@ export const authUser = actor({ return await applyJoinToRows(c, input.model, rows, input.join); }, + // Better Auth adapter action — called by the Better Auth adapter in better-auth.ts. + // Schema and behavior are constrained by Better Auth. async updateAuthRecord(c, input: { model: string; where: any[]; update: Record }) { const table = tableFor(input.model); const predicate = buildWhere(table, input.where); @@ -216,6 +225,8 @@ export const authUser = actor({ return await c.db.select().from(table).where(predicate).get(); }, + // Better Auth adapter action — called by the Better Auth adapter in better-auth.ts. + // Schema and behavior are constrained by Better Auth. async updateManyAuthRecords(c, input: { model: string; where: any[]; update: Record }) { const table = tableFor(input.model); const predicate = buildWhere(table, input.where); @@ -231,6 +242,8 @@ export const authUser = actor({ return row?.value ?? 0; }, + // Better Auth adapter action — called by the Better Auth adapter in better-auth.ts. + // Schema and behavior are constrained by Better Auth. async deleteAuthRecord(c, input: { model: string; where: any[] }) { const table = tableFor(input.model); const predicate = buildWhere(table, input.where); @@ -240,6 +253,8 @@ export const authUser = actor({ await c.db.delete(table).where(predicate).run(); }, + // Better Auth adapter action — called by the Better Auth adapter in better-auth.ts. + // Schema and behavior are constrained by Better Auth. async deleteManyAuthRecords(c, input: { model: string; where: any[] }) { const table = tableFor(input.model); const predicate = buildWhere(table, input.where); @@ -251,6 +266,8 @@ export const authUser = actor({ return rows.length; }, + // Better Auth adapter action — called by the Better Auth adapter in better-auth.ts. + // Schema and behavior are constrained by Better Auth. async countAuthRecords(c, input: { model: string; where?: any[] }) { const table = tableFor(input.model); const predicate = buildWhere(table, input.where); @@ -260,6 +277,7 @@ export const authUser = actor({ return row?.value ?? 0; }, + // Custom Foundry action — not part of Better Auth. async getAppAuthState(c, input: { sessionId: string }) { const session = await c.db.select().from(authSessions).where(eq(authSessions.id, input.sessionId)).get(); if (!session) { @@ -280,6 +298,7 @@ export const authUser = actor({ }; }, + // Custom Foundry action — not part of Better Auth. async upsertUserProfile( c, input: { @@ -288,6 +307,7 @@ export const authUser = actor({ githubAccountId?: string | null; githubLogin?: string | null; roleLabel?: string; + defaultModel?: string; eligibleOrganizationIdsJson?: string; starterRepoStatus?: string; starterRepoStarredAt?: number | null; @@ -299,10 +319,12 @@ export const authUser = actor({ await c.db .insert(userProfiles) .values({ + id: 1, userId: input.userId, githubAccountId: input.patch.githubAccountId ?? null, githubLogin: input.patch.githubLogin ?? null, roleLabel: input.patch.roleLabel ?? "GitHub user", + defaultModel: input.patch.defaultModel ?? "claude-sonnet-4", eligibleOrganizationIdsJson: input.patch.eligibleOrganizationIdsJson ?? "[]", starterRepoStatus: input.patch.starterRepoStatus ?? "pending", starterRepoStarredAt: input.patch.starterRepoStarredAt ?? null, @@ -316,6 +338,7 @@ export const authUser = actor({ ...(input.patch.githubAccountId !== undefined ? { githubAccountId: input.patch.githubAccountId } : {}), ...(input.patch.githubLogin !== undefined ? { githubLogin: input.patch.githubLogin } : {}), ...(input.patch.roleLabel !== undefined ? { roleLabel: input.patch.roleLabel } : {}), + ...(input.patch.defaultModel !== undefined ? { defaultModel: input.patch.defaultModel } : {}), ...(input.patch.eligibleOrganizationIdsJson !== undefined ? { eligibleOrganizationIdsJson: input.patch.eligibleOrganizationIdsJson } : {}), ...(input.patch.starterRepoStatus !== undefined ? { starterRepoStatus: input.patch.starterRepoStatus } : {}), ...(input.patch.starterRepoStarredAt !== undefined ? { starterRepoStarredAt: input.patch.starterRepoStarredAt } : {}), @@ -328,6 +351,7 @@ export const authUser = actor({ return await c.db.select().from(userProfiles).where(eq(userProfiles.userId, input.userId)).get(); }, + // Custom Foundry action — not part of Better Auth. async upsertSessionState(c, input: { sessionId: string; activeOrganizationId: string | null }) { const now = Date.now(); await c.db @@ -349,5 +373,101 @@ export const authUser = actor({ return await c.db.select().from(sessionState).where(eq(sessionState.sessionId, input.sessionId)).get(); }, + + // Custom Foundry action — not part of Better Auth. + async getTaskState(c, input: { taskId: string }) { + const rows = await c.db.select().from(userTaskState).where(eq(userTaskState.taskId, input.taskId)).all(); + const activeSessionId = rows.find((row) => typeof row.activeSessionId === "string" && row.activeSessionId.length > 0)?.activeSessionId ?? null; + return { + taskId: input.taskId, + activeSessionId, + sessions: rows.map((row) => ({ + sessionId: row.sessionId, + unread: row.unread === 1, + draftText: row.draftText, + draftAttachmentsJson: row.draftAttachmentsJson, + draftUpdatedAt: row.draftUpdatedAt ?? null, + updatedAt: row.updatedAt, + })), + }; + }, + + // Custom Foundry action — not part of Better Auth. + async upsertTaskState( + c, + input: { + taskId: string; + sessionId: string; + patch: { + activeSessionId?: string | null; + unread?: boolean; + draftText?: string; + draftAttachmentsJson?: string; + draftUpdatedAt?: number | null; + }; + }, + ) { + const now = Date.now(); + const existing = await c.db + .select() + .from(userTaskState) + .where(and(eq(userTaskState.taskId, input.taskId), eq(userTaskState.sessionId, input.sessionId))) + .get(); + + if (input.patch.activeSessionId !== undefined) { + await c.db + .update(userTaskState) + .set({ + activeSessionId: input.patch.activeSessionId, + updatedAt: now, + }) + .where(eq(userTaskState.taskId, input.taskId)) + .run(); + } + + await c.db + .insert(userTaskState) + .values({ + taskId: input.taskId, + sessionId: input.sessionId, + activeSessionId: input.patch.activeSessionId ?? existing?.activeSessionId ?? null, + unread: input.patch.unread !== undefined ? (input.patch.unread ? 1 : 0) : (existing?.unread ?? 0), + draftText: input.patch.draftText ?? existing?.draftText ?? "", + draftAttachmentsJson: input.patch.draftAttachmentsJson ?? existing?.draftAttachmentsJson ?? "[]", + draftUpdatedAt: input.patch.draftUpdatedAt === undefined ? (existing?.draftUpdatedAt ?? null) : input.patch.draftUpdatedAt, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [userTaskState.taskId, userTaskState.sessionId], + set: { + ...(input.patch.activeSessionId !== undefined ? { activeSessionId: input.patch.activeSessionId } : {}), + ...(input.patch.unread !== undefined ? { unread: input.patch.unread ? 1 : 0 } : {}), + ...(input.patch.draftText !== undefined ? { draftText: input.patch.draftText } : {}), + ...(input.patch.draftAttachmentsJson !== undefined ? { draftAttachmentsJson: input.patch.draftAttachmentsJson } : {}), + ...(input.patch.draftUpdatedAt !== undefined ? { draftUpdatedAt: input.patch.draftUpdatedAt } : {}), + updatedAt: now, + }, + }) + .run(); + + return await c.db + .select() + .from(userTaskState) + .where(and(eq(userTaskState.taskId, input.taskId), eq(userTaskState.sessionId, input.sessionId))) + .get(); + }, + + // Custom Foundry action — not part of Better Auth. + async deleteTaskState(c, input: { taskId: string; sessionId?: string }) { + if (input.sessionId) { + await c.db + .delete(userTaskState) + .where(and(eq(userTaskState.taskId, input.taskId), eq(userTaskState.sessionId, input.sessionId))) + .run(); + return; + } + + await c.db.delete(userTaskState).where(eq(userTaskState.taskId, input.taskId)).run(); + }, }, }); diff --git a/foundry/packages/backend/src/services/better-auth.ts b/foundry/packages/backend/src/services/better-auth.ts index 4509402..16c7e53 100644 --- a/foundry/packages/backend/src/services/better-auth.ts +++ b/foundry/packages/backend/src/services/better-auth.ts @@ -1,7 +1,7 @@ import { betterAuth } from "better-auth"; import { createAdapterFactory } from "better-auth/adapters"; import { APP_SHELL_ORGANIZATION_ID } from "../actors/organization/app-shell.js"; -import { authUserKey, organizationKey } from "../actors/keys.js"; +import { organizationKey, userKey } from "../actors/keys.js"; import { logger } from "../logging.js"; const AUTH_BASE_PATH = "/v1/auth"; @@ -75,7 +75,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin } // getOrCreate is intentional here: the adapter runs during Better Auth callbacks - // which can fire before any explicit create path. The app organization and auth user + // which can fire before any explicit create path. The app organization and user // actors must exist by the time the adapter needs them. const appOrganization = () => actorClient.organization.getOrCreate(organizationKey(APP_SHELL_ORGANIZATION_ID), { @@ -83,9 +83,9 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin }); // getOrCreate is intentional: Better Auth creates user records during OAuth - // callbacks, so the auth-user actor must be lazily provisioned on first access. - const getAuthUser = async (userId: string) => - await actorClient.authUser.getOrCreate(authUserKey(userId), { + // callbacks, so the user actor must be lazily provisioned on first access. + const getUser = async (userId: string) => + await actorClient.user.getOrCreate(userKey(userId), { createWithInput: { userId }, }); @@ -178,7 +178,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin throw new Error(`Unable to resolve auth actor for create(${model})`); } - const userActor = await getAuthUser(userId); + const userActor = await getUser(userId); const created = await userActor.createAuthRecord({ model, data: transformed }); const organization = await appOrganization(); @@ -220,7 +220,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin return null; } - const userActor = await getAuthUser(userId); + const userActor = await getUser(userId); const found = await userActor.findOneAuthRecord({ model, where: transformedWhere, join }); return found ? ((await transformOutput(found, model, undefined, join)) as any) : null; }, @@ -259,7 +259,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin const rows = []; for (const [userId, tokens] of byUser) { - const userActor = await getAuthUser(userId); + const userActor = await getUser(userId); const scopedWhere = transformedWhere.map((entry: any) => entry.field === "token" && entry.operator === "in" ? { ...entry, value: tokens } : entry, ); @@ -275,7 +275,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin return []; } - const userActor = await getAuthUser(userId); + const userActor = await getUser(userId); const found = await userActor.findManyAuthRecords({ model, where: transformedWhere, limit, sortBy, offset, join }); return await Promise.all(found.map(async (row: any) => await transformOutput(row, model, undefined, join))); }, @@ -292,7 +292,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin return null; } - const userActor = await getAuthUser(userId); + const userActor = await getUser(userId); const before = model === "user" ? await userActor.findOneAuthRecord({ model, where: transformedWhere }) @@ -345,7 +345,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin return 0; } - const userActor = await getAuthUser(userId); + const userActor = await getUser(userId); return await userActor.updateManyAuthRecords({ model, where: transformedWhere, update: transformedUpdate }); }, @@ -361,7 +361,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin return; } - const userActor = await getAuthUser(userId); + const userActor = await getUser(userId); const organization = await appOrganization(); const before = await userActor.findOneAuthRecord({ model, where: transformedWhere }); await userActor.deleteAuthRecord({ model, where: transformedWhere }); @@ -397,7 +397,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin if (!userId) { return 0; } - const userActor = await getAuthUser(userId); + const userActor = await getUser(userId); const organization = await appOrganization(); const sessions = await userActor.findManyAuthRecords({ model, where: transformedWhere, limit: 5000 }); const deleted = await userActor.deleteManyAuthRecords({ model, where: transformedWhere }); @@ -415,7 +415,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin return 0; } - const userActor = await getAuthUser(userId); + const userActor = await getUser(userId); const deleted = await userActor.deleteManyAuthRecords({ model, where: transformedWhere }); return deleted; }, @@ -431,7 +431,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin return 0; } - const userActor = await getAuthUser(userId); + const userActor = await getUser(userId); return await userActor.countAuthRecords({ model, where: transformedWhere }); }, }; @@ -481,12 +481,12 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin if (!route?.userId) { return null; } - const userActor = await getAuthUser(route.userId); + const userActor = await getUser(route.userId); return await userActor.getAppAuthState({ sessionId }); }, async upsertUserProfile(userId: string, patch: Record) { - const userActor = await getAuthUser(userId); + const userActor = await getUser(userId); return await userActor.upsertUserProfile({ userId, patch }); }, @@ -495,7 +495,7 @@ export function initBetterAuthService(actorClient: any, options: { apiUrl: strin if (!authState?.user?.id) { throw new Error(`Unknown auth session ${sessionId}`); } - const userActor = await getAuthUser(authState.user.id); + const userActor = await getUser(authState.user.id); return await userActor.upsertSessionState({ sessionId, activeOrganizationId }); }, diff --git a/foundry/packages/backend/test/keys.test.ts b/foundry/packages/backend/test/keys.test.ts index ac5f3c8..843648b 100644 --- a/foundry/packages/backend/test/keys.test.ts +++ b/foundry/packages/backend/test/keys.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { githubDataKey, historyKey, organizationKey, repositoryKey, taskKey, taskSandboxKey } from "../src/actors/keys.js"; +import { auditLogKey, githubDataKey, organizationKey, repositoryKey, taskKey, taskSandboxKey } from "../src/actors/keys.js"; describe("actor keys", () => { it("prefixes every key with organization namespace", () => { @@ -8,7 +8,7 @@ describe("actor keys", () => { repositoryKey("default", "repo"), taskKey("default", "repo", "task"), taskSandboxKey("default", "sbx"), - historyKey("default", "repo"), + auditLogKey("default", "repo"), githubDataKey("default"), ]; diff --git a/foundry/packages/backend/test/workbench-unread.test.ts b/foundry/packages/backend/test/workspace-unread.test.ts similarity index 92% rename from foundry/packages/backend/test/workbench-unread.test.ts rename to foundry/packages/backend/test/workspace-unread.test.ts index fc94e97..5f7221a 100644 --- a/foundry/packages/backend/test/workbench-unread.test.ts +++ b/foundry/packages/backend/test/workspace-unread.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; -import { requireSendableSessionMeta, shouldMarkSessionUnreadForStatus, shouldRecreateSessionForModelChange } from "../src/actors/task/workbench.js"; +import { requireSendableSessionMeta, shouldMarkSessionUnreadForStatus, shouldRecreateSessionForModelChange } from "../src/actors/task/workspace.js"; -describe("workbench unread status transitions", () => { +describe("workspace unread status transitions", () => { it("marks unread when a running session first becomes idle", () => { expect(shouldMarkSessionUnreadForStatus({ thinkingSinceMs: Date.now() - 1_000 }, "idle")).toBe(true); }); @@ -15,7 +15,7 @@ describe("workbench unread status transitions", () => { }); }); -describe("workbench model changes", () => { +describe("workspace model changes", () => { it("recreates an unused ready session so the selected model takes effect", () => { expect( shouldRecreateSessionForModelChange({ @@ -58,9 +58,9 @@ describe("workbench model changes", () => { }); }); -describe("workbench send readiness", () => { +describe("workspace send readiness", () => { it("rejects unknown sessions", () => { - expect(() => requireSendableSessionMeta(null, "session-1")).toThrow("Unknown workbench session: session-1"); + expect(() => requireSendableSessionMeta(null, "session-1")).toThrow("Unknown workspace session: session-1"); }); it("rejects pending sessions", () => { diff --git a/foundry/packages/client/package.json b/foundry/packages/client/package.json index 98079d5..9790474 100644 --- a/foundry/packages/client/package.json +++ b/foundry/packages/client/package.json @@ -10,8 +10,8 @@ "typecheck": "tsc --noEmit", "test": "vitest run", "test:e2e:full": "HF_ENABLE_DAEMON_FULL_E2E=1 vitest run test/e2e/full-integration-e2e.test.ts", - "test:e2e:workbench": "HF_ENABLE_DAEMON_WORKBENCH_E2E=1 vitest run test/e2e/workbench-e2e.test.ts", - "test:e2e:workbench-load": "HF_ENABLE_DAEMON_WORKBENCH_LOAD_E2E=1 vitest run test/e2e/workbench-load-e2e.test.ts" + "test:e2e:workspace": "HF_ENABLE_DAEMON_WORKBENCH_E2E=1 vitest run test/e2e/workspace-e2e.test.ts", + "test:e2e:workspace-load": "HF_ENABLE_DAEMON_WORKBENCH_LOAD_E2E=1 vitest run test/e2e/workspace-load-e2e.test.ts" }, "dependencies": { "@sandbox-agent/foundry-shared": "workspace:*", diff --git a/foundry/packages/client/src/app-client.ts b/foundry/packages/client/src/app-client.ts index 16968cf..0bf5526 100644 --- a/foundry/packages/client/src/app-client.ts +++ b/foundry/packages/client/src/app-client.ts @@ -4,6 +4,7 @@ import type { FoundryOrganization, FoundryUser, UpdateFoundryOrganizationProfileInput, + WorkspaceModelId, } from "@sandbox-agent/foundry-shared"; import type { BackendClient } from "./backend-client.js"; import { getMockFoundryAppClient } from "./mock-app.js"; @@ -17,6 +18,7 @@ export interface FoundryAppClient { skipStarterRepo(): Promise; starStarterRepo(organizationId: string): Promise; selectOrganization(organizationId: string): Promise; + setDefaultModel(model: WorkspaceModelId): Promise; updateOrganizationProfile(input: UpdateFoundryOrganizationProfileInput): Promise; triggerGithubSync(organizationId: string): Promise; completeHostedCheckout(organizationId: string, planId: FoundryBillingPlanId): Promise; diff --git a/foundry/packages/client/src/backend-client.ts b/foundry/packages/client/src/backend-client.ts index 14e5661..c060f24 100644 --- a/foundry/packages/client/src/backend-client.ts +++ b/foundry/packages/client/src/backend-client.ts @@ -10,25 +10,25 @@ import type { SandboxProcessesEvent, TaskRecord, TaskSummary, - TaskWorkbenchChangeModelInput, - TaskWorkbenchCreateTaskInput, - TaskWorkbenchCreateTaskResponse, - TaskWorkbenchDiffInput, - TaskWorkbenchRenameInput, - TaskWorkbenchRenameSessionInput, - TaskWorkbenchSelectInput, - TaskWorkbenchSetSessionUnreadInput, - TaskWorkbenchSendMessageInput, - TaskWorkbenchSnapshot, - TaskWorkbenchSessionInput, - TaskWorkbenchUpdateDraftInput, + TaskWorkspaceChangeModelInput, + TaskWorkspaceCreateTaskInput, + TaskWorkspaceCreateTaskResponse, + TaskWorkspaceDiffInput, + TaskWorkspaceRenameInput, + TaskWorkspaceRenameSessionInput, + TaskWorkspaceSelectInput, + TaskWorkspaceSetSessionUnreadInput, + TaskWorkspaceSendMessageInput, + TaskWorkspaceSnapshot, + TaskWorkspaceSessionInput, + TaskWorkspaceUpdateDraftInput, TaskEvent, - WorkbenchTaskDetail, - WorkbenchTaskSummary, - WorkbenchSessionDetail, + WorkspaceTaskDetail, + WorkspaceTaskSummary, + WorkspaceSessionDetail, OrganizationEvent, OrganizationSummarySnapshot, - HistoryEvent, + AuditLogEvent as HistoryEvent, HistoryQueryInput, SandboxProviderId, RepoOverview, @@ -37,6 +37,7 @@ import type { StarSandboxAgentRepoResult, SwitchResult, UpdateFoundryOrganizationProfileInput, + WorkspaceModelId, } from "@sandbox-agent/foundry-shared"; import type { ProcessCreateRequest, ProcessInfo, ProcessLogFollowQuery, ProcessLogsResponse, ProcessSignalQuery } from "sandbox-agent"; import { createMockBackendClient } from "./mock/backend-client.js"; @@ -78,39 +79,36 @@ interface OrganizationHandle { createTask(input: CreateTaskInput): Promise; listTasks(input: { organizationId: string; repoId?: string }): Promise; getRepoOverview(input: { organizationId: string; repoId: string }): Promise; - history(input: HistoryQueryInput): Promise; - switchTask(taskId: string): Promise; - getTask(input: { organizationId: string; taskId: string }): Promise; - attachTask(input: { organizationId: string; taskId: string; reason?: string }): Promise<{ target: string; sessionId: string | null }>; - pushTask(input: { organizationId: string; taskId: string; reason?: string }): Promise; - syncTask(input: { organizationId: string; taskId: string; reason?: string }): Promise; - mergeTask(input: { organizationId: string; taskId: string; reason?: string }): Promise; - archiveTask(input: { organizationId: string; taskId: string; reason?: string }): Promise; - killTask(input: { organizationId: string; taskId: string; reason?: string }): Promise; + auditLog(input: HistoryQueryInput): Promise; + switchTask(input: { repoId: string; taskId: string }): Promise; + getTask(input: { organizationId: string; repoId: string; taskId: string }): Promise; + attachTask(input: { organizationId: string; repoId: string; taskId: string; reason?: string }): Promise<{ target: string; sessionId: string | null }>; + pushTask(input: { organizationId: string; repoId: string; taskId: string; reason?: string }): Promise; + syncTask(input: { organizationId: string; repoId: string; taskId: string; reason?: string }): Promise; + mergeTask(input: { organizationId: string; repoId: string; taskId: string; reason?: string }): Promise; + archiveTask(input: { organizationId: string; repoId: string; taskId: string; reason?: string }): Promise; + killTask(input: { organizationId: string; repoId: string; taskId: string; reason?: string }): Promise; useOrganization(input: { organizationId: string }): Promise<{ organizationId: string }>; starSandboxAgentRepo(input: StarSandboxAgentRepoInput): Promise; getOrganizationSummary(input: { organizationId: string }): Promise; - applyTaskSummaryUpdate(input: { taskSummary: WorkbenchTaskSummary }): Promise; - removeTaskSummary(input: { taskId: string }): Promise; - reconcileWorkbenchState(input: { organizationId: string }): Promise; - createWorkbenchTask(input: TaskWorkbenchCreateTaskInput): Promise; - markWorkbenchUnread(input: TaskWorkbenchSelectInput): Promise; - renameWorkbenchTask(input: TaskWorkbenchRenameInput): Promise; - renameWorkbenchBranch(input: TaskWorkbenchRenameInput): Promise; - createWorkbenchSession(input: TaskWorkbenchSelectInput & { model?: string }): Promise<{ sessionId: string }>; - renameWorkbenchSession(input: TaskWorkbenchRenameSessionInput): Promise; - setWorkbenchSessionUnread(input: TaskWorkbenchSetSessionUnreadInput): Promise; - updateWorkbenchDraft(input: TaskWorkbenchUpdateDraftInput): Promise; - changeWorkbenchModel(input: TaskWorkbenchChangeModelInput): Promise; - sendWorkbenchMessage(input: TaskWorkbenchSendMessageInput): Promise; - stopWorkbenchSession(input: TaskWorkbenchSessionInput): Promise; - closeWorkbenchSession(input: TaskWorkbenchSessionInput): Promise; - publishWorkbenchPr(input: TaskWorkbenchSelectInput): Promise; - revertWorkbenchFile(input: TaskWorkbenchDiffInput): Promise; - reloadGithubOrganization(): Promise; - reloadGithubPullRequests(): Promise; - reloadGithubRepository(input: { repoId: string }): Promise; - reloadGithubPullRequest(input: { repoId: string; prNumber: number }): Promise; + adminReconcileWorkspaceState(input: { organizationId: string }): Promise; + createWorkspaceTask(input: TaskWorkspaceCreateTaskInput): Promise; + markWorkspaceUnread(input: TaskWorkspaceSelectInput): Promise; + renameWorkspaceTask(input: TaskWorkspaceRenameInput): Promise; + createWorkspaceSession(input: TaskWorkspaceSelectInput & { model?: string }): Promise<{ sessionId: string }>; + renameWorkspaceSession(input: TaskWorkspaceRenameSessionInput): Promise; + setWorkspaceSessionUnread(input: TaskWorkspaceSetSessionUnreadInput): Promise; + updateWorkspaceDraft(input: TaskWorkspaceUpdateDraftInput): Promise; + changeWorkspaceModel(input: TaskWorkspaceChangeModelInput): Promise; + sendWorkspaceMessage(input: TaskWorkspaceSendMessageInput): Promise; + stopWorkspaceSession(input: TaskWorkspaceSessionInput): Promise; + closeWorkspaceSession(input: TaskWorkspaceSessionInput): Promise; + publishWorkspacePr(input: TaskWorkspaceSelectInput): Promise; + revertWorkspaceFile(input: TaskWorkspaceDiffInput): Promise; + adminReloadGithubOrganization(): Promise; + adminReloadGithubPullRequests(): Promise; + adminReloadGithubRepository(input: { repoId: string }): Promise; + adminReloadGithubPullRequest(input: { repoId: string; prNumber: number }): Promise; } interface AppOrganizationHandle { @@ -119,6 +117,7 @@ interface AppOrganizationHandle { skipAppStarterRepo(input: { sessionId: string }): Promise; starAppStarterRepo(input: { sessionId: string; organizationId: string }): Promise; selectAppOrganization(input: { sessionId: string; organizationId: string }): Promise; + setAppDefaultModel(input: { sessionId: string; defaultModel: WorkspaceModelId }): Promise; updateAppOrganizationProfile(input: UpdateFoundryOrganizationProfileInput & { sessionId: string }): Promise; triggerAppRepoImport(input: { sessionId: string; organizationId: string }): Promise; beginAppGithubInstall(input: { sessionId: string; organizationId: string }): Promise<{ url: string }>; @@ -130,9 +129,9 @@ interface AppOrganizationHandle { } interface TaskHandle { - getTaskSummary(): Promise; - getTaskDetail(): Promise; - getSessionDetail(input: { sessionId: string }): Promise; + getTaskSummary(): Promise; + getTaskDetail(): Promise; + getSessionDetail(input: { sessionId: string }): Promise; connect(): ActorConn; } @@ -192,6 +191,7 @@ export interface BackendClient { skipAppStarterRepo(): Promise; starAppStarterRepo(organizationId: string): Promise; selectAppOrganization(organizationId: string): Promise; + setAppDefaultModel(defaultModel: WorkspaceModelId): Promise; updateAppOrganizationProfile(input: UpdateFoundryOrganizationProfileInput): Promise; triggerAppRepoImport(organizationId: string): Promise; reconnectAppGithub(organizationId: string): Promise; @@ -204,11 +204,11 @@ export interface BackendClient { createTask(input: CreateTaskInput): Promise; listTasks(organizationId: string, repoId?: string): Promise; getRepoOverview(organizationId: string, repoId: string): Promise; - getTask(organizationId: string, taskId: string): Promise; + getTask(organizationId: string, repoId: string, taskId: string): Promise; listHistory(input: HistoryQueryInput): Promise; - switchTask(organizationId: string, taskId: string): Promise; - attachTask(organizationId: string, taskId: string): Promise<{ target: string; sessionId: string | null }>; - runAction(organizationId: string, taskId: string, action: TaskAction): Promise; + switchTask(organizationId: string, repoId: string, taskId: string): Promise; + attachTask(organizationId: string, repoId: string, taskId: string): Promise<{ target: string; sessionId: string | null }>; + runAction(organizationId: string, repoId: string, taskId: string, action: TaskAction): Promise; createSandboxSession(input: { organizationId: string; sandboxProviderId: SandboxProviderId; @@ -280,28 +280,27 @@ export interface BackendClient { ): Promise<{ sandboxProviderId: SandboxProviderId; sandboxId: string; state: string; at: number }>; getSandboxAgentConnection(organizationId: string, sandboxProviderId: SandboxProviderId, sandboxId: string): Promise<{ endpoint: string; token?: string }>; getOrganizationSummary(organizationId: string): Promise; - getTaskDetail(organizationId: string, repoId: string, taskId: string): Promise; - getSessionDetail(organizationId: string, repoId: string, taskId: string, sessionId: string): Promise; - getWorkbench(organizationId: string): Promise; - subscribeWorkbench(organizationId: string, listener: () => void): () => void; - createWorkbenchTask(organizationId: string, input: TaskWorkbenchCreateTaskInput): Promise; - markWorkbenchUnread(organizationId: string, input: TaskWorkbenchSelectInput): Promise; - renameWorkbenchTask(organizationId: string, input: TaskWorkbenchRenameInput): Promise; - renameWorkbenchBranch(organizationId: string, input: TaskWorkbenchRenameInput): Promise; - createWorkbenchSession(organizationId: string, input: TaskWorkbenchSelectInput & { model?: string }): Promise<{ sessionId: string }>; - renameWorkbenchSession(organizationId: string, input: TaskWorkbenchRenameSessionInput): Promise; - setWorkbenchSessionUnread(organizationId: string, input: TaskWorkbenchSetSessionUnreadInput): Promise; - updateWorkbenchDraft(organizationId: string, input: TaskWorkbenchUpdateDraftInput): Promise; - changeWorkbenchModel(organizationId: string, input: TaskWorkbenchChangeModelInput): Promise; - sendWorkbenchMessage(organizationId: string, input: TaskWorkbenchSendMessageInput): Promise; - stopWorkbenchSession(organizationId: string, input: TaskWorkbenchSessionInput): Promise; - closeWorkbenchSession(organizationId: string, input: TaskWorkbenchSessionInput): Promise; - publishWorkbenchPr(organizationId: string, input: TaskWorkbenchSelectInput): Promise; - revertWorkbenchFile(organizationId: string, input: TaskWorkbenchDiffInput): Promise; - reloadGithubOrganization(organizationId: string): Promise; - reloadGithubPullRequests(organizationId: string): Promise; - reloadGithubRepository(organizationId: string, repoId: string): Promise; - reloadGithubPullRequest(organizationId: string, repoId: string, prNumber: number): Promise; + getTaskDetail(organizationId: string, repoId: string, taskId: string): Promise; + getSessionDetail(organizationId: string, repoId: string, taskId: string, sessionId: string): Promise; + getWorkspace(organizationId: string): Promise; + subscribeWorkspace(organizationId: string, listener: () => void): () => void; + createWorkspaceTask(organizationId: string, input: TaskWorkspaceCreateTaskInput): Promise; + markWorkspaceUnread(organizationId: string, input: TaskWorkspaceSelectInput): Promise; + renameWorkspaceTask(organizationId: string, input: TaskWorkspaceRenameInput): Promise; + createWorkspaceSession(organizationId: string, input: TaskWorkspaceSelectInput & { model?: string }): Promise<{ sessionId: string }>; + renameWorkspaceSession(organizationId: string, input: TaskWorkspaceRenameSessionInput): Promise; + setWorkspaceSessionUnread(organizationId: string, input: TaskWorkspaceSetSessionUnreadInput): Promise; + updateWorkspaceDraft(organizationId: string, input: TaskWorkspaceUpdateDraftInput): Promise; + changeWorkspaceModel(organizationId: string, input: TaskWorkspaceChangeModelInput): Promise; + sendWorkspaceMessage(organizationId: string, input: TaskWorkspaceSendMessageInput): Promise; + stopWorkspaceSession(organizationId: string, input: TaskWorkspaceSessionInput): Promise; + closeWorkspaceSession(organizationId: string, input: TaskWorkspaceSessionInput): Promise; + publishWorkspacePr(organizationId: string, input: TaskWorkspaceSelectInput): Promise; + revertWorkspaceFile(organizationId: string, input: TaskWorkspaceDiffInput): Promise; + adminReloadGithubOrganization(organizationId: string): Promise; + adminReloadGithubPullRequests(organizationId: string): Promise; + adminReloadGithubRepository(organizationId: string, repoId: string): Promise; + adminReloadGithubPullRequest(organizationId: string, repoId: string, prNumber: number): Promise; health(): Promise<{ ok: true }>; useOrganization(organizationId: string): Promise<{ organizationId: string }>; starSandboxAgentRepo(organizationId: string): Promise; @@ -410,7 +409,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien const rivetApiEndpoint = endpoints.rivetEndpoint; const appApiEndpoint = endpoints.appEndpoint; const client = createClient({ endpoint: rivetApiEndpoint }) as unknown as RivetClient; - const workbenchSubscriptions = new Map< + const workspaceSubscriptions = new Map< string, { listeners: Set<() => void>; @@ -563,7 +562,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien } }; - const getWorkbenchCompat = async (organizationId: string): Promise => { + const getWorkspaceCompat = async (organizationId: string): Promise => { const summary = await (await organization(organizationId)).getOrganizationSummary({ organizationId }); const tasks = ( await Promise.all( @@ -590,7 +589,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien } }), ); - const sessionDetailsById = new Map(sessionDetails.filter((entry): entry is readonly [string, WorkbenchSessionDetail] => entry !== null)); + const sessionDetailsById = new Map(sessionDetails.filter((entry): entry is readonly [string, WorkspaceSessionDetail] => entry !== null)); return { id: detail.id, repoId: detail.repoId, @@ -623,7 +622,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien }; }), ) - ).filter((task): task is TaskWorkbenchSnapshot["tasks"][number] => task !== null); + ).filter((task): task is TaskWorkspaceSnapshot["tasks"][number] => task !== null); const repositories = summary.repos .map((repo) => ({ @@ -642,14 +641,14 @@ export function createBackendClient(options: BackendClientOptions): BackendClien }; }; - const subscribeWorkbench = (organizationId: string, listener: () => void): (() => void) => { - let entry = workbenchSubscriptions.get(organizationId); + const subscribeWorkspace = (organizationId: string, listener: () => void): (() => void) => { + let entry = workspaceSubscriptions.get(organizationId); if (!entry) { entry = { listeners: new Set(), disposeConnPromise: null, }; - workbenchSubscriptions.set(organizationId, entry); + workspaceSubscriptions.set(organizationId, entry); } entry.listeners.add(listener); @@ -658,8 +657,8 @@ export function createBackendClient(options: BackendClientOptions): BackendClien entry.disposeConnPromise = (async () => { const handle = await organization(organizationId); const conn = (handle as any).connect(); - const unsubscribeEvent = conn.on("workbenchUpdated", () => { - const current = workbenchSubscriptions.get(organizationId); + const unsubscribeEvent = conn.on("organizationUpdated", () => { + const current = workspaceSubscriptions.get(organizationId); if (!current) { return; } @@ -677,7 +676,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien } return () => { - const current = workbenchSubscriptions.get(organizationId); + const current = workspaceSubscriptions.get(organizationId); if (!current) { return; } @@ -686,7 +685,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien return; } - workbenchSubscriptions.delete(organizationId); + workspaceSubscriptions.delete(organizationId); void current.disposeConnPromise?.then(async (disposeConn) => { await disposeConn?.(); }); @@ -849,6 +848,14 @@ export function createBackendClient(options: BackendClientOptions): BackendClien return await (await appOrganization()).selectAppOrganization({ sessionId, organizationId }); }, + async setAppDefaultModel(defaultModel: WorkspaceModelId): Promise { + const sessionId = await getSessionId(); + if (!sessionId) { + throw new Error("No active auth session"); + } + return await (await appOrganization()).setAppDefaultModel({ sessionId, defaultModel }); + }, + async updateAppOrganizationProfile(input: UpdateFoundryOrganizationProfileInput): Promise { const sessionId = await getSessionId(); if (!sessionId) { @@ -948,33 +955,36 @@ export function createBackendClient(options: BackendClientOptions): BackendClien return (await organization(organizationId)).getRepoOverview({ organizationId, repoId }); }, - async getTask(organizationId: string, taskId: string): Promise { + async getTask(organizationId: string, repoId: string, taskId: string): Promise { return (await organization(organizationId)).getTask({ organizationId, + repoId, taskId, }); }, async listHistory(input: HistoryQueryInput): Promise { - return (await organization(input.organizationId)).history(input); + return (await organization(input.organizationId)).auditLog(input); }, - async switchTask(organizationId: string, taskId: string): Promise { - return (await organization(organizationId)).switchTask(taskId); + async switchTask(organizationId: string, repoId: string, taskId: string): Promise { + return (await organization(organizationId)).switchTask({ repoId, taskId }); }, - async attachTask(organizationId: string, taskId: string): Promise<{ target: string; sessionId: string | null }> { + async attachTask(organizationId: string, repoId: string, taskId: string): Promise<{ target: string; sessionId: string | null }> { return (await organization(organizationId)).attachTask({ organizationId, + repoId, taskId, reason: "cli.attach", }); }, - async runAction(organizationId: string, taskId: string, action: TaskAction): Promise { + async runAction(organizationId: string, repoId: string, taskId: string, action: TaskAction): Promise { if (action === "push") { await (await organization(organizationId)).pushTask({ organizationId, + repoId, taskId, reason: "cli.push", }); @@ -983,6 +993,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien if (action === "sync") { await (await organization(organizationId)).syncTask({ organizationId, + repoId, taskId, reason: "cli.sync", }); @@ -991,6 +1002,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien if (action === "merge") { await (await organization(organizationId)).mergeTask({ organizationId, + repoId, taskId, reason: "cli.merge", }); @@ -999,6 +1011,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien if (action === "archive") { await (await organization(organizationId)).archiveTask({ organizationId, + repoId, taskId, reason: "cli.archive", }); @@ -1006,6 +1019,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien } await (await organization(organizationId)).killTask({ organizationId, + repoId, taskId, reason: "cli.kill", }); @@ -1160,92 +1174,88 @@ export function createBackendClient(options: BackendClientOptions): BackendClien return (await organization(organizationId)).getOrganizationSummary({ organizationId }); }, - async getTaskDetail(organizationId: string, repoId: string, taskIdValue: string): Promise { + async getTaskDetail(organizationId: string, repoId: string, taskIdValue: string): Promise { return (await task(organizationId, repoId, taskIdValue)).getTaskDetail(); }, - async getSessionDetail(organizationId: string, repoId: string, taskIdValue: string, sessionId: string): Promise { + async getSessionDetail(organizationId: string, repoId: string, taskIdValue: string, sessionId: string): Promise { return (await task(organizationId, repoId, taskIdValue)).getSessionDetail({ sessionId }); }, - async getWorkbench(organizationId: string): Promise { - return await getWorkbenchCompat(organizationId); + async getWorkspace(organizationId: string): Promise { + return await getWorkspaceCompat(organizationId); }, - subscribeWorkbench(organizationId: string, listener: () => void): () => void { - return subscribeWorkbench(organizationId, listener); + subscribeWorkspace(organizationId: string, listener: () => void): () => void { + return subscribeWorkspace(organizationId, listener); }, - async createWorkbenchTask(organizationId: string, input: TaskWorkbenchCreateTaskInput): Promise { - return (await organization(organizationId)).createWorkbenchTask(input); + async createWorkspaceTask(organizationId: string, input: TaskWorkspaceCreateTaskInput): Promise { + return (await organization(organizationId)).createWorkspaceTask(input); }, - async markWorkbenchUnread(organizationId: string, input: TaskWorkbenchSelectInput): Promise { - await (await organization(organizationId)).markWorkbenchUnread(input); + async markWorkspaceUnread(organizationId: string, input: TaskWorkspaceSelectInput): Promise { + await (await organization(organizationId)).markWorkspaceUnread(input); }, - async renameWorkbenchTask(organizationId: string, input: TaskWorkbenchRenameInput): Promise { - await (await organization(organizationId)).renameWorkbenchTask(input); + async renameWorkspaceTask(organizationId: string, input: TaskWorkspaceRenameInput): Promise { + await (await organization(organizationId)).renameWorkspaceTask(input); }, - async renameWorkbenchBranch(organizationId: string, input: TaskWorkbenchRenameInput): Promise { - await (await organization(organizationId)).renameWorkbenchBranch(input); + async createWorkspaceSession(organizationId: string, input: TaskWorkspaceSelectInput & { model?: string }): Promise<{ sessionId: string }> { + return await (await organization(organizationId)).createWorkspaceSession(input); }, - async createWorkbenchSession(organizationId: string, input: TaskWorkbenchSelectInput & { model?: string }): Promise<{ sessionId: string }> { - return await (await organization(organizationId)).createWorkbenchSession(input); + async renameWorkspaceSession(organizationId: string, input: TaskWorkspaceRenameSessionInput): Promise { + await (await organization(organizationId)).renameWorkspaceSession(input); }, - async renameWorkbenchSession(organizationId: string, input: TaskWorkbenchRenameSessionInput): Promise { - await (await organization(organizationId)).renameWorkbenchSession(input); + async setWorkspaceSessionUnread(organizationId: string, input: TaskWorkspaceSetSessionUnreadInput): Promise { + await (await organization(organizationId)).setWorkspaceSessionUnread(input); }, - async setWorkbenchSessionUnread(organizationId: string, input: TaskWorkbenchSetSessionUnreadInput): Promise { - await (await organization(organizationId)).setWorkbenchSessionUnread(input); + async updateWorkspaceDraft(organizationId: string, input: TaskWorkspaceUpdateDraftInput): Promise { + await (await organization(organizationId)).updateWorkspaceDraft(input); }, - async updateWorkbenchDraft(organizationId: string, input: TaskWorkbenchUpdateDraftInput): Promise { - await (await organization(organizationId)).updateWorkbenchDraft(input); + async changeWorkspaceModel(organizationId: string, input: TaskWorkspaceChangeModelInput): Promise { + await (await organization(organizationId)).changeWorkspaceModel(input); }, - async changeWorkbenchModel(organizationId: string, input: TaskWorkbenchChangeModelInput): Promise { - await (await organization(organizationId)).changeWorkbenchModel(input); + async sendWorkspaceMessage(organizationId: string, input: TaskWorkspaceSendMessageInput): Promise { + await (await organization(organizationId)).sendWorkspaceMessage(input); }, - async sendWorkbenchMessage(organizationId: string, input: TaskWorkbenchSendMessageInput): Promise { - await (await organization(organizationId)).sendWorkbenchMessage(input); + async stopWorkspaceSession(organizationId: string, input: TaskWorkspaceSessionInput): Promise { + await (await organization(organizationId)).stopWorkspaceSession(input); }, - async stopWorkbenchSession(organizationId: string, input: TaskWorkbenchSessionInput): Promise { - await (await organization(organizationId)).stopWorkbenchSession(input); + async closeWorkspaceSession(organizationId: string, input: TaskWorkspaceSessionInput): Promise { + await (await organization(organizationId)).closeWorkspaceSession(input); }, - async closeWorkbenchSession(organizationId: string, input: TaskWorkbenchSessionInput): Promise { - await (await organization(organizationId)).closeWorkbenchSession(input); + async publishWorkspacePr(organizationId: string, input: TaskWorkspaceSelectInput): Promise { + await (await organization(organizationId)).publishWorkspacePr(input); }, - async publishWorkbenchPr(organizationId: string, input: TaskWorkbenchSelectInput): Promise { - await (await organization(organizationId)).publishWorkbenchPr(input); + async revertWorkspaceFile(organizationId: string, input: TaskWorkspaceDiffInput): Promise { + await (await organization(organizationId)).revertWorkspaceFile(input); }, - async revertWorkbenchFile(organizationId: string, input: TaskWorkbenchDiffInput): Promise { - await (await organization(organizationId)).revertWorkbenchFile(input); + async adminReloadGithubOrganization(organizationId: string): Promise { + await (await organization(organizationId)).adminReloadGithubOrganization(); }, - async reloadGithubOrganization(organizationId: string): Promise { - await (await organization(organizationId)).reloadGithubOrganization(); + async adminReloadGithubPullRequests(organizationId: string): Promise { + await (await organization(organizationId)).adminReloadGithubPullRequests(); }, - async reloadGithubPullRequests(organizationId: string): Promise { - await (await organization(organizationId)).reloadGithubPullRequests(); + async adminReloadGithubRepository(organizationId: string, repoId: string): Promise { + await (await organization(organizationId)).adminReloadGithubRepository({ repoId }); }, - async reloadGithubRepository(organizationId: string, repoId: string): Promise { - await (await organization(organizationId)).reloadGithubRepository({ repoId }); - }, - - async reloadGithubPullRequest(organizationId: string, repoId: string, prNumber: number): Promise { - await (await organization(organizationId)).reloadGithubPullRequest({ repoId, prNumber }); + async adminReloadGithubPullRequest(organizationId: string, repoId: string, prNumber: number): Promise { + await (await organization(organizationId)).adminReloadGithubPullRequest({ repoId, prNumber }); }, async health(): Promise<{ ok: true }> { diff --git a/foundry/packages/client/src/index.ts b/foundry/packages/client/src/index.ts index 87909a9..e28745f 100644 --- a/foundry/packages/client/src/index.ts +++ b/foundry/packages/client/src/index.ts @@ -8,4 +8,4 @@ export * from "./subscription/use-subscription.js"; export * from "./keys.js"; export * from "./mock-app.js"; export * from "./view-model.js"; -export * from "./workbench-client.js"; +export * from "./workspace-client.js"; diff --git a/foundry/packages/client/src/keys.ts b/foundry/packages/client/src/keys.ts index 314f16a..84dd00b 100644 --- a/foundry/packages/client/src/keys.ts +++ b/foundry/packages/client/src/keys.ts @@ -16,6 +16,6 @@ export function taskSandboxKey(organizationId: string, sandboxId: string): Actor return ["org", organizationId, "sandbox", sandboxId]; } -export function historyKey(organizationId: string, repoId: string): ActorKey { - return ["org", organizationId, "repository", repoId, "history"]; +export function auditLogKey(organizationId: string, repoId: string): ActorKey { + return ["org", organizationId, "repository", repoId, "audit-log"]; } diff --git a/foundry/packages/client/src/mock-app.ts b/foundry/packages/client/src/mock-app.ts index 0fa6fc7..a96ea6a 100644 --- a/foundry/packages/client/src/mock-app.ts +++ b/foundry/packages/client/src/mock-app.ts @@ -1,4 +1,4 @@ -import type { WorkbenchModelId } from "@sandbox-agent/foundry-shared"; +import type { WorkspaceModelId } from "@sandbox-agent/foundry-shared"; import { injectMockLatency } from "./mock/latency.js"; import rivetDevFixture from "../../../scripts/data/rivet-dev.json" with { type: "json" }; @@ -16,6 +16,7 @@ export interface MockFoundryUser { githubLogin: string; roleLabel: string; eligibleOrganizationIds: string[]; + defaultModel: WorkspaceModelId; } export interface MockFoundryOrganizationMember { @@ -61,7 +62,6 @@ export interface MockFoundryOrganizationSettings { slug: string; primaryDomain: string; seatAccrualMode: "first_prompt"; - defaultModel: WorkbenchModelId; autoImportRepos: boolean; } @@ -111,6 +111,7 @@ export interface MockFoundryAppClient { skipStarterRepo(): Promise; starStarterRepo(organizationId: string): Promise; selectOrganization(organizationId: string): Promise; + setDefaultModel(model: WorkspaceModelId): Promise; updateOrganizationProfile(input: UpdateMockOrganizationProfileInput): Promise; triggerGithubSync(organizationId: string): Promise; completeHostedCheckout(organizationId: string, planId: MockBillingPlanId): Promise; @@ -180,7 +181,6 @@ function buildRivetOrganization(): MockFoundryOrganization { slug: "rivet", primaryDomain: "rivet.dev", seatAccrualMode: "first_prompt", - defaultModel: "gpt-5.3-codex", autoImportRepos: true, }, github: { @@ -233,6 +233,7 @@ function buildDefaultSnapshot(): MockFoundryAppSnapshot { githubLogin: "nathan", roleLabel: "Founder", eligibleOrganizationIds: ["personal-nathan", "acme", "rivet"], + defaultModel: "gpt-5.3-codex", }, { id: "user-maya", @@ -241,6 +242,7 @@ function buildDefaultSnapshot(): MockFoundryAppSnapshot { githubLogin: "maya", roleLabel: "Staff Engineer", eligibleOrganizationIds: ["acme"], + defaultModel: "claude-sonnet-4", }, { id: "user-jamie", @@ -249,6 +251,7 @@ function buildDefaultSnapshot(): MockFoundryAppSnapshot { githubLogin: "jamie", roleLabel: "Platform Lead", eligibleOrganizationIds: ["personal-jamie", "rivet"], + defaultModel: "claude-opus-4", }, ], organizations: [ @@ -261,7 +264,6 @@ function buildDefaultSnapshot(): MockFoundryAppSnapshot { slug: "nathan", primaryDomain: "personal", seatAccrualMode: "first_prompt", - defaultModel: "claude-sonnet-4", autoImportRepos: true, }, github: { @@ -297,7 +299,6 @@ function buildDefaultSnapshot(): MockFoundryAppSnapshot { slug: "acme", primaryDomain: "acme.dev", seatAccrualMode: "first_prompt", - defaultModel: "claude-sonnet-4", autoImportRepos: true, }, github: { @@ -342,7 +343,6 @@ function buildDefaultSnapshot(): MockFoundryAppSnapshot { slug: "jamie", primaryDomain: "personal", seatAccrualMode: "first_prompt", - defaultModel: "claude-opus-4", autoImportRepos: true, }, github: { @@ -538,6 +538,18 @@ class MockFoundryAppStore implements MockFoundryAppClient { } } + async setDefaultModel(model: WorkspaceModelId): Promise { + await this.injectAsyncLatency(); + const currentUserId = this.snapshot.auth.currentUserId; + if (!currentUserId) { + throw new Error("No signed-in mock user"); + } + this.updateSnapshot((current) => ({ + ...current, + users: current.users.map((user) => (user.id === currentUserId ? { ...user, defaultModel: model } : user)), + })); + } + async updateOrganizationProfile(input: UpdateMockOrganizationProfileInput): Promise { await this.injectAsyncLatency(); this.requireOrganization(input.organizationId); diff --git a/foundry/packages/client/src/mock/backend-client.ts b/foundry/packages/client/src/mock/backend-client.ts index 011192d..be51fe2 100644 --- a/foundry/packages/client/src/mock/backend-client.ts +++ b/foundry/packages/client/src/mock/backend-client.ts @@ -6,25 +6,25 @@ import type { SessionEvent, TaskRecord, TaskSummary, - TaskWorkbenchChangeModelInput, - TaskWorkbenchCreateTaskInput, - TaskWorkbenchCreateTaskResponse, - TaskWorkbenchDiffInput, - TaskWorkbenchRenameInput, - TaskWorkbenchRenameSessionInput, - TaskWorkbenchSelectInput, - TaskWorkbenchSetSessionUnreadInput, - TaskWorkbenchSendMessageInput, - TaskWorkbenchSnapshot, - TaskWorkbenchSessionInput, - TaskWorkbenchUpdateDraftInput, + TaskWorkspaceChangeModelInput, + TaskWorkspaceCreateTaskInput, + TaskWorkspaceCreateTaskResponse, + TaskWorkspaceDiffInput, + TaskWorkspaceRenameInput, + TaskWorkspaceRenameSessionInput, + TaskWorkspaceSelectInput, + TaskWorkspaceSetSessionUnreadInput, + TaskWorkspaceSendMessageInput, + TaskWorkspaceSnapshot, + TaskWorkspaceSessionInput, + TaskWorkspaceUpdateDraftInput, TaskEvent, - WorkbenchSessionDetail, - WorkbenchTaskDetail, - WorkbenchTaskSummary, + WorkspaceSessionDetail, + WorkspaceTaskDetail, + WorkspaceTaskSummary, OrganizationEvent, OrganizationSummarySnapshot, - HistoryEvent, + AuditLogEvent as HistoryEvent, HistoryQueryInput, SandboxProviderId, RepoOverview, @@ -34,7 +34,7 @@ import type { } from "@sandbox-agent/foundry-shared"; import type { ProcessCreateRequest, ProcessLogFollowQuery, ProcessLogsResponse, ProcessSignalQuery } from "sandbox-agent"; import type { ActorConn, BackendClient, SandboxProcessRecord, SandboxSessionEventRecord, SandboxSessionRecord } from "../backend-client.js"; -import { getSharedMockWorkbenchClient } from "./workbench-client.js"; +import { getSharedMockWorkspaceClient } from "./workspace-client.js"; interface MockProcessRecord extends SandboxProcessRecord { logText: string; @@ -89,7 +89,7 @@ function toTaskStatus(status: TaskRecord["status"], archived: boolean): TaskReco } export function createMockBackendClient(defaultOrganizationId = "default"): BackendClient { - const workbench = getSharedMockWorkbenchClient(); + const workspace = getSharedMockWorkspaceClient(); const listenersBySandboxId = new Map void>>(); const processesBySandboxId = new Map(); const connectionListeners = new Map void>>(); @@ -97,7 +97,7 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back let nextProcessId = 1; const requireTask = (taskId: string) => { - const task = workbench.getSnapshot().tasks.find((candidate) => candidate.id === taskId); + const task = workspace.getSnapshot().tasks.find((candidate) => candidate.id === taskId); if (!task) { throw new Error(`Unknown mock task ${taskId}`); } @@ -164,7 +164,7 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back async dispose(): Promise {}, }); - const buildTaskSummary = (task: TaskWorkbenchSnapshot["tasks"][number]): WorkbenchTaskSummary => ({ + const buildTaskSummary = (task: TaskWorkspaceSnapshot["tasks"][number]): WorkspaceTaskSummary => ({ id: task.id, repoId: task.repoId, title: task.title, @@ -187,7 +187,7 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back })), }); - const buildTaskDetail = (task: TaskWorkbenchSnapshot["tasks"][number]): WorkbenchTaskDetail => ({ + const buildTaskDetail = (task: TaskWorkspaceSnapshot["tasks"][number]): WorkspaceTaskDetail => ({ ...buildTaskSummary(task), task: task.title, agentType: task.sessions[0]?.agent === "Codex" ? "codex" : "claude", @@ -211,7 +211,7 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back activeSandboxId: task.id, }); - const buildSessionDetail = (task: TaskWorkbenchSnapshot["tasks"][number], sessionId: string): WorkbenchSessionDetail => { + const buildSessionDetail = (task: TaskWorkspaceSnapshot["tasks"][number], sessionId: string): WorkspaceSessionDetail => { const tab = task.sessions.find((candidate) => candidate.id === sessionId); if (!tab) { throw new Error(`Unknown mock session ${sessionId} for task ${task.id}`); @@ -232,7 +232,7 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back }; const buildOrganizationSummary = (): OrganizationSummarySnapshot => { - const snapshot = workbench.getSnapshot(); + const snapshot = workspace.getSnapshot(); const taskSummaries = snapshot.tasks.map(buildTaskSummary); return { organizationId: defaultOrganizationId, @@ -256,20 +256,16 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back `sandbox:${organizationId}:${sandboxProviderId}:${sandboxId}`; const emitOrganizationSnapshot = (): void => { - const summary = buildOrganizationSummary(); - const latestTask = [...summary.taskSummaries].sort((left, right) => right.updatedAtMs - left.updatedAtMs)[0] ?? null; - if (latestTask) { - emitConnectionEvent(organizationScope(defaultOrganizationId), "organizationUpdated", { - type: "taskSummaryUpdated", - taskSummary: latestTask, - } satisfies OrganizationEvent); - } + emitConnectionEvent(organizationScope(defaultOrganizationId), "organizationUpdated", { + type: "organizationUpdated", + snapshot: buildOrganizationSummary(), + } satisfies OrganizationEvent); }; const emitTaskUpdate = (taskId: string): void => { const task = requireTask(taskId); emitConnectionEvent(taskScope(defaultOrganizationId, task.repoId, task.id), "taskUpdated", { - type: "taskDetailUpdated", + type: "taskUpdated", detail: buildTaskDetail(task), } satisfies TaskEvent); }; @@ -400,6 +396,10 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back return unsupportedAppSnapshot(); }, + async setAppDefaultModel(): Promise { + return unsupportedAppSnapshot(); + }, + async updateAppOrganizationProfile(): Promise { return unsupportedAppSnapshot(); }, @@ -433,7 +433,7 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back }, async listRepos(_organizationId: string): Promise { - return workbench.getSnapshot().repos.map((repo) => ({ + return workspace.getSnapshot().repos.map((repo) => ({ organizationId: defaultOrganizationId, repoId: repo.id, remoteUrl: mockRepoRemote(repo.label), @@ -447,7 +447,7 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back }, async listTasks(_organizationId: string, repoId?: string): Promise { - return workbench + return workspace .getSnapshot() .tasks.filter((task) => !repoId || task.repoId === repoId) .map((task) => ({ @@ -641,24 +641,24 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back return buildOrganizationSummary(); }, - async getTaskDetail(_organizationId: string, _repoId: string, taskId: string): Promise { + async getTaskDetail(_organizationId: string, _repoId: string, taskId: string): Promise { return buildTaskDetail(requireTask(taskId)); }, - async getSessionDetail(_organizationId: string, _repoId: string, taskId: string, sessionId: string): Promise { + async getSessionDetail(_organizationId: string, _repoId: string, taskId: string, sessionId: string): Promise { return buildSessionDetail(requireTask(taskId), sessionId); }, - async getWorkbench(): Promise { - return workbench.getSnapshot(); + async getWorkspace(): Promise { + return workspace.getSnapshot(); }, - subscribeWorkbench(_organizationId: string, listener: () => void): () => void { - return workbench.subscribe(listener); + subscribeWorkspace(_organizationId: string, listener: () => void): () => void { + return workspace.subscribe(listener); }, - async createWorkbenchTask(_organizationId: string, input: TaskWorkbenchCreateTaskInput): Promise { - const created = await workbench.createTask(input); + async createWorkspaceTask(_organizationId: string, input: TaskWorkspaceCreateTaskInput): Promise { + const created = await workspace.createTask(input); emitOrganizationSnapshot(); emitTaskUpdate(created.taskId); if (created.sessionId) { @@ -667,99 +667,93 @@ export function createMockBackendClient(defaultOrganizationId = "default"): Back return created; }, - async markWorkbenchUnread(_organizationId: string, input: TaskWorkbenchSelectInput): Promise { - await workbench.markTaskUnread(input); + async markWorkspaceUnread(_organizationId: string, input: TaskWorkspaceSelectInput): Promise { + await workspace.markTaskUnread(input); emitOrganizationSnapshot(); emitTaskUpdate(input.taskId); }, - async renameWorkbenchTask(_organizationId: string, input: TaskWorkbenchRenameInput): Promise { - await workbench.renameTask(input); + async renameWorkspaceTask(_organizationId: string, input: TaskWorkspaceRenameInput): Promise { + await workspace.renameTask(input); emitOrganizationSnapshot(); emitTaskUpdate(input.taskId); }, - async renameWorkbenchBranch(_organizationId: string, input: TaskWorkbenchRenameInput): Promise { - await workbench.renameBranch(input); - emitOrganizationSnapshot(); - emitTaskUpdate(input.taskId); - }, - - async createWorkbenchSession(_organizationId: string, input: TaskWorkbenchSelectInput & { model?: string }): Promise<{ sessionId: string }> { - const created = await workbench.addSession(input); + async createWorkspaceSession(_organizationId: string, input: TaskWorkspaceSelectInput & { model?: string }): Promise<{ sessionId: string }> { + const created = await workspace.addSession(input); emitOrganizationSnapshot(); emitTaskUpdate(input.taskId); emitSessionUpdate(input.taskId, created.sessionId); return created; }, - async renameWorkbenchSession(_organizationId: string, input: TaskWorkbenchRenameSessionInput): Promise { - await workbench.renameSession(input); + async renameWorkspaceSession(_organizationId: string, input: TaskWorkspaceRenameSessionInput): Promise { + await workspace.renameSession(input); emitOrganizationSnapshot(); emitTaskUpdate(input.taskId); emitSessionUpdate(input.taskId, input.sessionId); }, - async setWorkbenchSessionUnread(_organizationId: string, input: TaskWorkbenchSetSessionUnreadInput): Promise { - await workbench.setSessionUnread(input); + async setWorkspaceSessionUnread(_organizationId: string, input: TaskWorkspaceSetSessionUnreadInput): Promise { + await workspace.setSessionUnread(input); emitOrganizationSnapshot(); emitTaskUpdate(input.taskId); emitSessionUpdate(input.taskId, input.sessionId); }, - async updateWorkbenchDraft(_organizationId: string, input: TaskWorkbenchUpdateDraftInput): Promise { - await workbench.updateDraft(input); + async updateWorkspaceDraft(_organizationId: string, input: TaskWorkspaceUpdateDraftInput): Promise { + await workspace.updateDraft(input); emitOrganizationSnapshot(); emitTaskUpdate(input.taskId); emitSessionUpdate(input.taskId, input.sessionId); }, - async changeWorkbenchModel(_organizationId: string, input: TaskWorkbenchChangeModelInput): Promise { - await workbench.changeModel(input); + async changeWorkspaceModel(_organizationId: string, input: TaskWorkspaceChangeModelInput): Promise { + await workspace.changeModel(input); emitOrganizationSnapshot(); emitTaskUpdate(input.taskId); emitSessionUpdate(input.taskId, input.sessionId); }, - async sendWorkbenchMessage(_organizationId: string, input: TaskWorkbenchSendMessageInput): Promise { - await workbench.sendMessage(input); + async sendWorkspaceMessage(_organizationId: string, input: TaskWorkspaceSendMessageInput): Promise { + await workspace.sendMessage(input); emitOrganizationSnapshot(); emitTaskUpdate(input.taskId); emitSessionUpdate(input.taskId, input.sessionId); }, - async stopWorkbenchSession(_organizationId: string, input: TaskWorkbenchSessionInput): Promise { - await workbench.stopAgent(input); + async stopWorkspaceSession(_organizationId: string, input: TaskWorkspaceSessionInput): Promise { + await workspace.stopAgent(input); emitOrganizationSnapshot(); emitTaskUpdate(input.taskId); emitSessionUpdate(input.taskId, input.sessionId); }, - async closeWorkbenchSession(_organizationId: string, input: TaskWorkbenchSessionInput): Promise { - await workbench.closeSession(input); + async closeWorkspaceSession(_organizationId: string, input: TaskWorkspaceSessionInput): Promise { + await workspace.closeSession(input); emitOrganizationSnapshot(); emitTaskUpdate(input.taskId); }, - async publishWorkbenchPr(_organizationId: string, input: TaskWorkbenchSelectInput): Promise { - await workbench.publishPr(input); + async publishWorkspacePr(_organizationId: string, input: TaskWorkspaceSelectInput): Promise { + await workspace.publishPr(input); emitOrganizationSnapshot(); emitTaskUpdate(input.taskId); }, - async revertWorkbenchFile(_organizationId: string, input: TaskWorkbenchDiffInput): Promise { - await workbench.revertFile(input); + async revertWorkspaceFile(_organizationId: string, input: TaskWorkspaceDiffInput): Promise { + await workspace.revertFile(input); emitOrganizationSnapshot(); emitTaskUpdate(input.taskId); }, - async reloadGithubOrganization(): Promise {}, + async adminReloadGithubOrganization(): Promise {}, - async reloadGithubPullRequests(): Promise {}, + async adminReloadGithubPullRequests(): Promise {}, - async reloadGithubRepository(): Promise {}, + async adminReloadGithubRepository(): Promise {}, - async reloadGithubPullRequest(): Promise {}, + async adminReloadGithubPullRequest(): Promise {}, async health(): Promise<{ ok: true }> { return { ok: true }; diff --git a/foundry/packages/client/src/mock/workbench-client.ts b/foundry/packages/client/src/mock/workspace-client.ts similarity index 83% rename from foundry/packages/client/src/mock/workbench-client.ts rename to foundry/packages/client/src/mock/workspace-client.ts index fbed2d0..0041e9f 100644 --- a/foundry/packages/client/src/mock/workbench-client.ts +++ b/foundry/packages/client/src/mock/workspace-client.ts @@ -1,33 +1,33 @@ import { MODEL_GROUPS, buildInitialMockLayoutViewModel, - groupWorkbenchRepositories, + groupWorkspaceRepositories, nowMs, providerAgent, randomReply, removeFileTreePath, slugify, uid, -} from "../workbench-model.js"; +} from "../workspace-model.js"; import type { - TaskWorkbenchAddSessionResponse, - TaskWorkbenchChangeModelInput, - TaskWorkbenchCreateTaskInput, - TaskWorkbenchCreateTaskResponse, - TaskWorkbenchDiffInput, - TaskWorkbenchRenameInput, - TaskWorkbenchRenameSessionInput, - TaskWorkbenchSelectInput, - TaskWorkbenchSetSessionUnreadInput, - TaskWorkbenchSendMessageInput, - TaskWorkbenchSnapshot, - TaskWorkbenchSessionInput, - TaskWorkbenchUpdateDraftInput, - WorkbenchSession as AgentSession, - WorkbenchTask as Task, - WorkbenchTranscriptEvent as TranscriptEvent, + TaskWorkspaceAddSessionResponse, + TaskWorkspaceChangeModelInput, + TaskWorkspaceCreateTaskInput, + TaskWorkspaceCreateTaskResponse, + TaskWorkspaceDiffInput, + TaskWorkspaceRenameInput, + TaskWorkspaceRenameSessionInput, + TaskWorkspaceSelectInput, + TaskWorkspaceSetSessionUnreadInput, + TaskWorkspaceSendMessageInput, + TaskWorkspaceSnapshot, + TaskWorkspaceSessionInput, + TaskWorkspaceUpdateDraftInput, + WorkspaceSession as AgentSession, + WorkspaceTask as Task, + WorkspaceTranscriptEvent as TranscriptEvent, } from "@sandbox-agent/foundry-shared"; -import type { TaskWorkbenchClient } from "../workbench-client.js"; +import type { TaskWorkspaceClient } from "../workspace-client.js"; function buildTranscriptEvent(params: { sessionId: string; @@ -47,12 +47,12 @@ function buildTranscriptEvent(params: { }; } -class MockWorkbenchStore implements TaskWorkbenchClient { +class MockWorkspaceStore implements TaskWorkspaceClient { private snapshot = buildInitialMockLayoutViewModel(); private listeners = new Set<() => void>(); private pendingTimers = new Map>(); - getSnapshot(): TaskWorkbenchSnapshot { + getSnapshot(): TaskWorkspaceSnapshot { return this.snapshot; } @@ -63,7 +63,7 @@ class MockWorkbenchStore implements TaskWorkbenchClient { }; } - async createTask(input: TaskWorkbenchCreateTaskInput): Promise { + async createTask(input: TaskWorkspaceCreateTaskInput): Promise { const id = uid(); const sessionId = `session-${id}`; const repo = this.snapshot.repos.find((candidate) => candidate.id === input.repoId); @@ -109,7 +109,7 @@ class MockWorkbenchStore implements TaskWorkbenchClient { return { taskId: id, sessionId }; } - async markTaskUnread(input: TaskWorkbenchSelectInput): Promise { + async markTaskUnread(input: TaskWorkspaceSelectInput): Promise { this.updateTask(input.taskId, (task) => { const targetSession = task.sessions[task.sessions.length - 1] ?? null; if (!targetSession) { @@ -123,7 +123,7 @@ class MockWorkbenchStore implements TaskWorkbenchClient { }); } - async renameTask(input: TaskWorkbenchRenameInput): Promise { + async renameTask(input: TaskWorkspaceRenameInput): Promise { const value = input.value.trim(); if (!value) { throw new Error(`Cannot rename task ${input.taskId} to an empty title`); @@ -131,19 +131,11 @@ class MockWorkbenchStore implements TaskWorkbenchClient { this.updateTask(input.taskId, (task) => ({ ...task, title: value, updatedAtMs: nowMs() })); } - async renameBranch(input: TaskWorkbenchRenameInput): Promise { - const value = input.value.trim(); - if (!value) { - throw new Error(`Cannot rename branch for task ${input.taskId} to an empty value`); - } - this.updateTask(input.taskId, (task) => ({ ...task, branch: value, updatedAtMs: nowMs() })); - } - - async archiveTask(input: TaskWorkbenchSelectInput): Promise { + async archiveTask(input: TaskWorkspaceSelectInput): Promise { this.updateTask(input.taskId, (task) => ({ ...task, status: "archived", updatedAtMs: nowMs() })); } - async publishPr(input: TaskWorkbenchSelectInput): Promise { + async publishPr(input: TaskWorkspaceSelectInput): Promise { const nextPrNumber = Math.max(0, ...this.snapshot.tasks.map((task) => task.pullRequest?.number ?? 0)) + 1; this.updateTask(input.taskId, (task) => ({ ...task, @@ -152,7 +144,7 @@ class MockWorkbenchStore implements TaskWorkbenchClient { })); } - async revertFile(input: TaskWorkbenchDiffInput): Promise { + async revertFile(input: TaskWorkspaceDiffInput): Promise { this.updateTask(input.taskId, (task) => { const file = task.fileChanges.find((entry) => entry.path === input.path); const nextDiffs = { ...task.diffs }; @@ -167,7 +159,7 @@ class MockWorkbenchStore implements TaskWorkbenchClient { }); } - async updateDraft(input: TaskWorkbenchUpdateDraftInput): Promise { + async updateDraft(input: TaskWorkspaceUpdateDraftInput): Promise { this.assertSession(input.taskId, input.sessionId); this.updateTask(input.taskId, (task) => ({ ...task, @@ -187,7 +179,7 @@ class MockWorkbenchStore implements TaskWorkbenchClient { })); } - async sendMessage(input: TaskWorkbenchSendMessageInput): Promise { + async sendMessage(input: TaskWorkspaceSendMessageInput): Promise { const text = input.text.trim(); if (!text) { throw new Error(`Cannot send an empty mock prompt for task ${input.taskId}`); @@ -288,7 +280,7 @@ class MockWorkbenchStore implements TaskWorkbenchClient { this.pendingTimers.set(input.sessionId, timer); } - async stopAgent(input: TaskWorkbenchSessionInput): Promise { + async stopAgent(input: TaskWorkspaceSessionInput): Promise { this.assertSession(input.taskId, input.sessionId); const existing = this.pendingTimers.get(input.sessionId); if (existing) { @@ -311,14 +303,14 @@ class MockWorkbenchStore implements TaskWorkbenchClient { }); } - async setSessionUnread(input: TaskWorkbenchSetSessionUnreadInput): Promise { + async setSessionUnread(input: TaskWorkspaceSetSessionUnreadInput): Promise { this.updateTask(input.taskId, (currentTask) => ({ ...currentTask, sessions: currentTask.sessions.map((candidate) => (candidate.id === input.sessionId ? { ...candidate, unread: input.unread } : candidate)), })); } - async renameSession(input: TaskWorkbenchRenameSessionInput): Promise { + async renameSession(input: TaskWorkspaceRenameSessionInput): Promise { const title = input.title.trim(); if (!title) { throw new Error(`Cannot rename session ${input.sessionId} to an empty title`); @@ -329,7 +321,7 @@ class MockWorkbenchStore implements TaskWorkbenchClient { })); } - async closeSession(input: TaskWorkbenchSessionInput): Promise { + async closeSession(input: TaskWorkspaceSessionInput): Promise { this.updateTask(input.taskId, (currentTask) => { if (currentTask.sessions.length <= 1) { return currentTask; @@ -342,7 +334,7 @@ class MockWorkbenchStore implements TaskWorkbenchClient { }); } - async addSession(input: TaskWorkbenchSelectInput): Promise { + async addSession(input: TaskWorkspaceSelectInput): Promise { this.assertTask(input.taskId); const nextSessionId = uid(); const nextSession: AgentSession = { @@ -368,7 +360,7 @@ class MockWorkbenchStore implements TaskWorkbenchClient { return { sessionId: nextSession.id }; } - async changeModel(input: TaskWorkbenchChangeModelInput): Promise { + async changeModel(input: TaskWorkspaceChangeModelInput): Promise { const group = MODEL_GROUPS.find((candidate) => candidate.models.some((entry) => entry.id === input.model)); if (!group) { throw new Error(`Unable to resolve model provider for ${input.model}`); @@ -382,11 +374,11 @@ class MockWorkbenchStore implements TaskWorkbenchClient { })); } - private updateState(updater: (current: TaskWorkbenchSnapshot) => TaskWorkbenchSnapshot): void { + private updateState(updater: (current: TaskWorkspaceSnapshot) => TaskWorkspaceSnapshot): void { const nextSnapshot = updater(this.snapshot); this.snapshot = { ...nextSnapshot, - repositories: groupWorkbenchRepositories(nextSnapshot.repos, nextSnapshot.tasks), + repositories: groupWorkspaceRepositories(nextSnapshot.repos, nextSnapshot.tasks), }; this.notify(); } @@ -436,11 +428,11 @@ function candidateEventIndex(task: Task, sessionId: string): number { return (session?.transcript.length ?? 0) + 1; } -let sharedMockWorkbenchClient: TaskWorkbenchClient | null = null; +let sharedMockWorkspaceClient: TaskWorkspaceClient | null = null; -export function getSharedMockWorkbenchClient(): TaskWorkbenchClient { - if (!sharedMockWorkbenchClient) { - sharedMockWorkbenchClient = new MockWorkbenchStore(); +export function getSharedMockWorkspaceClient(): TaskWorkspaceClient { + if (!sharedMockWorkspaceClient) { + sharedMockWorkspaceClient = new MockWorkspaceStore(); } - return sharedMockWorkbenchClient; + return sharedMockWorkspaceClient; } diff --git a/foundry/packages/client/src/remote/app-client.ts b/foundry/packages/client/src/remote/app-client.ts index 6daa2c5..f1cb908 100644 --- a/foundry/packages/client/src/remote/app-client.ts +++ b/foundry/packages/client/src/remote/app-client.ts @@ -1,4 +1,4 @@ -import type { FoundryAppSnapshot, FoundryBillingPlanId, UpdateFoundryOrganizationProfileInput } from "@sandbox-agent/foundry-shared"; +import type { FoundryAppSnapshot, FoundryBillingPlanId, UpdateFoundryOrganizationProfileInput, WorkspaceModelId } from "@sandbox-agent/foundry-shared"; import type { BackendClient } from "../backend-client.js"; import type { FoundryAppClient } from "../app-client.js"; @@ -72,6 +72,11 @@ class RemoteFoundryAppStore implements FoundryAppClient { this.notify(); } + async setDefaultModel(model: WorkspaceModelId): Promise { + this.snapshot = await this.backend.setAppDefaultModel(model); + this.notify(); + } + async updateOrganizationProfile(input: UpdateFoundryOrganizationProfileInput): Promise { this.snapshot = await this.backend.updateAppOrganizationProfile(input); this.notify(); diff --git a/foundry/packages/client/src/remote/workbench-client.ts b/foundry/packages/client/src/remote/workbench-client.ts deleted file mode 100644 index 0dcbecb..0000000 --- a/foundry/packages/client/src/remote/workbench-client.ts +++ /dev/null @@ -1,198 +0,0 @@ -import type { - TaskWorkbenchAddSessionResponse, - TaskWorkbenchChangeModelInput, - TaskWorkbenchCreateTaskInput, - TaskWorkbenchCreateTaskResponse, - TaskWorkbenchDiffInput, - TaskWorkbenchRenameInput, - TaskWorkbenchRenameSessionInput, - TaskWorkbenchSelectInput, - TaskWorkbenchSetSessionUnreadInput, - TaskWorkbenchSendMessageInput, - TaskWorkbenchSnapshot, - TaskWorkbenchSessionInput, - TaskWorkbenchUpdateDraftInput, -} from "@sandbox-agent/foundry-shared"; -import type { BackendClient } from "../backend-client.js"; -import { groupWorkbenchRepositories } from "../workbench-model.js"; -import type { TaskWorkbenchClient } from "../workbench-client.js"; - -export interface RemoteWorkbenchClientOptions { - backend: BackendClient; - organizationId: string; -} - -class RemoteWorkbenchStore implements TaskWorkbenchClient { - private readonly backend: BackendClient; - private readonly organizationId: string; - private snapshot: TaskWorkbenchSnapshot; - private readonly listeners = new Set<() => void>(); - private unsubscribeWorkbench: (() => void) | null = null; - private refreshPromise: Promise | null = null; - private refreshRetryTimeout: ReturnType | null = null; - - constructor(options: RemoteWorkbenchClientOptions) { - this.backend = options.backend; - this.organizationId = options.organizationId; - this.snapshot = { - organizationId: options.organizationId, - repos: [], - repositories: [], - tasks: [], - }; - } - - getSnapshot(): TaskWorkbenchSnapshot { - return this.snapshot; - } - - subscribe(listener: () => void): () => void { - this.listeners.add(listener); - this.ensureStarted(); - return () => { - this.listeners.delete(listener); - if (this.listeners.size === 0 && this.refreshRetryTimeout) { - clearTimeout(this.refreshRetryTimeout); - this.refreshRetryTimeout = null; - } - if (this.listeners.size === 0 && this.unsubscribeWorkbench) { - this.unsubscribeWorkbench(); - this.unsubscribeWorkbench = null; - } - }; - } - - async createTask(input: TaskWorkbenchCreateTaskInput): Promise { - const created = await this.backend.createWorkbenchTask(this.organizationId, input); - await this.refresh(); - return created; - } - - async markTaskUnread(input: TaskWorkbenchSelectInput): Promise { - await this.backend.markWorkbenchUnread(this.organizationId, input); - await this.refresh(); - } - - async renameTask(input: TaskWorkbenchRenameInput): Promise { - await this.backend.renameWorkbenchTask(this.organizationId, input); - await this.refresh(); - } - - async renameBranch(input: TaskWorkbenchRenameInput): Promise { - await this.backend.renameWorkbenchBranch(this.organizationId, input); - await this.refresh(); - } - - async archiveTask(input: TaskWorkbenchSelectInput): Promise { - await this.backend.runAction(this.organizationId, input.taskId, "archive"); - await this.refresh(); - } - - async publishPr(input: TaskWorkbenchSelectInput): Promise { - await this.backend.publishWorkbenchPr(this.organizationId, input); - await this.refresh(); - } - - async revertFile(input: TaskWorkbenchDiffInput): Promise { - await this.backend.revertWorkbenchFile(this.organizationId, input); - await this.refresh(); - } - - async updateDraft(input: TaskWorkbenchUpdateDraftInput): Promise { - await this.backend.updateWorkbenchDraft(this.organizationId, input); - // Skip refresh — the server broadcast will trigger it, and the frontend - // holds local draft state to avoid the round-trip overwriting user input. - } - - async sendMessage(input: TaskWorkbenchSendMessageInput): Promise { - await this.backend.sendWorkbenchMessage(this.organizationId, input); - await this.refresh(); - } - - async stopAgent(input: TaskWorkbenchSessionInput): Promise { - await this.backend.stopWorkbenchSession(this.organizationId, input); - await this.refresh(); - } - - async setSessionUnread(input: TaskWorkbenchSetSessionUnreadInput): Promise { - await this.backend.setWorkbenchSessionUnread(this.organizationId, input); - await this.refresh(); - } - - async renameSession(input: TaskWorkbenchRenameSessionInput): Promise { - await this.backend.renameWorkbenchSession(this.organizationId, input); - await this.refresh(); - } - - async closeSession(input: TaskWorkbenchSessionInput): Promise { - await this.backend.closeWorkbenchSession(this.organizationId, input); - await this.refresh(); - } - - async addSession(input: TaskWorkbenchSelectInput): Promise { - const created = await this.backend.createWorkbenchSession(this.organizationId, input); - await this.refresh(); - return created; - } - - async changeModel(input: TaskWorkbenchChangeModelInput): Promise { - await this.backend.changeWorkbenchModel(this.organizationId, input); - await this.refresh(); - } - - private ensureStarted(): void { - if (!this.unsubscribeWorkbench) { - this.unsubscribeWorkbench = this.backend.subscribeWorkbench(this.organizationId, () => { - void this.refresh().catch(() => { - this.scheduleRefreshRetry(); - }); - }); - } - void this.refresh().catch(() => { - this.scheduleRefreshRetry(); - }); - } - - private scheduleRefreshRetry(): void { - if (this.refreshRetryTimeout || this.listeners.size === 0) { - return; - } - - this.refreshRetryTimeout = setTimeout(() => { - this.refreshRetryTimeout = null; - void this.refresh().catch(() => { - this.scheduleRefreshRetry(); - }); - }, 1_000); - } - - private async refresh(): Promise { - if (this.refreshPromise) { - await this.refreshPromise; - return; - } - - this.refreshPromise = (async () => { - const nextSnapshot = await this.backend.getWorkbench(this.organizationId); - if (this.refreshRetryTimeout) { - clearTimeout(this.refreshRetryTimeout); - this.refreshRetryTimeout = null; - } - this.snapshot = { - ...nextSnapshot, - repositories: nextSnapshot.repositories ?? groupWorkbenchRepositories(nextSnapshot.repos, nextSnapshot.tasks), - }; - for (const listener of [...this.listeners]) { - listener(); - } - })().finally(() => { - this.refreshPromise = null; - }); - - await this.refreshPromise; - } -} - -export function createRemoteWorkbenchClient(options: RemoteWorkbenchClientOptions): TaskWorkbenchClient { - return new RemoteWorkbenchStore(options); -} diff --git a/foundry/packages/client/src/remote/workspace-client.ts b/foundry/packages/client/src/remote/workspace-client.ts new file mode 100644 index 0000000..61c86d7 --- /dev/null +++ b/foundry/packages/client/src/remote/workspace-client.ts @@ -0,0 +1,193 @@ +import type { + TaskWorkspaceAddSessionResponse, + TaskWorkspaceChangeModelInput, + TaskWorkspaceCreateTaskInput, + TaskWorkspaceCreateTaskResponse, + TaskWorkspaceDiffInput, + TaskWorkspaceRenameInput, + TaskWorkspaceRenameSessionInput, + TaskWorkspaceSelectInput, + TaskWorkspaceSetSessionUnreadInput, + TaskWorkspaceSendMessageInput, + TaskWorkspaceSnapshot, + TaskWorkspaceSessionInput, + TaskWorkspaceUpdateDraftInput, +} from "@sandbox-agent/foundry-shared"; +import type { BackendClient } from "../backend-client.js"; +import { groupWorkspaceRepositories } from "../workspace-model.js"; +import type { TaskWorkspaceClient } from "../workspace-client.js"; + +export interface RemoteWorkspaceClientOptions { + backend: BackendClient; + organizationId: string; +} + +class RemoteWorkspaceStore implements TaskWorkspaceClient { + private readonly backend: BackendClient; + private readonly organizationId: string; + private snapshot: TaskWorkspaceSnapshot; + private readonly listeners = new Set<() => void>(); + private unsubscribeWorkspace: (() => void) | null = null; + private refreshPromise: Promise | null = null; + private refreshRetryTimeout: ReturnType | null = null; + + constructor(options: RemoteWorkspaceClientOptions) { + this.backend = options.backend; + this.organizationId = options.organizationId; + this.snapshot = { + organizationId: options.organizationId, + repos: [], + repositories: [], + tasks: [], + }; + } + + getSnapshot(): TaskWorkspaceSnapshot { + return this.snapshot; + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + this.ensureStarted(); + return () => { + this.listeners.delete(listener); + if (this.listeners.size === 0 && this.refreshRetryTimeout) { + clearTimeout(this.refreshRetryTimeout); + this.refreshRetryTimeout = null; + } + if (this.listeners.size === 0 && this.unsubscribeWorkspace) { + this.unsubscribeWorkspace(); + this.unsubscribeWorkspace = null; + } + }; + } + + async createTask(input: TaskWorkspaceCreateTaskInput): Promise { + const created = await this.backend.createWorkspaceTask(this.organizationId, input); + await this.refresh(); + return created; + } + + async markTaskUnread(input: TaskWorkspaceSelectInput): Promise { + await this.backend.markWorkspaceUnread(this.organizationId, input); + await this.refresh(); + } + + async renameTask(input: TaskWorkspaceRenameInput): Promise { + await this.backend.renameWorkspaceTask(this.organizationId, input); + await this.refresh(); + } + + async archiveTask(input: TaskWorkspaceSelectInput): Promise { + await this.backend.runAction(this.organizationId, input.repoId, input.taskId, "archive"); + await this.refresh(); + } + + async publishPr(input: TaskWorkspaceSelectInput): Promise { + await this.backend.publishWorkspacePr(this.organizationId, input); + await this.refresh(); + } + + async revertFile(input: TaskWorkspaceDiffInput): Promise { + await this.backend.revertWorkspaceFile(this.organizationId, input); + await this.refresh(); + } + + async updateDraft(input: TaskWorkspaceUpdateDraftInput): Promise { + await this.backend.updateWorkspaceDraft(this.organizationId, input); + // Skip refresh — the server broadcast will trigger it, and the frontend + // holds local draft state to avoid the round-trip overwriting user input. + } + + async sendMessage(input: TaskWorkspaceSendMessageInput): Promise { + await this.backend.sendWorkspaceMessage(this.organizationId, input); + await this.refresh(); + } + + async stopAgent(input: TaskWorkspaceSessionInput): Promise { + await this.backend.stopWorkspaceSession(this.organizationId, input); + await this.refresh(); + } + + async setSessionUnread(input: TaskWorkspaceSetSessionUnreadInput): Promise { + await this.backend.setWorkspaceSessionUnread(this.organizationId, input); + await this.refresh(); + } + + async renameSession(input: TaskWorkspaceRenameSessionInput): Promise { + await this.backend.renameWorkspaceSession(this.organizationId, input); + await this.refresh(); + } + + async closeSession(input: TaskWorkspaceSessionInput): Promise { + await this.backend.closeWorkspaceSession(this.organizationId, input); + await this.refresh(); + } + + async addSession(input: TaskWorkspaceSelectInput): Promise { + const created = await this.backend.createWorkspaceSession(this.organizationId, input); + await this.refresh(); + return created; + } + + async changeModel(input: TaskWorkspaceChangeModelInput): Promise { + await this.backend.changeWorkspaceModel(this.organizationId, input); + await this.refresh(); + } + + private ensureStarted(): void { + if (!this.unsubscribeWorkspace) { + this.unsubscribeWorkspace = this.backend.subscribeWorkspace(this.organizationId, () => { + void this.refresh().catch(() => { + this.scheduleRefreshRetry(); + }); + }); + } + void this.refresh().catch(() => { + this.scheduleRefreshRetry(); + }); + } + + private scheduleRefreshRetry(): void { + if (this.refreshRetryTimeout || this.listeners.size === 0) { + return; + } + + this.refreshRetryTimeout = setTimeout(() => { + this.refreshRetryTimeout = null; + void this.refresh().catch(() => { + this.scheduleRefreshRetry(); + }); + }, 1_000); + } + + private async refresh(): Promise { + if (this.refreshPromise) { + await this.refreshPromise; + return; + } + + this.refreshPromise = (async () => { + const nextSnapshot = await this.backend.getWorkspace(this.organizationId); + if (this.refreshRetryTimeout) { + clearTimeout(this.refreshRetryTimeout); + this.refreshRetryTimeout = null; + } + this.snapshot = { + ...nextSnapshot, + repositories: nextSnapshot.repositories ?? groupWorkspaceRepositories(nextSnapshot.repos, nextSnapshot.tasks), + }; + for (const listener of [...this.listeners]) { + listener(); + } + })().finally(() => { + this.refreshPromise = null; + }); + + await this.refreshPromise; + } +} + +export function createRemoteWorkspaceClient(options: RemoteWorkspaceClientOptions): TaskWorkspaceClient { + return new RemoteWorkspaceStore(options); +} diff --git a/foundry/packages/client/src/subscription/topics.ts b/foundry/packages/client/src/subscription/topics.ts index f6a0acc..fad3259 100644 --- a/foundry/packages/client/src/subscription/topics.ts +++ b/foundry/packages/client/src/subscription/topics.ts @@ -5,8 +5,8 @@ import type { SandboxProcessesEvent, SessionEvent, TaskEvent, - WorkbenchSessionDetail, - WorkbenchTaskDetail, + WorkspaceSessionDetail, + WorkspaceTaskDetail, OrganizationEvent, OrganizationSummarySnapshot, } from "@sandbox-agent/foundry-shared"; @@ -48,16 +48,6 @@ export interface SandboxProcessesTopicParams { sandboxId: string; } -function upsertById(items: T[], nextItem: T, sort: (left: T, right: T) => number): T[] { - const filtered = items.filter((item) => item.id !== nextItem.id); - return [...filtered, nextItem].sort(sort); -} - -function upsertByPrId(items: T[], nextItem: T, sort: (left: T, right: T) => number): T[] { - const filtered = items.filter((item) => item.prId !== nextItem.prId); - return [...filtered, nextItem].sort(sort); -} - export const topicDefinitions = { app: { key: () => "app", @@ -72,41 +62,7 @@ export const topicDefinitions = { event: "organizationUpdated", connect: (backend: BackendClient, params: OrganizationTopicParams) => backend.connectOrganization(params.organizationId), fetchInitial: (backend: BackendClient, params: OrganizationTopicParams) => backend.getOrganizationSummary(params.organizationId), - applyEvent: (current: OrganizationSummarySnapshot, event: OrganizationEvent) => { - switch (event.type) { - case "taskSummaryUpdated": - return { - ...current, - taskSummaries: upsertById(current.taskSummaries, event.taskSummary, (left, right) => right.updatedAtMs - left.updatedAtMs), - }; - case "taskRemoved": - return { - ...current, - taskSummaries: current.taskSummaries.filter((task) => task.id !== event.taskId), - }; - case "repoAdded": - case "repoUpdated": - return { - ...current, - repos: upsertById(current.repos, event.repo, (left, right) => right.latestActivityMs - left.latestActivityMs), - }; - case "repoRemoved": - return { - ...current, - repos: current.repos.filter((repo) => repo.id !== event.repoId), - }; - case "pullRequestUpdated": - return { - ...current, - openPullRequests: upsertByPrId(current.openPullRequests, event.pullRequest, (left, right) => right.updatedAtMs - left.updatedAtMs), - }; - case "pullRequestRemoved": - return { - ...current, - openPullRequests: current.openPullRequests.filter((pullRequest) => pullRequest.prId !== event.prId), - }; - } - }, + applyEvent: (_current: OrganizationSummarySnapshot, event: OrganizationEvent) => event.snapshot, } satisfies TopicDefinition, task: { @@ -114,8 +70,8 @@ export const topicDefinitions = { event: "taskUpdated", connect: (backend: BackendClient, params: TaskTopicParams) => backend.connectTask(params.organizationId, params.repoId, params.taskId), fetchInitial: (backend: BackendClient, params: TaskTopicParams) => backend.getTaskDetail(params.organizationId, params.repoId, params.taskId), - applyEvent: (_current: WorkbenchTaskDetail, event: TaskEvent) => event.detail, - } satisfies TopicDefinition, + applyEvent: (_current: WorkspaceTaskDetail, event: TaskEvent) => event.detail, + } satisfies TopicDefinition, session: { key: (params: SessionTopicParams) => `session:${params.organizationId}:${params.taskId}:${params.sessionId}`, @@ -123,13 +79,13 @@ export const topicDefinitions = { connect: (backend: BackendClient, params: SessionTopicParams) => backend.connectTask(params.organizationId, params.repoId, params.taskId), fetchInitial: (backend: BackendClient, params: SessionTopicParams) => backend.getSessionDetail(params.organizationId, params.repoId, params.taskId, params.sessionId), - applyEvent: (current: WorkbenchSessionDetail, event: SessionEvent) => { + applyEvent: (current: WorkspaceSessionDetail, event: SessionEvent) => { if (event.session.sessionId !== current.sessionId) { return current; } return event.session; }, - } satisfies TopicDefinition, + } satisfies TopicDefinition, sandboxProcesses: { key: (params: SandboxProcessesTopicParams) => `sandbox:${params.organizationId}:${params.sandboxProviderId}:${params.sandboxId}`, diff --git a/foundry/packages/client/src/workbench-client.ts b/foundry/packages/client/src/workbench-client.ts deleted file mode 100644 index c317649..0000000 --- a/foundry/packages/client/src/workbench-client.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { - TaskWorkbenchAddSessionResponse, - TaskWorkbenchChangeModelInput, - TaskWorkbenchCreateTaskInput, - TaskWorkbenchCreateTaskResponse, - TaskWorkbenchDiffInput, - TaskWorkbenchRenameInput, - TaskWorkbenchRenameSessionInput, - TaskWorkbenchSelectInput, - TaskWorkbenchSetSessionUnreadInput, - TaskWorkbenchSendMessageInput, - TaskWorkbenchSnapshot, - TaskWorkbenchSessionInput, - TaskWorkbenchUpdateDraftInput, -} from "@sandbox-agent/foundry-shared"; -import type { BackendClient } from "./backend-client.js"; -import { getSharedMockWorkbenchClient } from "./mock/workbench-client.js"; -import { createRemoteWorkbenchClient } from "./remote/workbench-client.js"; - -export type TaskWorkbenchClientMode = "mock" | "remote"; - -export interface CreateTaskWorkbenchClientOptions { - mode: TaskWorkbenchClientMode; - backend?: BackendClient; - organizationId?: string; -} - -export interface TaskWorkbenchClient { - getSnapshot(): TaskWorkbenchSnapshot; - subscribe(listener: () => void): () => void; - createTask(input: TaskWorkbenchCreateTaskInput): Promise; - markTaskUnread(input: TaskWorkbenchSelectInput): Promise; - renameTask(input: TaskWorkbenchRenameInput): Promise; - renameBranch(input: TaskWorkbenchRenameInput): Promise; - archiveTask(input: TaskWorkbenchSelectInput): Promise; - publishPr(input: TaskWorkbenchSelectInput): Promise; - revertFile(input: TaskWorkbenchDiffInput): Promise; - updateDraft(input: TaskWorkbenchUpdateDraftInput): Promise; - sendMessage(input: TaskWorkbenchSendMessageInput): Promise; - stopAgent(input: TaskWorkbenchSessionInput): Promise; - setSessionUnread(input: TaskWorkbenchSetSessionUnreadInput): Promise; - renameSession(input: TaskWorkbenchRenameSessionInput): Promise; - closeSession(input: TaskWorkbenchSessionInput): Promise; - addSession(input: TaskWorkbenchSelectInput): Promise; - changeModel(input: TaskWorkbenchChangeModelInput): Promise; -} - -export function createTaskWorkbenchClient(options: CreateTaskWorkbenchClientOptions): TaskWorkbenchClient { - if (options.mode === "mock") { - return getSharedMockWorkbenchClient(); - } - - if (!options.backend) { - throw new Error("Remote task workbench client requires a backend client"); - } - if (!options.organizationId) { - throw new Error("Remote task workbench client requires a organization id"); - } - - return createRemoteWorkbenchClient({ - backend: options.backend, - organizationId: options.organizationId, - }); -} diff --git a/foundry/packages/client/src/workspace-client.ts b/foundry/packages/client/src/workspace-client.ts new file mode 100644 index 0000000..d6505fe --- /dev/null +++ b/foundry/packages/client/src/workspace-client.ts @@ -0,0 +1,63 @@ +import type { + TaskWorkspaceAddSessionResponse, + TaskWorkspaceChangeModelInput, + TaskWorkspaceCreateTaskInput, + TaskWorkspaceCreateTaskResponse, + TaskWorkspaceDiffInput, + TaskWorkspaceRenameInput, + TaskWorkspaceRenameSessionInput, + TaskWorkspaceSelectInput, + TaskWorkspaceSetSessionUnreadInput, + TaskWorkspaceSendMessageInput, + TaskWorkspaceSnapshot, + TaskWorkspaceSessionInput, + TaskWorkspaceUpdateDraftInput, +} from "@sandbox-agent/foundry-shared"; +import type { BackendClient } from "./backend-client.js"; +import { getSharedMockWorkspaceClient } from "./mock/workspace-client.js"; +import { createRemoteWorkspaceClient } from "./remote/workspace-client.js"; + +export type TaskWorkspaceClientMode = "mock" | "remote"; + +export interface CreateTaskWorkspaceClientOptions { + mode: TaskWorkspaceClientMode; + backend?: BackendClient; + organizationId?: string; +} + +export interface TaskWorkspaceClient { + getSnapshot(): TaskWorkspaceSnapshot; + subscribe(listener: () => void): () => void; + createTask(input: TaskWorkspaceCreateTaskInput): Promise; + markTaskUnread(input: TaskWorkspaceSelectInput): Promise; + renameTask(input: TaskWorkspaceRenameInput): Promise; + archiveTask(input: TaskWorkspaceSelectInput): Promise; + publishPr(input: TaskWorkspaceSelectInput): Promise; + revertFile(input: TaskWorkspaceDiffInput): Promise; + updateDraft(input: TaskWorkspaceUpdateDraftInput): Promise; + sendMessage(input: TaskWorkspaceSendMessageInput): Promise; + stopAgent(input: TaskWorkspaceSessionInput): Promise; + setSessionUnread(input: TaskWorkspaceSetSessionUnreadInput): Promise; + renameSession(input: TaskWorkspaceRenameSessionInput): Promise; + closeSession(input: TaskWorkspaceSessionInput): Promise; + addSession(input: TaskWorkspaceSelectInput): Promise; + changeModel(input: TaskWorkspaceChangeModelInput): Promise; +} + +export function createTaskWorkspaceClient(options: CreateTaskWorkspaceClientOptions): TaskWorkspaceClient { + if (options.mode === "mock") { + return getSharedMockWorkspaceClient(); + } + + if (!options.backend) { + throw new Error("Remote task workspace client requires a backend client"); + } + if (!options.organizationId) { + throw new Error("Remote task workspace client requires a organization id"); + } + + return createRemoteWorkspaceClient({ + backend: options.backend, + organizationId: options.organizationId, + }); +} diff --git a/foundry/packages/client/src/workbench-model.ts b/foundry/packages/client/src/workspace-model.ts similarity index 98% rename from foundry/packages/client/src/workbench-model.ts rename to foundry/packages/client/src/workspace-model.ts index afe9e8b..689b262 100644 --- a/foundry/packages/client/src/workbench-model.ts +++ b/foundry/packages/client/src/workspace-model.ts @@ -1,17 +1,17 @@ import type { - WorkbenchAgentKind as AgentKind, - WorkbenchSession as AgentSession, - WorkbenchDiffLineKind as DiffLineKind, - WorkbenchFileTreeNode as FileTreeNode, - WorkbenchTask as Task, - TaskWorkbenchSnapshot, - WorkbenchHistoryEvent as HistoryEvent, - WorkbenchModelGroup as ModelGroup, - WorkbenchModelId as ModelId, - WorkbenchParsedDiffLine as ParsedDiffLine, - WorkbenchRepositorySection, - WorkbenchRepo, - WorkbenchTranscriptEvent as TranscriptEvent, + WorkspaceAgentKind as AgentKind, + WorkspaceSession as AgentSession, + WorkspaceDiffLineKind as DiffLineKind, + WorkspaceFileTreeNode as FileTreeNode, + WorkspaceTask as Task, + TaskWorkspaceSnapshot, + WorkspaceHistoryEvent as HistoryEvent, + WorkspaceModelGroup as ModelGroup, + WorkspaceModelId as ModelId, + WorkspaceParsedDiffLine as ParsedDiffLine, + WorkspaceRepositorySection, + WorkspaceRepo, + WorkspaceTranscriptEvent as TranscriptEvent, } from "@sandbox-agent/foundry-shared"; import rivetDevFixture from "../../../scripts/data/rivet-dev.json" with { type: "json" }; @@ -1300,7 +1300,7 @@ export function buildInitialTasks(): Task[] { * Uses real public repos so the mock sidebar matches what an actual rivet-dev * organization would show after a GitHub sync. */ -function buildMockRepos(): WorkbenchRepo[] { +function buildMockRepos(): WorkspaceRepo[] { return rivetDevFixture.repos.map((r) => ({ id: repoIdFromFullName(r.fullName), label: r.fullName, @@ -1349,19 +1349,19 @@ function buildPrTasks(): Task[] { }); } -export function buildInitialMockLayoutViewModel(): TaskWorkbenchSnapshot { +export function buildInitialMockLayoutViewModel(): TaskWorkspaceSnapshot { const repos = buildMockRepos(); const tasks = [...buildInitialTasks(), ...buildPrTasks()]; return { organizationId: "default", repos, - repositories: groupWorkbenchRepositories(repos, tasks), + repositories: groupWorkspaceRepositories(repos, tasks), tasks, }; } -export function groupWorkbenchRepositories(repos: WorkbenchRepo[], tasks: Task[]): WorkbenchRepositorySection[] { - const grouped = new Map(); +export function groupWorkspaceRepositories(repos: WorkspaceRepo[], tasks: Task[]): WorkspaceRepositorySection[] { + const grouped = new Map(); for (const repo of repos) { grouped.set(repo.id, { diff --git a/foundry/packages/client/test/e2e/full-integration-e2e.test.ts b/foundry/packages/client/test/e2e/full-integration-e2e.test.ts index 8446892..d3851c0 100644 --- a/foundry/packages/client/test/e2e/full-integration-e2e.test.ts +++ b/foundry/packages/client/test/e2e/full-integration-e2e.test.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import { describe, expect, it } from "vitest"; -import type { HistoryEvent, RepoOverview } from "@sandbox-agent/foundry-shared"; +import type { AuditLogEvent as HistoryEvent, RepoOverview } from "@sandbox-agent/foundry-shared"; import { createBackendClient } from "../../src/backend-client.js"; import { requireImportedRepo } from "./helpers.js"; diff --git a/foundry/packages/client/test/e2e/github-pr-e2e.test.ts b/foundry/packages/client/test/e2e/github-pr-e2e.test.ts index 83101fb..642f4eb 100644 --- a/foundry/packages/client/test/e2e/github-pr-e2e.test.ts +++ b/foundry/packages/client/test/e2e/github-pr-e2e.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { TaskRecord, HistoryEvent } from "@sandbox-agent/foundry-shared"; +import type { AuditLogEvent as HistoryEvent, TaskRecord } from "@sandbox-agent/foundry-shared"; import { createBackendClient } from "../../src/backend-client.js"; import { requireImportedRepo } from "./helpers.js"; diff --git a/foundry/packages/client/test/e2e/workbench-e2e.test.ts b/foundry/packages/client/test/e2e/workspace-e2e.test.ts similarity index 81% rename from foundry/packages/client/test/e2e/workbench-e2e.test.ts rename to foundry/packages/client/test/e2e/workspace-e2e.test.ts index 5442795..f2780d0 100644 --- a/foundry/packages/client/test/e2e/workbench-e2e.test.ts +++ b/foundry/packages/client/test/e2e/workspace-e2e.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { TaskWorkbenchSnapshot, WorkbenchSession, WorkbenchTask, WorkbenchModelId, WorkbenchTranscriptEvent } from "@sandbox-agent/foundry-shared"; +import type { TaskWorkspaceSnapshot, WorkspaceSession, WorkspaceTask, WorkspaceModelId, WorkspaceTranscriptEvent } from "@sandbox-agent/foundry-shared"; import { createBackendClient } from "../../src/backend-client.js"; import { requireImportedRepo } from "./helpers.js"; @@ -13,7 +13,7 @@ function requiredEnv(name: string): string { return value; } -function workbenchModelEnv(name: string, fallback: WorkbenchModelId): WorkbenchModelId { +function workspaceModelEnv(name: string, fallback: WorkspaceModelId): WorkspaceModelId { const value = process.env[name]?.trim(); switch (value) { case "claude-sonnet-4": @@ -50,7 +50,7 @@ async function poll(label: string, timeoutMs: number, intervalMs: number, fn: } } -function findTask(snapshot: TaskWorkbenchSnapshot, taskId: string): WorkbenchTask { +function findTask(snapshot: TaskWorkspaceSnapshot, taskId: string): WorkspaceTask { const task = snapshot.tasks.find((candidate) => candidate.id === taskId); if (!task) { throw new Error(`task ${taskId} missing from snapshot`); @@ -58,7 +58,7 @@ function findTask(snapshot: TaskWorkbenchSnapshot, taskId: string): WorkbenchTas return task; } -function findTab(task: WorkbenchTask, sessionId: string): WorkbenchSession { +function findTab(task: WorkspaceTask, sessionId: string): WorkspaceSession { const tab = task.sessions.find((candidate) => candidate.id === sessionId); if (!tab) { throw new Error(`tab ${sessionId} missing from task ${task.id}`); @@ -66,7 +66,7 @@ function findTab(task: WorkbenchTask, sessionId: string): WorkbenchSession { return tab; } -function extractEventText(event: WorkbenchTranscriptEvent): string { +function extractEventText(event: WorkspaceTranscriptEvent): string { const payload = event.payload; if (!payload || typeof payload !== "object") { return String(payload ?? ""); @@ -127,7 +127,7 @@ function extractEventText(event: WorkbenchTranscriptEvent): string { return JSON.stringify(payload); } -function transcriptIncludesAgentText(transcript: WorkbenchTranscriptEvent[], expectedText: string): boolean { +function transcriptIncludesAgentText(transcript: WorkspaceTranscriptEvent[], expectedText: string): boolean { return transcript .filter((event) => event.sender === "agent") .map((event) => extractEventText(event)) @@ -135,15 +135,15 @@ function transcriptIncludesAgentText(transcript: WorkbenchTranscriptEvent[], exp .includes(expectedText); } -describe("e2e(client): workbench flows", () => { +describe("e2e(client): workspace flows", () => { it.skipIf(!RUN_WORKBENCH_E2E)( - "creates a task from an imported repo, adds sessions, exchanges messages, and manages workbench state", + "creates a task from an imported repo, adds sessions, exchanges messages, and manages workspace state", { timeout: 20 * 60_000 }, async () => { const endpoint = process.env.HF_E2E_BACKEND_ENDPOINT?.trim() || "http://127.0.0.1:7741/v1/rivet"; const organizationId = process.env.HF_E2E_WORKSPACE?.trim() || "default"; const repoRemote = requiredEnv("HF_E2E_GITHUB_REPO"); - const model = workbenchModelEnv("HF_E2E_MODEL", "gpt-5.3-codex"); + const model = workspaceModelEnv("HF_E2E_MODEL", "gpt-5.3-codex"); const runId = `wb-${Date.now().toString(36)}`; const expectedFile = `${runId}.txt`; const expectedInitialReply = `WORKBENCH_READY_${runId}`; @@ -155,9 +155,9 @@ describe("e2e(client): workbench flows", () => { }); const repo = await requireImportedRepo(client, organizationId, repoRemote); - const created = await client.createWorkbenchTask(organizationId, { + const created = await client.createWorkspaceTask(organizationId, { repoId: repo.repoId, - title: `Workbench E2E ${runId}`, + title: `Workspace E2E ${runId}`, branch: `e2e/${runId}`, model, task: `Reply with exactly: ${expectedInitialReply}`, @@ -167,7 +167,7 @@ describe("e2e(client): workbench flows", () => { "task provisioning", 12 * 60_000, 2_000, - async () => findTask(await client.getWorkbench(organizationId), created.taskId), + async () => findTask(await client.getWorkspace(organizationId), created.taskId), (task) => task.branch === `e2e/${runId}` && task.sessions.length > 0, ); @@ -177,7 +177,7 @@ describe("e2e(client): workbench flows", () => { "initial agent response", 12 * 60_000, 2_000, - async () => findTask(await client.getWorkbench(organizationId), created.taskId), + async () => findTask(await client.getWorkspace(organizationId), created.taskId), (task) => { const tab = findTab(task, primaryTab.id); return task.status === "idle" && tab.status === "idle" && transcriptIncludesAgentText(tab.transcript, expectedInitialReply); @@ -187,28 +187,28 @@ describe("e2e(client): workbench flows", () => { expect(findTab(initialCompleted, primaryTab.id).sessionId).toBeTruthy(); expect(transcriptIncludesAgentText(findTab(initialCompleted, primaryTab.id).transcript, expectedInitialReply)).toBe(true); - await client.renameWorkbenchTask(organizationId, { + await client.renameWorkspaceTask(organizationId, { taskId: created.taskId, - value: `Workbench E2E ${runId} Renamed`, + value: `Workspace E2E ${runId} Renamed`, }); - await client.renameWorkbenchSession(organizationId, { + await client.renameWorkspaceSession(organizationId, { taskId: created.taskId, sessionId: primaryTab.id, title: "Primary Session", }); - const secondTab = await client.createWorkbenchSession(organizationId, { + const secondTab = await client.createWorkspaceSession(organizationId, { taskId: created.taskId, model, }); - await client.renameWorkbenchSession(organizationId, { + await client.renameWorkspaceSession(organizationId, { taskId: created.taskId, sessionId: secondTab.sessionId, title: "Follow-up Session", }); - await client.updateWorkbenchDraft(organizationId, { + await client.updateWorkspaceDraft(organizationId, { taskId: created.taskId, sessionId: secondTab.sessionId, text: [ @@ -226,11 +226,11 @@ describe("e2e(client): workbench flows", () => { ], }); - const drafted = findTask(await client.getWorkbench(organizationId), created.taskId); + const drafted = findTask(await client.getWorkspace(organizationId), created.taskId); expect(findTab(drafted, secondTab.sessionId).draft.text).toContain(expectedReply); expect(findTab(drafted, secondTab.sessionId).draft.attachments).toHaveLength(1); - await client.sendWorkbenchMessage(organizationId, { + await client.sendWorkspaceMessage(organizationId, { taskId: created.taskId, sessionId: secondTab.sessionId, text: [ @@ -252,7 +252,7 @@ describe("e2e(client): workbench flows", () => { "follow-up session response", 10 * 60_000, 2_000, - async () => findTask(await client.getWorkbench(organizationId), created.taskId), + async () => findTask(await client.getWorkspace(organizationId), created.taskId), (task) => { const tab = findTab(task, secondTab.sessionId); return ( @@ -265,17 +265,17 @@ describe("e2e(client): workbench flows", () => { expect(transcriptIncludesAgentText(secondTranscript, expectedReply)).toBe(true); expect(withSecondReply.fileChanges.some((file) => file.path === expectedFile)).toBe(true); - await client.setWorkbenchSessionUnread(organizationId, { + await client.setWorkspaceSessionUnread(organizationId, { taskId: created.taskId, sessionId: secondTab.sessionId, unread: false, }); - await client.markWorkbenchUnread(organizationId, { taskId: created.taskId }); + await client.markWorkspaceUnread(organizationId, { taskId: created.taskId }); - const unreadSnapshot = findTask(await client.getWorkbench(organizationId), created.taskId); + const unreadSnapshot = findTask(await client.getWorkspace(organizationId), created.taskId); expect(unreadSnapshot.sessions.some((tab) => tab.unread)).toBe(true); - await client.closeWorkbenchSession(organizationId, { + await client.closeWorkspaceSession(organizationId, { taskId: created.taskId, sessionId: secondTab.sessionId, }); @@ -284,26 +284,26 @@ describe("e2e(client): workbench flows", () => { "secondary session closed", 30_000, 1_000, - async () => findTask(await client.getWorkbench(organizationId), created.taskId), + async () => findTask(await client.getWorkspace(organizationId), created.taskId), (task) => !task.sessions.some((tab) => tab.id === secondTab.sessionId), ); expect(closedSnapshot.sessions).toHaveLength(1); - await client.revertWorkbenchFile(organizationId, { + await client.revertWorkspaceFile(organizationId, { taskId: created.taskId, path: expectedFile, }); const revertedSnapshot = await poll( - "file revert reflected in workbench", + "file revert reflected in workspace", 30_000, 1_000, - async () => findTask(await client.getWorkbench(organizationId), created.taskId), + async () => findTask(await client.getWorkspace(organizationId), created.taskId), (task) => !task.fileChanges.some((file) => file.path === expectedFile), ); expect(revertedSnapshot.fileChanges.some((file) => file.path === expectedFile)).toBe(false); - expect(revertedSnapshot.title).toBe(`Workbench E2E ${runId} Renamed`); + expect(revertedSnapshot.title).toBe(`Workspace E2E ${runId} Renamed`); expect(findTab(revertedSnapshot, primaryTab.id).sessionName).toBe("Primary Session"); }, ); diff --git a/foundry/packages/client/test/e2e/workbench-load-e2e.test.ts b/foundry/packages/client/test/e2e/workspace-load-e2e.test.ts similarity index 87% rename from foundry/packages/client/test/e2e/workbench-load-e2e.test.ts rename to foundry/packages/client/test/e2e/workspace-load-e2e.test.ts index b358b80..68bdf00 100644 --- a/foundry/packages/client/test/e2e/workbench-load-e2e.test.ts +++ b/foundry/packages/client/test/e2e/workspace-load-e2e.test.ts @@ -1,11 +1,11 @@ import { describe, expect, it } from "vitest"; import { createFoundryLogger, - type TaskWorkbenchSnapshot, - type WorkbenchSession, - type WorkbenchTask, - type WorkbenchModelId, - type WorkbenchTranscriptEvent, + type TaskWorkspaceSnapshot, + type WorkspaceSession, + type WorkspaceTask, + type WorkspaceModelId, + type WorkspaceTranscriptEvent, } from "@sandbox-agent/foundry-shared"; import { createBackendClient } from "../../src/backend-client.js"; import { requireImportedRepo } from "./helpers.js"; @@ -14,7 +14,7 @@ const RUN_WORKBENCH_LOAD_E2E = process.env.HF_ENABLE_DAEMON_WORKBENCH_LOAD_E2E = const logger = createFoundryLogger({ service: "foundry-client-e2e", bindings: { - suite: "workbench-load", + suite: "workspace-load", }, }); @@ -26,7 +26,7 @@ function requiredEnv(name: string): string { return value; } -function workbenchModelEnv(name: string, fallback: WorkbenchModelId): WorkbenchModelId { +function workspaceModelEnv(name: string, fallback: WorkspaceModelId): WorkspaceModelId { const value = process.env[name]?.trim(); switch (value) { case "claude-sonnet-4": @@ -72,7 +72,7 @@ async function poll(label: string, timeoutMs: number, intervalMs: number, fn: } } -function findTask(snapshot: TaskWorkbenchSnapshot, taskId: string): WorkbenchTask { +function findTask(snapshot: TaskWorkspaceSnapshot, taskId: string): WorkspaceTask { const task = snapshot.tasks.find((candidate) => candidate.id === taskId); if (!task) { throw new Error(`task ${taskId} missing from snapshot`); @@ -80,7 +80,7 @@ function findTask(snapshot: TaskWorkbenchSnapshot, taskId: string): WorkbenchTas return task; } -function findTab(task: WorkbenchTask, sessionId: string): WorkbenchSession { +function findTab(task: WorkspaceTask, sessionId: string): WorkspaceSession { const tab = task.sessions.find((candidate) => candidate.id === sessionId); if (!tab) { throw new Error(`tab ${sessionId} missing from task ${task.id}`); @@ -88,7 +88,7 @@ function findTab(task: WorkbenchTask, sessionId: string): WorkbenchSession { return tab; } -function extractEventText(event: WorkbenchTranscriptEvent): string { +function extractEventText(event: WorkspaceTranscriptEvent): string { const payload = event.payload; if (!payload || typeof payload !== "object") { return String(payload ?? ""); @@ -138,7 +138,7 @@ function extractEventText(event: WorkbenchTranscriptEvent): string { return typeof envelope.method === "string" ? envelope.method : JSON.stringify(payload); } -function transcriptIncludesAgentText(transcript: WorkbenchTranscriptEvent[], expectedText: string): boolean { +function transcriptIncludesAgentText(transcript: WorkspaceTranscriptEvent[], expectedText: string): boolean { return transcript .filter((event) => event.sender === "agent") .map((event) => extractEventText(event)) @@ -150,7 +150,7 @@ function average(values: number[]): number { return values.reduce((sum, value) => sum + value, 0) / Math.max(values.length, 1); } -async function measureWorkbenchSnapshot( +async function measureWorkspaceSnapshot( client: ReturnType, organizationId: string, iterations: number, @@ -163,11 +163,11 @@ async function measureWorkbenchSnapshot( transcriptEventCount: number; }> { const durations: number[] = []; - let snapshot: TaskWorkbenchSnapshot | null = null; + let snapshot: TaskWorkspaceSnapshot | null = null; for (let index = 0; index < iterations; index += 1) { const startedAt = performance.now(); - snapshot = await client.getWorkbench(organizationId); + snapshot = await client.getWorkspace(organizationId); durations.push(performance.now() - startedAt); } @@ -191,12 +191,12 @@ async function measureWorkbenchSnapshot( }; } -describe("e2e(client): workbench load", () => { +describe("e2e(client): workspace 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/v1/rivet"; const organizationId = process.env.HF_E2E_WORKSPACE?.trim() || "default"; const repoRemote = requiredEnv("HF_E2E_GITHUB_REPO"); - const model = workbenchModelEnv("HF_E2E_MODEL", "gpt-5.3-codex"); + const model = workspaceModelEnv("HF_E2E_MODEL", "gpt-5.3-codex"); const taskCount = intEnv("HF_LOAD_TASK_COUNT", 3); const extraSessionCount = intEnv("HF_LOAD_EXTRA_SESSION_COUNT", 2); const pollIntervalMs = intEnv("HF_LOAD_POLL_INTERVAL_MS", 2_000); @@ -220,16 +220,16 @@ describe("e2e(client): workbench load", () => { transcriptEventCount: number; }> = []; - snapshotSeries.push(await measureWorkbenchSnapshot(client, organizationId, 2)); + snapshotSeries.push(await measureWorkspaceSnapshot(client, organizationId, 2)); for (let taskIndex = 0; taskIndex < taskCount; taskIndex += 1) { const runId = `load-${taskIndex}-${Date.now().toString(36)}`; const initialReply = `LOAD_INIT_${runId}`; const createStartedAt = performance.now(); - const created = await client.createWorkbenchTask(organizationId, { + const created = await client.createWorkspaceTask(organizationId, { repoId: repo.repoId, - title: `Workbench Load ${runId}`, + title: `Workspace Load ${runId}`, branch: `load/${runId}`, model, task: `Reply with exactly: ${initialReply}`, @@ -241,7 +241,7 @@ describe("e2e(client): workbench load", () => { `task ${runId} provisioning`, 12 * 60_000, pollIntervalMs, - async () => findTask(await client.getWorkbench(organizationId), created.taskId), + async () => findTask(await client.getWorkspace(organizationId), created.taskId), (task) => { const tab = task.sessions[0]; return Boolean(tab && task.status === "idle" && tab.status === "idle" && transcriptIncludesAgentText(tab.transcript, initialReply)); @@ -256,13 +256,13 @@ describe("e2e(client): workbench load", () => { for (let sessionIndex = 0; sessionIndex < extraSessionCount; sessionIndex += 1) { const expectedReply = `LOAD_REPLY_${runId}_${sessionIndex}`; const createSessionStartedAt = performance.now(); - const createdSession = await client.createWorkbenchSession(organizationId, { + const createdSession = await client.createWorkspaceSession(organizationId, { taskId: created.taskId, model, }); createSessionLatencies.push(performance.now() - createSessionStartedAt); - await client.sendWorkbenchMessage(organizationId, { + await client.sendWorkspaceMessage(organizationId, { taskId: created.taskId, sessionId: createdSession.sessionId, text: `Run pwd in the repo, then reply with exactly: ${expectedReply}`, @@ -274,7 +274,7 @@ describe("e2e(client): workbench load", () => { `task ${runId} session ${sessionIndex} reply`, 10 * 60_000, pollIntervalMs, - async () => findTask(await client.getWorkbench(organizationId), created.taskId), + async () => findTask(await client.getWorkspace(organizationId), created.taskId), (task) => { const tab = findTab(task, createdSession.sessionId); return tab.status === "idle" && transcriptIncludesAgentText(tab.transcript, expectedReply); @@ -285,14 +285,14 @@ describe("e2e(client): workbench load", () => { expect(transcriptIncludesAgentText(findTab(withReply, createdSession.sessionId).transcript, expectedReply)).toBe(true); } - const snapshotMetrics = await measureWorkbenchSnapshot(client, organizationId, 3); + const snapshotMetrics = await measureWorkspaceSnapshot(client, organizationId, 3); snapshotSeries.push(snapshotMetrics); logger.info( { taskIndex: taskIndex + 1, ...snapshotMetrics, }, - "workbench_load_snapshot", + "workspace_load_snapshot", ); } @@ -314,7 +314,7 @@ describe("e2e(client): workbench load", () => { snapshotTranscriptFinalCount: lastSnapshot.transcriptEventCount, }; - logger.info(summary, "workbench_load_summary"); + logger.info(summary, "workspace_load_summary"); expect(createTaskLatencies.length).toBe(taskCount); expect(provisionLatencies.length).toBe(taskCount); diff --git a/foundry/packages/client/test/keys.test.ts b/foundry/packages/client/test/keys.test.ts index 9bd6477..eae67cf 100644 --- a/foundry/packages/client/test/keys.test.ts +++ b/foundry/packages/client/test/keys.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { historyKey, organizationKey, repositoryKey, taskKey, taskSandboxKey } from "../src/keys.js"; +import { auditLogKey, organizationKey, repositoryKey, taskKey, taskSandboxKey } from "../src/keys.js"; describe("actor keys", () => { it("prefixes every key with organization namespace", () => { @@ -8,7 +8,7 @@ describe("actor keys", () => { repositoryKey("default", "repo"), taskKey("default", "repo", "task"), taskSandboxKey("default", "sbx"), - historyKey("default", "repo"), + auditLogKey("default", "repo"), ]; for (const key of keys) { diff --git a/foundry/packages/client/test/subscription-manager.test.ts b/foundry/packages/client/test/subscription-manager.test.ts index 9908113..4c8636e 100644 --- a/foundry/packages/client/test/subscription-manager.test.ts +++ b/foundry/packages/client/test/subscription-manager.test.ts @@ -115,17 +115,24 @@ describe("RemoteSubscriptionManager", () => { ]); conn.emit("organizationUpdated", { - type: "taskSummaryUpdated", - taskSummary: { - id: "task-1", - repoId: "repo-1", - title: "Updated task", - status: "running", - repoName: "repo-1", - updatedAtMs: 20, - branch: "feature/live", - pullRequest: null, - sessionsSummary: [], + type: "organizationUpdated", + snapshot: { + organizationId: "org-1", + repos: [], + taskSummaries: [ + { + id: "task-1", + repoId: "repo-1", + title: "Updated task", + status: "running", + repoName: "repo-1", + updatedAtMs: 20, + branch: "feature/live", + pullRequest: null, + sessionsSummary: [], + }, + ], + openPullRequests: [], }, } satisfies OrganizationEvent); diff --git a/foundry/packages/frontend/src/components/dev-panel.tsx b/foundry/packages/frontend/src/components/dev-panel.tsx index 56907ff..b28e8da 100644 --- a/foundry/packages/frontend/src/components/dev-panel.tsx +++ b/foundry/packages/frontend/src/components/dev-panel.tsx @@ -7,10 +7,10 @@ import type { FoundryAppSnapshot, FoundryOrganization, TaskStatus, - TaskWorkbenchSnapshot, - WorkbenchSandboxSummary, - WorkbenchSessionSummary, - WorkbenchTaskStatus, + TaskWorkspaceSnapshot, + WorkspaceSandboxSummary, + WorkspaceSessionSummary, + WorkspaceTaskStatus, } from "@sandbox-agent/foundry-shared"; import { useSubscription } from "@sandbox-agent/foundry-client"; import type { DebugSubscriptionTopic } from "@sandbox-agent/foundry-client"; @@ -18,7 +18,7 @@ import { describeTaskState } from "../features/tasks/status"; interface DevPanelProps { organizationId: string; - snapshot: TaskWorkbenchSnapshot; + snapshot: TaskWorkspaceSnapshot; organization?: FoundryOrganization | null; focusedTask?: DevPanelFocusedTask | null; } @@ -27,14 +27,14 @@ export interface DevPanelFocusedTask { id: string; repoId: string; title: string | null; - status: WorkbenchTaskStatus; + status: WorkspaceTaskStatus; runtimeStatus?: TaskStatus | null; statusMessage?: string | null; branch?: string | null; activeSandboxId?: string | null; activeSessionId?: string | null; - sandboxes?: WorkbenchSandboxSummary[]; - sessions?: WorkbenchSessionSummary[]; + sandboxes?: WorkspaceSandboxSummary[]; + sessions?: WorkspaceSessionSummary[]; } interface TopicInfo { diff --git a/foundry/packages/frontend/src/components/mock-layout.tsx b/foundry/packages/frontend/src/components/mock-layout.tsx index 1ff4d35..9eb7134 100644 --- a/foundry/packages/frontend/src/components/mock-layout.tsx +++ b/foundry/packages/frontend/src/components/mock-layout.tsx @@ -4,11 +4,11 @@ import { useStyletron } from "baseui"; import { createErrorContext, type FoundryOrganization, - type TaskWorkbenchSnapshot, - type WorkbenchOpenPrSummary, - type WorkbenchSessionSummary, - type WorkbenchTaskDetail, - type WorkbenchTaskSummary, + type TaskWorkspaceSnapshot, + type WorkspaceOpenPrSummary, + type WorkspaceSessionSummary, + type WorkspaceTaskDetail, + type WorkspaceTaskSummary, } from "@sandbox-agent/foundry-shared"; import { useSubscription } from "@sandbox-agent/foundry-client"; @@ -39,7 +39,7 @@ import { type Message, type ModelId, } from "./mock-layout/view-model"; -import { activeMockOrganization, useMockAppSnapshot } from "../lib/mock-app"; +import { activeMockOrganization, activeMockUser, useMockAppClient, useMockAppSnapshot } from "../lib/mock-app"; import { backendClient } from "../lib/backend"; import { subscriptionManager } from "../lib/subscription"; import { describeTaskState, isProvisioningTaskStatus } from "../features/tasks/status"; @@ -131,7 +131,7 @@ function GithubInstallationWarning({ } function toSessionModel( - summary: WorkbenchSessionSummary, + summary: WorkspaceSessionSummary, sessionDetail?: { draft: Task["sessions"][number]["draft"]; transcript: Task["sessions"][number]["transcript"] }, ): Task["sessions"][number] { return { @@ -155,8 +155,8 @@ function toSessionModel( } function toTaskModel( - summary: WorkbenchTaskSummary, - detail?: WorkbenchTaskDetail, + summary: WorkspaceTaskSummary, + detail?: WorkspaceTaskDetail, sessionCache?: Map, ): Task { const sessions = detail?.sessionsSummary ?? summary.sessionsSummary; @@ -190,7 +190,7 @@ function isOpenPrTaskId(taskId: string): boolean { return taskId.startsWith(OPEN_PR_TASK_PREFIX); } -function toOpenPrTaskModel(pullRequest: WorkbenchOpenPrSummary): Task { +function toOpenPrTaskModel(pullRequest: WorkspaceOpenPrSummary): Task { return { id: openPrTaskId(pullRequest.prId), repoId: pullRequest.repoId, @@ -241,7 +241,7 @@ function groupRepositories(repos: Array<{ id: string; label: string }>, tasks: T .filter((repo) => repo.tasks.length > 0); } -interface WorkbenchActions { +interface WorkspaceActions { createTask(input: { repoId: string; task: string; @@ -252,7 +252,6 @@ interface WorkbenchActions { }): Promise<{ taskId: string; sessionId?: string }>; markTaskUnread(input: { taskId: string }): Promise; renameTask(input: { taskId: string; value: string }): Promise; - renameBranch(input: { taskId: string; value: string }): Promise; archiveTask(input: { taskId: string }): Promise; publishPr(input: { taskId: string }): Promise; revertFile(input: { taskId: string; path: string }): Promise; @@ -264,14 +263,14 @@ interface WorkbenchActions { closeSession(input: { taskId: string; sessionId: string }): Promise; addSession(input: { taskId: string; model?: string }): Promise<{ sessionId: string }>; changeModel(input: { taskId: string; sessionId: string; model: ModelId }): Promise; - reloadGithubOrganization(): Promise; - reloadGithubPullRequests(): Promise; - reloadGithubRepository(repoId: string): Promise; - reloadGithubPullRequest(repoId: string, prNumber: number): Promise; + adminReloadGithubOrganization(): Promise; + adminReloadGithubPullRequests(): Promise; + adminReloadGithubRepository(repoId: string): Promise; + adminReloadGithubPullRequest(repoId: string, prNumber: number): Promise; } const TranscriptPanel = memo(function TranscriptPanel({ - taskWorkbenchClient, + taskWorkspaceClient, task, hasSandbox, activeSessionId, @@ -290,7 +289,7 @@ const TranscriptPanel = memo(function TranscriptPanel({ selectedSessionHydrating = false, onNavigateToUsage, }: { - taskWorkbenchClient: WorkbenchActions; + taskWorkspaceClient: WorkspaceActions; task: Task; hasSandbox: boolean; activeSessionId: string | null; @@ -310,8 +309,11 @@ const TranscriptPanel = memo(function TranscriptPanel({ onNavigateToUsage?: () => void; }) { const t = useFoundryTokens(); - const [defaultModel, setDefaultModel] = useState("claude-sonnet-4"); - const [editingField, setEditingField] = useState<"title" | "branch" | null>(null); + const appSnapshot = useMockAppSnapshot(); + const appClient = useMockAppClient(); + const currentUser = activeMockUser(appSnapshot); + const defaultModel = currentUser?.defaultModel ?? "claude-sonnet-4"; + const [editingField, setEditingField] = useState<"title" | null>(null); const [editValue, setEditValue] = useState(""); const [editingSessionId, setEditingSessionId] = useState(null); const [editingSessionName, setEditingSessionName] = useState(""); @@ -436,14 +438,14 @@ const TranscriptPanel = memo(function TranscriptPanel({ return; } - void taskWorkbenchClient.setSessionUnread({ + void taskWorkspaceClient.setSessionUnread({ taskId: task.id, sessionId: activeAgentSession.id, unread: false, }); }, [activeAgentSession?.id, activeAgentSession?.unread, task.id]); - const startEditingField = useCallback((field: "title" | "branch", value: string) => { + const startEditingField = useCallback((field: "title", value: string) => { setEditingField(field); setEditValue(value); }, []); @@ -453,18 +455,14 @@ const TranscriptPanel = memo(function TranscriptPanel({ }, []); const commitEditingField = useCallback( - (field: "title" | "branch") => { + (field: "title") => { const value = editValue.trim(); if (!value) { setEditingField(null); return; } - if (field === "title") { - void taskWorkbenchClient.renameTask({ taskId: task.id, value }); - } else { - void taskWorkbenchClient.renameBranch({ taskId: task.id, value }); - } + void taskWorkspaceClient.renameTask({ taskId: task.id, value }); setEditingField(null); }, [editValue, task.id], @@ -474,7 +472,7 @@ const TranscriptPanel = memo(function TranscriptPanel({ const flushDraft = useCallback( (text: string, nextAttachments: LineAttachment[], sessionId: string) => { - void taskWorkbenchClient.updateDraft({ + void taskWorkspaceClient.updateDraft({ taskId: task.id, sessionId, text, @@ -535,7 +533,7 @@ const TranscriptPanel = memo(function TranscriptPanel({ onSetActiveSessionId(promptSession.id); onSetLastAgentSessionId(promptSession.id); - void taskWorkbenchClient.sendMessage({ + void taskWorkspaceClient.sendMessage({ taskId: task.id, sessionId: promptSession.id, text, @@ -548,7 +546,7 @@ const TranscriptPanel = memo(function TranscriptPanel({ return; } - void taskWorkbenchClient.stopAgent({ + void taskWorkspaceClient.stopAgent({ taskId: task.id, sessionId: promptSession.id, }); @@ -562,7 +560,7 @@ const TranscriptPanel = memo(function TranscriptPanel({ onSetLastAgentSessionId(sessionId); const session = task.sessions.find((candidate) => candidate.id === sessionId); if (session?.unread) { - void taskWorkbenchClient.setSessionUnread({ + void taskWorkspaceClient.setSessionUnread({ taskId: task.id, sessionId, unread: false, @@ -576,7 +574,7 @@ const TranscriptPanel = memo(function TranscriptPanel({ const setSessionUnread = useCallback( (sessionId: string, unread: boolean) => { - void taskWorkbenchClient.setSessionUnread({ taskId: task.id, sessionId, unread }); + void taskWorkspaceClient.setSessionUnread({ taskId: task.id, sessionId, unread }); }, [task.id], ); @@ -610,7 +608,7 @@ const TranscriptPanel = memo(function TranscriptPanel({ return; } - void taskWorkbenchClient.renameSession({ + void taskWorkspaceClient.renameSession({ taskId: task.id, sessionId: editingSessionId, title: trimmedName, @@ -631,7 +629,7 @@ const TranscriptPanel = memo(function TranscriptPanel({ } onSyncRouteSession(task.id, nextSessionId); - void taskWorkbenchClient.closeSession({ taskId: task.id, sessionId }); + void taskWorkspaceClient.closeSession({ taskId: task.id, sessionId }); }, [activeSessionId, task.id, task.sessions, lastAgentSessionId, onSetActiveSessionId, onSetLastAgentSessionId, onSyncRouteSession], ); @@ -651,7 +649,7 @@ const TranscriptPanel = memo(function TranscriptPanel({ const addSession = useCallback(() => { void (async () => { - const { sessionId } = await taskWorkbenchClient.addSession({ taskId: task.id }); + const { sessionId } = await taskWorkspaceClient.addSession({ taskId: task.id }); onSetLastAgentSessionId(sessionId); onSetActiveSessionId(sessionId); onSyncRouteSession(task.id, sessionId); @@ -664,7 +662,7 @@ const TranscriptPanel = memo(function TranscriptPanel({ throw new Error(`Unable to change model for task ${task.id} without an active prompt session`); } - void taskWorkbenchClient.changeModel({ + void taskWorkspaceClient.changeModel({ taskId: task.id, sessionId: promptSession.id, model, @@ -939,7 +937,7 @@ const TranscriptPanel = memo(function TranscriptPanel({ messageRefs={messageRefs} historyEvents={historyEvents} onSelectHistoryEvent={jumpToHistoryEvent} - targetMessageId={pendingHistoryTarget && activeSessionId === pendingHistoryTarget.sessionId ? pendingHistoryTarget.messageId : null} + targetMessageId={pendingHistoryTarget && activeAgentSession?.id === pendingHistoryTarget.sessionId ? pendingHistoryTarget.messageId : null} onTargetMessageResolved={() => setPendingHistoryTarget(null)} copiedMessageId={copiedMessageId} onCopyMessage={(message) => { @@ -966,7 +964,9 @@ const TranscriptPanel = memo(function TranscriptPanel({ onStop={stopAgent} onRemoveAttachment={removeAttachment} onChangeModel={changeModel} - onSetDefaultModel={setDefaultModel} + onSetDefaultModel={(model) => { + void appClient.setDefaultModel(model); + }} /> ) : null} @@ -1280,27 +1280,26 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId } const [css] = useStyletron(); const t = useFoundryTokens(); const navigate = useNavigate(); - const taskWorkbenchClient = useMemo( + const taskWorkspaceClient = useMemo( () => ({ - createTask: (input) => backendClient.createWorkbenchTask(organizationId, input), - markTaskUnread: (input) => backendClient.markWorkbenchUnread(organizationId, input), - renameTask: (input) => backendClient.renameWorkbenchTask(organizationId, input), - renameBranch: (input) => backendClient.renameWorkbenchBranch(organizationId, input), - archiveTask: async (input) => backendClient.runAction(organizationId, input.taskId, "archive"), - publishPr: (input) => backendClient.publishWorkbenchPr(organizationId, input), - revertFile: (input) => backendClient.revertWorkbenchFile(organizationId, input), - updateDraft: (input) => backendClient.updateWorkbenchDraft(organizationId, input), - sendMessage: (input) => backendClient.sendWorkbenchMessage(organizationId, input), - stopAgent: (input) => backendClient.stopWorkbenchSession(organizationId, input), - setSessionUnread: (input) => backendClient.setWorkbenchSessionUnread(organizationId, input), - renameSession: (input) => backendClient.renameWorkbenchSession(organizationId, input), - closeSession: (input) => backendClient.closeWorkbenchSession(organizationId, input), - addSession: (input) => backendClient.createWorkbenchSession(organizationId, input), - changeModel: (input) => backendClient.changeWorkbenchModel(organizationId, input), - reloadGithubOrganization: () => backendClient.reloadGithubOrganization(organizationId), - reloadGithubPullRequests: () => backendClient.reloadGithubPullRequests(organizationId), - reloadGithubRepository: (repoId) => backendClient.reloadGithubRepository(organizationId, repoId), - reloadGithubPullRequest: (repoId, prNumber) => backendClient.reloadGithubPullRequest(organizationId, repoId, prNumber), + createTask: (input) => backendClient.createWorkspaceTask(organizationId, input), + markTaskUnread: (input) => backendClient.markWorkspaceUnread(organizationId, input), + renameTask: (input) => backendClient.renameWorkspaceTask(organizationId, input), + archiveTask: async (input) => backendClient.runAction(organizationId, input.repoId, input.taskId, "archive"), + publishPr: (input) => backendClient.publishWorkspacePr(organizationId, input), + revertFile: (input) => backendClient.revertWorkspaceFile(organizationId, input), + updateDraft: (input) => backendClient.updateWorkspaceDraft(organizationId, input), + sendMessage: (input) => backendClient.sendWorkspaceMessage(organizationId, input), + stopAgent: (input) => backendClient.stopWorkspaceSession(organizationId, input), + setSessionUnread: (input) => backendClient.setWorkspaceSessionUnread(organizationId, input), + renameSession: (input) => backendClient.renameWorkspaceSession(organizationId, input), + closeSession: (input) => backendClient.closeWorkspaceSession(organizationId, input), + addSession: (input) => backendClient.createWorkspaceSession(organizationId, input), + changeModel: (input) => backendClient.changeWorkspaceModel(organizationId, input), + adminReloadGithubOrganization: () => backendClient.adminReloadGithubOrganization(organizationId), + adminReloadGithubPullRequests: () => backendClient.adminReloadGithubPullRequests(organizationId), + adminReloadGithubRepository: (repoId) => backendClient.adminReloadGithubRepository(organizationId, repoId), + adminReloadGithubPullRequest: (repoId, prNumber) => backendClient.adminReloadGithubPullRequest(organizationId, repoId, prNumber), }), [organizationId], ); @@ -1495,7 +1494,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId } }, [selectedOpenPullRequest, selectedTaskId, tasks]); const materializeOpenPullRequest = useCallback( - async (pullRequest: WorkbenchOpenPrSummary) => { + async (pullRequest: WorkspaceOpenPrSummary) => { if (resolvingOpenPullRequestsRef.current.has(pullRequest.prId)) { return; } @@ -1504,7 +1503,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId } setMaterializingOpenPrId(pullRequest.prId); try { - const { taskId, sessionId } = await taskWorkbenchClient.createTask({ + const { taskId, sessionId } = await taskWorkspaceClient.createTask({ repoId: pullRequest.repoId, task: `Continue work on GitHub PR #${pullRequest.number}: ${pullRequest.title}`, model: "gpt-5.3-codex", @@ -1534,7 +1533,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId } ); } }, - [navigate, taskWorkbenchClient, organizationId], + [navigate, taskWorkspaceClient, organizationId], ); useEffect(() => { @@ -1664,7 +1663,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId } autoCreatingSessionForTaskRef.current.add(activeTask.id); void (async () => { try { - const { sessionId } = await taskWorkbenchClient.addSession({ taskId: activeTask.id }); + const { sessionId } = await taskWorkspaceClient.addSession({ taskId: activeTask.id }); syncRouteSession(activeTask.id, sessionId, true); } catch (error) { logger.error( @@ -1672,13 +1671,13 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId } taskId: activeTask.id, ...createErrorContext(error), }, - "failed_to_auto_create_workbench_session", + "failed_to_auto_create_workspace_session", ); // Keep the guard in the set on error to prevent retry storms. // The guard is cleared when sessions appear (line above) or the task changes. } })(); - }, [activeTask, selectedSessionId, syncRouteSession, taskWorkbenchClient]); + }, [activeTask, selectedSessionId, syncRouteSession, taskWorkspaceClient]); const createTask = useCallback( (overrideRepoId?: string, options?: { title?: string; task?: string; branch?: string; onBranch?: string }) => { @@ -1688,7 +1687,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId } throw new Error("Cannot create a task without an available repo"); } - const { taskId, sessionId } = await taskWorkbenchClient.createTask({ + const { taskId, sessionId } = await taskWorkspaceClient.createTask({ repoId, task: options?.task ?? "New task", model: "gpt-5.3-codex", @@ -1706,7 +1705,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId } }); })(); }, - [navigate, selectedNewTaskRepoId, taskWorkbenchClient, organizationId], + [navigate, selectedNewTaskRepoId, taskWorkspaceClient, organizationId], ); const openDiffTab = useCallback( @@ -1757,7 +1756,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId } ); const markTaskUnread = useCallback((id: string) => { - void taskWorkbenchClient.markTaskUnread({ taskId: id }); + void taskWorkspaceClient.markTaskUnread({ taskId: id }); }, []); const renameTask = useCallback( @@ -1777,29 +1776,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId } return; } - void taskWorkbenchClient.renameTask({ taskId: id, value: trimmedTitle }); - }, - [tasks], - ); - - const renameBranch = useCallback( - (id: string) => { - const currentTask = tasks.find((task) => task.id === id); - if (!currentTask) { - throw new Error(`Unable to rename missing task ${id}`); - } - - const nextBranch = window.prompt("Rename branch", currentTask.branch ?? ""); - if (nextBranch === null) { - return; - } - - const trimmedBranch = nextBranch.trim(); - if (!trimmedBranch) { - return; - } - - void taskWorkbenchClient.renameBranch({ taskId: id, value: trimmedBranch }); + void taskWorkspaceClient.renameTask({ taskId: id, value: trimmedTitle }); }, [tasks], ); @@ -1808,14 +1785,14 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId } if (!activeTask) { throw new Error("Cannot archive without an active task"); } - void taskWorkbenchClient.archiveTask({ taskId: activeTask.id }); + void taskWorkspaceClient.archiveTask({ taskId: activeTask.id }); }, [activeTask]); const publishPr = useCallback(() => { if (!activeTask) { throw new Error("Cannot publish PR without an active task"); } - void taskWorkbenchClient.publishPr({ taskId: activeTask.id }); + void taskWorkspaceClient.publishPr({ taskId: activeTask.id }); }, [activeTask]); const revertFile = useCallback( @@ -1835,7 +1812,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId } : (current[activeTask.id] ?? null), })); - void taskWorkbenchClient.revertFile({ + void taskWorkspaceClient.revertFile({ taskId: activeTask.id, path, }); @@ -1939,14 +1916,13 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId } onSelectNewTaskRepo={setSelectedNewTaskRepoId} onMarkUnread={markTaskUnread} onRenameTask={renameTask} - onRenameBranch={renameBranch} onReorderRepositories={reorderRepositories} taskOrderByRepository={taskOrderByRepository} onReorderTasks={reorderTasks} - onReloadOrganization={() => void taskWorkbenchClient.reloadGithubOrganization()} - onReloadPullRequests={() => void taskWorkbenchClient.reloadGithubPullRequests()} - onReloadRepository={(repoId) => void taskWorkbenchClient.reloadGithubRepository(repoId)} - onReloadPullRequest={(repoId, prNumber) => void taskWorkbenchClient.reloadGithubPullRequest(repoId, prNumber)} + onReloadOrganization={() => void taskWorkspaceClient.adminReloadGithubOrganization()} + onReloadPullRequests={() => void taskWorkspaceClient.adminReloadGithubPullRequests()} + onReloadRepository={(repoId) => void taskWorkspaceClient.adminReloadGithubRepository(repoId)} + onReloadPullRequest={(repoId, prNumber) => void taskWorkspaceClient.adminReloadGithubPullRequest(repoId, prNumber)} onToggleSidebar={() => setLeftSidebarOpen(false)} /> @@ -2079,7 +2055,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId } {showDevPanel && ( @@ -2114,14 +2090,13 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId } onSelectNewTaskRepo={setSelectedNewTaskRepoId} onMarkUnread={markTaskUnread} onRenameTask={renameTask} - onRenameBranch={renameBranch} onReorderRepositories={reorderRepositories} taskOrderByRepository={taskOrderByRepository} onReorderTasks={reorderTasks} - onReloadOrganization={() => void taskWorkbenchClient.reloadGithubOrganization()} - onReloadPullRequests={() => void taskWorkbenchClient.reloadGithubPullRequests()} - onReloadRepository={(repoId) => void taskWorkbenchClient.reloadGithubRepository(repoId)} - onReloadPullRequest={(repoId, prNumber) => void taskWorkbenchClient.reloadGithubPullRequest(repoId, prNumber)} + onReloadOrganization={() => void taskWorkspaceClient.adminReloadGithubOrganization()} + onReloadPullRequests={() => void taskWorkspaceClient.adminReloadGithubPullRequests()} + onReloadRepository={(repoId) => void taskWorkspaceClient.adminReloadGithubRepository(repoId)} + onReloadPullRequest={(repoId, prNumber) => void taskWorkspaceClient.adminReloadGithubPullRequest(repoId, prNumber)} onToggleSidebar={() => setLeftSidebarOpen(false)} /> @@ -2169,14 +2144,13 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId } onSelectNewTaskRepo={setSelectedNewTaskRepoId} onMarkUnread={markTaskUnread} onRenameTask={renameTask} - onRenameBranch={renameBranch} onReorderRepositories={reorderRepositories} taskOrderByRepository={taskOrderByRepository} onReorderTasks={reorderTasks} - onReloadOrganization={() => void taskWorkbenchClient.reloadGithubOrganization()} - onReloadPullRequests={() => void taskWorkbenchClient.reloadGithubPullRequests()} - onReloadRepository={(repoId) => void taskWorkbenchClient.reloadGithubRepository(repoId)} - onReloadPullRequest={(repoId, prNumber) => void taskWorkbenchClient.reloadGithubPullRequest(repoId, prNumber)} + onReloadOrganization={() => void taskWorkspaceClient.adminReloadGithubOrganization()} + onReloadPullRequests={() => void taskWorkspaceClient.adminReloadGithubPullRequests()} + onReloadRepository={(repoId) => void taskWorkspaceClient.adminReloadGithubRepository(repoId)} + onReloadPullRequest={(repoId, prNumber) => void taskWorkspaceClient.adminReloadGithubPullRequest(repoId, prNumber)} onToggleSidebar={() => { setLeftSidebarPeeking(false); setLeftSidebarOpen(true); @@ -2189,7 +2163,7 @@ export function MockLayout({ organizationId, selectedTaskId, selectedSessionId } {leftSidebarOpen ? : null}
void; onMarkUnread: (id: string) => void; onRenameTask: (id: string) => void; - onRenameBranch: (id: string) => void; onReorderRepositories: (fromIndex: number, toIndex: number) => void; taskOrderByRepository: Record; onReorderTasks: (repositoryId: string, fromIndex: number, toIndex: number) => void; @@ -729,7 +727,6 @@ export const Sidebar = memo(function Sidebar({ } contextMenu.open(event, [ { label: "Rename task", onClick: () => onRenameTask(task.id) }, - { label: "Rename branch", onClick: () => onRenameBranch(task.id) }, { label: "Mark as unread", onClick: () => onMarkUnread(task.id) }, ]); }} diff --git a/foundry/packages/frontend/src/components/mock-layout/transcript-header.tsx b/foundry/packages/frontend/src/components/mock-layout/transcript-header.tsx index a024871..27a6973 100644 --- a/foundry/packages/frontend/src/components/mock-layout/transcript-header.tsx +++ b/foundry/packages/frontend/src/components/mock-layout/transcript-header.tsx @@ -30,11 +30,11 @@ export const TranscriptHeader = memo(function TranscriptHeader({ task: Task; hasSandbox: boolean; activeSession: AgentSession | null | undefined; - editingField: "title" | "branch" | null; + editingField: "title" | null; editValue: string; onEditValueChange: (value: string) => void; - onStartEditingField: (field: "title" | "branch", value: string) => void; - onCommitEditingField: (field: "title" | "branch") => void; + onStartEditingField: (field: "title", value: string) => void; + onCommitEditingField: (field: "title") => void; onCancelEditingField: () => void; onSetActiveSessionUnread: (unread: boolean) => void; sidebarCollapsed?: boolean; @@ -118,55 +118,20 @@ export const TranscriptHeader = memo(function TranscriptHeader({ )} {task.branch ? ( - editingField === "branch" ? ( - onEditValueChange(event.target.value)} - onBlur={() => onCommitEditingField("branch")} - onKeyDown={(event) => { - if (event.key === "Enter") { - onCommitEditingField("branch"); - } else if (event.key === "Escape") { - onCancelEditingField(); - } - }} - className={css({ - appearance: "none", - WebkitAppearance: "none", - margin: "0", - outline: "none", - padding: "2px 8px", - borderRadius: "999px", - border: `1px solid ${t.borderFocus}`, - backgroundColor: t.interactiveSubtle, - color: t.textPrimary, - fontSize: "11px", - whiteSpace: "nowrap", - fontFamily: '"IBM Plex Mono", monospace', - minWidth: "60px", - })} - /> - ) : ( - onStartEditingField("branch", task.branch ?? "")} - className={css({ - padding: "2px 8px", - borderRadius: "999px", - border: `1px solid ${t.borderMedium}`, - backgroundColor: t.interactiveSubtle, - color: t.textPrimary, - fontSize: "11px", - whiteSpace: "nowrap", - fontFamily: '"IBM Plex Mono", monospace', - cursor: "pointer", - ":hover": { borderColor: t.borderFocus }, - })} - > - {task.branch} - - ) + + {task.branch} + ) : null}
diff --git a/foundry/packages/frontend/src/components/mock-layout/view-model.test.ts b/foundry/packages/frontend/src/components/mock-layout/view-model.test.ts index 21228fc..bc6ab87 100644 --- a/foundry/packages/frontend/src/components/mock-layout/view-model.test.ts +++ b/foundry/packages/frontend/src/components/mock-layout/view-model.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "vitest"; -import type { WorkbenchSession } from "@sandbox-agent/foundry-shared"; +import type { WorkspaceSession } from "@sandbox-agent/foundry-shared"; import { buildDisplayMessages } from "./view-model"; -function makeSession(transcript: WorkbenchSession["transcript"]): WorkbenchSession { +function makeSession(transcript: WorkspaceSession["transcript"]): WorkspaceSession { return { id: "session-1", sessionId: "session-1", diff --git a/foundry/packages/frontend/src/components/mock-layout/view-model.ts b/foundry/packages/frontend/src/components/mock-layout/view-model.ts index 83f5c7a..cf29137 100644 --- a/foundry/packages/frontend/src/components/mock-layout/view-model.ts +++ b/foundry/packages/frontend/src/components/mock-layout/view-model.ts @@ -1,17 +1,17 @@ import type { - WorkbenchAgentKind as AgentKind, - WorkbenchSession as AgentSession, - WorkbenchDiffLineKind as DiffLineKind, - WorkbenchFileChange as FileChange, - WorkbenchFileTreeNode as FileTreeNode, - WorkbenchTask as Task, - WorkbenchHistoryEvent as HistoryEvent, - WorkbenchLineAttachment as LineAttachment, - WorkbenchModelGroup as ModelGroup, - WorkbenchModelId as ModelId, - WorkbenchParsedDiffLine as ParsedDiffLine, - WorkbenchRepositorySection as RepositorySection, - WorkbenchTranscriptEvent as TranscriptEvent, + WorkspaceAgentKind as AgentKind, + WorkspaceSession as AgentSession, + WorkspaceDiffLineKind as DiffLineKind, + WorkspaceFileChange as FileChange, + WorkspaceFileTreeNode as FileTreeNode, + WorkspaceTask as Task, + WorkspaceHistoryEvent as HistoryEvent, + WorkspaceLineAttachment as LineAttachment, + WorkspaceModelGroup as ModelGroup, + WorkspaceModelId as ModelId, + WorkspaceParsedDiffLine as ParsedDiffLine, + WorkspaceRepositorySection as RepositorySection, + WorkspaceTranscriptEvent as TranscriptEvent, } from "@sandbox-agent/foundry-shared"; import { extractEventText } from "../../features/sessions/model"; diff --git a/foundry/packages/frontend/src/components/organization-dashboard.tsx b/foundry/packages/frontend/src/components/organization-dashboard.tsx index 461ee90..db50cc1 100644 --- a/foundry/packages/frontend/src/components/organization-dashboard.tsx +++ b/foundry/packages/frontend/src/components/organization-dashboard.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState, type ReactNode } from "react"; -import type { AgentType, RepoBranchRecord, RepoOverview, TaskWorkbenchSnapshot, WorkbenchTaskStatus } from "@sandbox-agent/foundry-shared"; +import type { AgentType, RepoBranchRecord, RepoOverview, TaskWorkspaceSnapshot, WorkspaceTaskStatus } from "@sandbox-agent/foundry-shared"; import { currentFoundryOrganization, useSubscription } from "@sandbox-agent/foundry-client"; import { useMutation, useQuery } from "@tanstack/react-query"; import { Link, useNavigate } from "@tanstack/react-router"; @@ -100,7 +100,7 @@ const AGENT_OPTIONS: SelectItem[] = [ { id: "claude", label: "claude" }, ]; -function statusKind(status: WorkbenchTaskStatus): StatusTagKind { +function statusKind(status: WorkspaceTaskStatus): StatusTagKind { if (status === "running") return "positive"; if (status === "error") return "negative"; if (status === "new" || String(status).startsWith("init_")) return "warning"; @@ -515,7 +515,7 @@ export function OrganizationDashboard({ organizationId, selectedTaskId, selected }; }, [repoOverviewMode, selectedForSession, selectedSummary]); const devPanelSnapshot = useMemo( - (): TaskWorkbenchSnapshot => ({ + (): TaskWorkspaceSnapshot => ({ organizationId, repos: repos.map((repo) => ({ id: repo.id, label: repo.label })), repositories: [], diff --git a/foundry/packages/frontend/src/features/tasks/status.ts b/foundry/packages/frontend/src/features/tasks/status.ts index 90a6673..ac4febe 100644 --- a/foundry/packages/frontend/src/features/tasks/status.ts +++ b/foundry/packages/frontend/src/features/tasks/status.ts @@ -1,4 +1,4 @@ -import type { TaskStatus, WorkbenchSessionStatus } from "@sandbox-agent/foundry-shared"; +import type { TaskStatus, WorkspaceSessionStatus } from "@sandbox-agent/foundry-shared"; import type { HeaderStatusInfo } from "../../components/mock-layout/ui"; export type TaskDisplayStatus = TaskStatus | "new"; @@ -73,7 +73,7 @@ export function describeTaskState(status: TaskDisplayStatus | null | undefined, export function deriveHeaderStatus( taskStatus: TaskDisplayStatus | null | undefined, taskStatusMessage: string | null | undefined, - sessionStatus: WorkbenchSessionStatus | null | undefined, + sessionStatus: WorkspaceSessionStatus | null | undefined, sessionErrorMessage: string | null | undefined, hasSandbox?: boolean, ): HeaderStatusInfo { diff --git a/foundry/packages/frontend/src/lib/mock-app.ts b/foundry/packages/frontend/src/lib/mock-app.ts index acf3009..207c438 100644 --- a/foundry/packages/frontend/src/lib/mock-app.ts +++ b/foundry/packages/frontend/src/lib/mock-app.ts @@ -7,7 +7,13 @@ import { eligibleFoundryOrganizations, type FoundryAppClient, } from "@sandbox-agent/foundry-client"; -import type { FoundryAppSnapshot, FoundryBillingPlanId, FoundryOrganization, UpdateFoundryOrganizationProfileInput } from "@sandbox-agent/foundry-shared"; +import type { + FoundryAppSnapshot, + FoundryBillingPlanId, + FoundryOrganization, + UpdateFoundryOrganizationProfileInput, + WorkspaceModelId, +} from "@sandbox-agent/foundry-shared"; import { backendClient } from "./backend"; import { subscriptionManager } from "./subscription"; import { frontendClientMode } from "./env"; @@ -58,6 +64,9 @@ const remoteAppClient: FoundryAppClient = { async selectOrganization(organizationId: string): Promise { await backendClient.selectAppOrganization(organizationId); }, + async setDefaultModel(defaultModel: WorkspaceModelId): Promise { + await backendClient.setAppDefaultModel(defaultModel); + }, async updateOrganizationProfile(input: UpdateFoundryOrganizationProfileInput): Promise { await backendClient.updateAppOrganizationProfile(input); }, diff --git a/foundry/packages/frontend/src/sandbox-agent-react.d.ts b/foundry/packages/frontend/src/sandbox-agent-react.d.ts index c587db7..60519b3 100644 --- a/foundry/packages/frontend/src/sandbox-agent-react.d.ts +++ b/foundry/packages/frontend/src/sandbox-agent-react.d.ts @@ -43,15 +43,27 @@ declare module "@sandbox-agent/react" { className?: string; classNames?: Partial; endRef?: RefObject; + scrollRef?: RefObject; + scrollToEntryId?: string | null; sessionError?: string | null; eventError?: string | null; isThinking?: boolean; agentId?: string; + virtualize?: boolean; + onAtBottomChange?: (atBottom: boolean) => void; onEventClick?: (eventId: string) => void; onPermissionReply?: (permissionId: string, reply: PermissionReply) => void; + isDividerEntry?: (entry: TranscriptEntry) => boolean; + canOpenEvent?: (entry: TranscriptEntry) => boolean; + getToolGroupSummary?: (entries: TranscriptEntry[]) => string; renderMessageText?: (entry: TranscriptEntry) => ReactNode; renderInlinePendingIndicator?: () => ReactNode; renderThinkingState?: (context: { agentId?: string }) => ReactNode; + renderToolItemIcon?: (entry: TranscriptEntry) => ReactNode; + renderToolGroupIcon?: (entries: TranscriptEntry[], expanded: boolean) => ReactNode; + renderChevron?: (expanded: boolean) => ReactNode; + renderEventLinkContent?: (entry: TranscriptEntry) => ReactNode; + renderPermissionIcon?: (entry: TranscriptEntry) => ReactNode; renderPermissionOptionContent?: (context: PermissionOptionRenderContext) => ReactNode; } diff --git a/foundry/packages/shared/src/app-shell.ts b/foundry/packages/shared/src/app-shell.ts index 93d3b02..e44aab8 100644 --- a/foundry/packages/shared/src/app-shell.ts +++ b/foundry/packages/shared/src/app-shell.ts @@ -1,4 +1,4 @@ -import type { WorkbenchModelId } from "./workbench.js"; +import type { WorkspaceModelId } from "./workspace.js"; export type FoundryBillingPlanId = "free" | "team"; export type FoundryBillingStatus = "active" | "trialing" | "past_due" | "scheduled_cancel"; @@ -14,6 +14,7 @@ export interface FoundryUser { githubLogin: string; roleLabel: string; eligibleOrganizationIds: string[]; + defaultModel: WorkspaceModelId; } export interface FoundryOrganizationMember { @@ -59,7 +60,6 @@ export interface FoundryOrganizationSettings { slug: string; primaryDomain: string; seatAccrualMode: "first_prompt"; - defaultModel: WorkbenchModelId; autoImportRepos: boolean; } diff --git a/foundry/packages/shared/src/contracts.ts b/foundry/packages/shared/src/contracts.ts index d6725f7..8d4092d 100644 --- a/foundry/packages/shared/src/contracts.ts +++ b/foundry/packages/shared/src/contracts.ts @@ -54,7 +54,6 @@ export const CreateTaskInputSchema = z.object({ explicitTitle: z.string().trim().min(1).optional(), explicitBranchName: z.string().trim().min(1).optional(), sandboxProviderId: SandboxProviderIdSchema.optional(), - agentType: AgentTypeSchema.optional(), onBranch: z.string().trim().min(1).optional(), }); export type CreateTaskInput = z.infer; @@ -69,9 +68,7 @@ export const TaskRecordSchema = z.object({ task: z.string().min(1), sandboxProviderId: SandboxProviderIdSchema, status: TaskStatusSchema, - statusMessage: z.string().nullable(), activeSandboxId: z.string().nullable(), - activeSessionId: z.string().nullable(), sandboxes: z.array( z.object({ sandboxId: z.string().min(1), @@ -83,17 +80,12 @@ export const TaskRecordSchema = z.object({ updatedAt: z.number().int(), }), ), - agentType: z.string().nullable(), - prSubmitted: z.boolean(), diffStat: z.string().nullable(), prUrl: z.string().nullable(), prAuthor: z.string().nullable(), ciStatus: z.string().nullable(), reviewStatus: z.string().nullable(), reviewer: z.string().nullable(), - conflictsWithMain: z.string().nullable(), - hasUnpushed: z.string().nullable(), - parentBranch: z.string().nullable(), createdAt: z.number().int(), updatedAt: z.number().int(), }); @@ -112,6 +104,7 @@ export type TaskSummary = z.infer; export const TaskActionInputSchema = z.object({ organizationId: OrganizationIdSchema, + repoId: RepoIdSchema, taskId: z.string().min(1), }); export type TaskActionInput = z.infer; @@ -180,7 +173,7 @@ export const HistoryQueryInputSchema = z.object({ }); export type HistoryQueryInput = z.infer; -export const HistoryEventSchema = z.object({ +export const AuditLogEventSchema = z.object({ id: z.number().int(), organizationId: OrganizationIdSchema, repoId: z.string().nullable(), @@ -190,7 +183,7 @@ export const HistoryEventSchema = z.object({ payloadJson: z.string().min(1), createdAt: z.number().int(), }); -export type HistoryEvent = z.infer; +export type AuditLogEvent = z.infer; export const PruneInputSchema = z.object({ organizationId: OrganizationIdSchema, @@ -201,6 +194,7 @@ export type PruneInput = z.infer; export const KillInputSchema = z.object({ organizationId: OrganizationIdSchema, + repoId: RepoIdSchema, taskId: z.string().min(1), deleteBranch: z.boolean(), abandon: z.boolean(), diff --git a/foundry/packages/shared/src/index.ts b/foundry/packages/shared/src/index.ts index 754bf21..cba186a 100644 --- a/foundry/packages/shared/src/index.ts +++ b/foundry/packages/shared/src/index.ts @@ -3,5 +3,5 @@ export * from "./contracts.js"; export * from "./config.js"; export * from "./logging.js"; export * from "./realtime-events.js"; -export * from "./workbench.js"; +export * from "./workspace.js"; export * from "./organization.js"; diff --git a/foundry/packages/shared/src/realtime-events.ts b/foundry/packages/shared/src/realtime-events.ts index ddb5c2b..2cb07e3 100644 --- a/foundry/packages/shared/src/realtime-events.ts +++ b/foundry/packages/shared/src/realtime-events.ts @@ -1,5 +1,5 @@ import type { FoundryAppSnapshot } from "./app-shell.js"; -import type { WorkbenchOpenPrSummary, WorkbenchRepositorySummary, WorkbenchSessionDetail, WorkbenchTaskDetail, WorkbenchTaskSummary } from "./workbench.js"; +import type { OrganizationSummarySnapshot, WorkspaceSessionDetail, WorkspaceTaskDetail } from "./workspace.js"; export interface SandboxProcessSnapshot { id: string; @@ -16,20 +16,13 @@ export interface SandboxProcessSnapshot { } /** Organization-level events broadcast by the organization actor. */ -export type OrganizationEvent = - | { type: "taskSummaryUpdated"; taskSummary: WorkbenchTaskSummary } - | { type: "taskRemoved"; taskId: string } - | { type: "repoAdded"; repo: WorkbenchRepositorySummary } - | { type: "repoUpdated"; repo: WorkbenchRepositorySummary } - | { type: "repoRemoved"; repoId: string } - | { type: "pullRequestUpdated"; pullRequest: WorkbenchOpenPrSummary } - | { type: "pullRequestRemoved"; prId: string }; +export type OrganizationEvent = { type: "organizationUpdated"; snapshot: OrganizationSummarySnapshot }; /** Task-level events broadcast by the task actor. */ -export type TaskEvent = { type: "taskDetailUpdated"; detail: WorkbenchTaskDetail }; +export type TaskEvent = { type: "taskUpdated"; detail: WorkspaceTaskDetail }; /** Session-level events broadcast by the task actor and filtered by sessionId on the client. */ -export type SessionEvent = { type: "sessionUpdated"; session: WorkbenchSessionDetail }; +export type SessionEvent = { type: "sessionUpdated"; session: WorkspaceSessionDetail }; /** App-level events broadcast by the app organization actor. */ export type AppEvent = { type: "appUpdated"; snapshot: FoundryAppSnapshot }; diff --git a/foundry/packages/shared/src/workbench.ts b/foundry/packages/shared/src/workspace.ts similarity index 51% rename from foundry/packages/shared/src/workbench.ts rename to foundry/packages/shared/src/workspace.ts index 6a0df2e..325d8d6 100644 --- a/foundry/packages/shared/src/workbench.ts +++ b/foundry/packages/shared/src/workspace.ts @@ -1,8 +1,8 @@ -import type { AgentType, SandboxProviderId, TaskStatus } from "./contracts.js"; +import type { SandboxProviderId, TaskStatus } from "./contracts.js"; -export type WorkbenchTaskStatus = TaskStatus | "new"; -export type WorkbenchAgentKind = "Claude" | "Codex" | "Cursor"; -export type WorkbenchModelId = +export type WorkspaceTaskStatus = TaskStatus | "new"; +export type WorkspaceAgentKind = "Claude" | "Codex" | "Cursor"; +export type WorkspaceModelId = | "claude-sonnet-4" | "claude-opus-4" | "gpt-5.3-codex" @@ -11,9 +11,9 @@ export type WorkbenchModelId = | "gpt-5.1-codex-max" | "gpt-5.2" | "gpt-5.1-codex-mini"; -export type WorkbenchSessionStatus = "pending_provision" | "pending_session_create" | "ready" | "running" | "idle" | "error"; +export type WorkspaceSessionStatus = "pending_provision" | "pending_session_create" | "ready" | "running" | "idle" | "error"; -export interface WorkbenchTranscriptEvent { +export interface WorkspaceTranscriptEvent { id: string; eventIndex: number; sessionId: string; @@ -23,23 +23,23 @@ export interface WorkbenchTranscriptEvent { payload: unknown; } -export interface WorkbenchComposerDraft { +export interface WorkspaceComposerDraft { text: string; - attachments: WorkbenchLineAttachment[]; + attachments: WorkspaceLineAttachment[]; updatedAtMs: number | null; } /** Session metadata without transcript content. */ -export interface WorkbenchSessionSummary { +export interface WorkspaceSessionSummary { id: string; /** Stable UI session id used for routing and task-local identity. */ sessionId: string; /** Underlying sandbox session id when provisioning has completed. */ sandboxSessionId?: string | null; sessionName: string; - agent: WorkbenchAgentKind; - model: WorkbenchModelId; - status: WorkbenchSessionStatus; + agent: WorkspaceAgentKind; + model: WorkspaceModelId; + status: WorkspaceSessionStatus; thinkingSinceMs: number | null; unread: boolean; created: boolean; @@ -47,44 +47,44 @@ export interface WorkbenchSessionSummary { } /** Full session content — only fetched when viewing a specific session. */ -export interface WorkbenchSessionDetail { +export interface WorkspaceSessionDetail { /** Stable UI session id used for the session topic key and routing. */ sessionId: string; sandboxSessionId: string | null; sessionName: string; - agent: WorkbenchAgentKind; - model: WorkbenchModelId; - status: WorkbenchSessionStatus; + agent: WorkspaceAgentKind; + model: WorkspaceModelId; + status: WorkspaceSessionStatus; thinkingSinceMs: number | null; unread: boolean; created: boolean; errorMessage?: string | null; - draft: WorkbenchComposerDraft; - transcript: WorkbenchTranscriptEvent[]; + draft: WorkspaceComposerDraft; + transcript: WorkspaceTranscriptEvent[]; } -export interface WorkbenchFileChange { +export interface WorkspaceFileChange { path: string; added: number; removed: number; type: "M" | "A" | "D"; } -export interface WorkbenchFileTreeNode { +export interface WorkspaceFileTreeNode { name: string; path: string; isDir: boolean; - children?: WorkbenchFileTreeNode[]; + children?: WorkspaceFileTreeNode[]; } -export interface WorkbenchLineAttachment { +export interface WorkspaceLineAttachment { id: string; filePath: string; lineNumber: number; lineContent: string; } -export interface WorkbenchHistoryEvent { +export interface WorkspaceHistoryEvent { id: string; messageId: string; preview: string; @@ -94,78 +94,67 @@ export interface WorkbenchHistoryEvent { detail: string; } -export type WorkbenchDiffLineKind = "context" | "add" | "remove" | "hunk"; +export type WorkspaceDiffLineKind = "context" | "add" | "remove" | "hunk"; -export interface WorkbenchParsedDiffLine { - kind: WorkbenchDiffLineKind; +export interface WorkspaceParsedDiffLine { + kind: WorkspaceDiffLineKind; lineNumber: number; text: string; } -export interface WorkbenchPullRequestSummary { - number: number; - status: "draft" | "ready"; -} - -export interface WorkbenchOpenPrSummary { - prId: string; - repoId: string; - repoFullName: string; +export interface WorkspacePullRequestSummary { number: number; title: string; state: string; url: string; headRefName: string; baseRefName: string; + repoFullName: string; authorLogin: string | null; isDraft: boolean; updatedAtMs: number; } -export interface WorkbenchSandboxSummary { +export interface WorkspaceSandboxSummary { sandboxProviderId: SandboxProviderId; sandboxId: string; cwd: string | null; } /** Sidebar-level task data. Materialized in the organization actor's SQLite. */ -export interface WorkbenchTaskSummary { +export interface WorkspaceTaskSummary { id: string; repoId: string; title: string; - status: WorkbenchTaskStatus; + status: WorkspaceTaskStatus; repoName: string; updatedAtMs: number; branch: string | null; - pullRequest: WorkbenchPullRequestSummary | null; + pullRequest: WorkspacePullRequestSummary | null; /** Summary of sessions — no transcript content. */ - sessionsSummary: WorkbenchSessionSummary[]; + sessionsSummary: WorkspaceSessionSummary[]; } /** Full task detail — only fetched when viewing a specific task. */ -export interface WorkbenchTaskDetail extends WorkbenchTaskSummary { +export interface WorkspaceTaskDetail extends WorkspaceTaskSummary { /** Original task prompt/instructions shown in the detail view. */ task: string; - /** Agent choice used when creating new sandbox sessions for this task. */ - agentType: AgentType | null; /** Underlying task runtime status preserved for detail views and error handling. */ runtimeStatus: TaskStatus; - statusMessage: string | null; - activeSessionId: string | null; diffStat: string | null; prUrl: string | null; reviewStatus: string | null; - fileChanges: WorkbenchFileChange[]; + fileChanges: WorkspaceFileChange[]; diffs: Record; - fileTree: WorkbenchFileTreeNode[]; + fileTree: WorkspaceFileTreeNode[]; minutesUsed: number; /** Sandbox info for this task. */ - sandboxes: WorkbenchSandboxSummary[]; + sandboxes: WorkspaceSandboxSummary[]; activeSandboxId: string | null; } /** Repo-level summary for organization sidebar. */ -export interface WorkbenchRepositorySummary { +export interface WorkspaceRepositorySummary { id: string; label: string; /** Aggregated branch/task overview state (replaces getRepoOverview polling). */ @@ -176,121 +165,126 @@ export interface WorkbenchRepositorySummary { /** Organization-level snapshot — initial fetch for the organization topic. */ export interface OrganizationSummarySnapshot { organizationId: string; - repos: WorkbenchRepositorySummary[]; - taskSummaries: WorkbenchTaskSummary[]; - openPullRequests: WorkbenchOpenPrSummary[]; + repos: WorkspaceRepositorySummary[]; + taskSummaries: WorkspaceTaskSummary[]; } -export interface WorkbenchSession extends WorkbenchSessionSummary { - draft: WorkbenchComposerDraft; - transcript: WorkbenchTranscriptEvent[]; +export interface WorkspaceSession extends WorkspaceSessionSummary { + draft: WorkspaceComposerDraft; + transcript: WorkspaceTranscriptEvent[]; } -export interface WorkbenchTask { +export interface WorkspaceTask { id: string; repoId: string; title: string; - status: WorkbenchTaskStatus; + status: WorkspaceTaskStatus; runtimeStatus?: TaskStatus; - statusMessage?: string | null; repoName: string; updatedAtMs: number; branch: string | null; - pullRequest: WorkbenchPullRequestSummary | null; - sessions: WorkbenchSession[]; - fileChanges: WorkbenchFileChange[]; + pullRequest: WorkspacePullRequestSummary | null; + sessions: WorkspaceSession[]; + fileChanges: WorkspaceFileChange[]; diffs: Record; - fileTree: WorkbenchFileTreeNode[]; + fileTree: WorkspaceFileTreeNode[]; minutesUsed: number; activeSandboxId?: string | null; } -export interface WorkbenchRepo { +export interface WorkspaceRepo { id: string; label: string; } -export interface WorkbenchRepositorySection { +export interface WorkspaceRepositorySection { id: string; label: string; updatedAtMs: number; - tasks: WorkbenchTask[]; + tasks: WorkspaceTask[]; } -export interface TaskWorkbenchSnapshot { +export interface TaskWorkspaceSnapshot { organizationId: string; - repos: WorkbenchRepo[]; - repositories: WorkbenchRepositorySection[]; - tasks: WorkbenchTask[]; + repos: WorkspaceRepo[]; + repositories: WorkspaceRepositorySection[]; + tasks: WorkspaceTask[]; } -export interface WorkbenchModelOption { - id: WorkbenchModelId; +export interface WorkspaceModelOption { + id: WorkspaceModelId; label: string; } -export interface WorkbenchModelGroup { +export interface WorkspaceModelGroup { provider: string; - models: WorkbenchModelOption[]; + models: WorkspaceModelOption[]; } -export interface TaskWorkbenchSelectInput { +export interface TaskWorkspaceSelectInput { + repoId: string; taskId: string; + authSessionId?: string; } -export interface TaskWorkbenchCreateTaskInput { +export interface TaskWorkspaceCreateTaskInput { repoId: string; task: string; title?: string; branch?: string; onBranch?: string; - model?: WorkbenchModelId; + model?: WorkspaceModelId; + authSessionId?: string; } -export interface TaskWorkbenchRenameInput { +export interface TaskWorkspaceRenameInput { + repoId: string; taskId: string; value: string; } -export interface TaskWorkbenchSendMessageInput { +export interface TaskWorkspaceSendMessageInput { taskId: string; sessionId: string; text: string; - attachments: WorkbenchLineAttachment[]; + attachments: WorkspaceLineAttachment[]; + authSessionId?: string; } -export interface TaskWorkbenchSessionInput { +export interface TaskWorkspaceSessionInput { taskId: string; sessionId: string; + authSessionId?: string; } -export interface TaskWorkbenchRenameSessionInput extends TaskWorkbenchSessionInput { +export interface TaskWorkspaceRenameSessionInput extends TaskWorkspaceSessionInput { title: string; } -export interface TaskWorkbenchChangeModelInput extends TaskWorkbenchSessionInput { - model: WorkbenchModelId; +export interface TaskWorkspaceChangeModelInput extends TaskWorkspaceSessionInput { + model: WorkspaceModelId; } -export interface TaskWorkbenchUpdateDraftInput extends TaskWorkbenchSessionInput { +export interface TaskWorkspaceUpdateDraftInput extends TaskWorkspaceSessionInput { text: string; - attachments: WorkbenchLineAttachment[]; + attachments: WorkspaceLineAttachment[]; } -export interface TaskWorkbenchSetSessionUnreadInput extends TaskWorkbenchSessionInput { +export interface TaskWorkspaceSetSessionUnreadInput extends TaskWorkspaceSessionInput { unread: boolean; } -export interface TaskWorkbenchDiffInput { +export interface TaskWorkspaceDiffInput { + repoId: string; taskId: string; path: string; } -export interface TaskWorkbenchCreateTaskResponse { +export interface TaskWorkspaceCreateTaskResponse { taskId: string; sessionId?: string; } -export interface TaskWorkbenchAddSessionResponse { +export interface TaskWorkspaceAddSessionResponse { sessionId: string; }