From d75e8c31d105d547b23901fb8896018155f2e337 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Wed, 11 Mar 2026 13:23:54 -0700 Subject: [PATCH] Rename Foundry handoffs to tasks (#239) * Restore foundry onboarding stack * Consolidate foundry rename * Create foundry tasks without prompts * Rename Foundry handoffs to tasks --- .dockerignore | 2 +- .env.development.example | 27 + .gitignore | 2 +- docs/deploy/foundry-self-hosting.mdx | 153 ++ factory/compose.dev.yaml | 90 - .../backend/src/actors/handoff/db/db.ts | 10 - .../src/actors/handoff/db/drizzle.config.ts | 6 - .../db/drizzle/0001_rapid_eddie_brock.sql | 3 - .../db/drizzle/0005_sandbox_actor_id.sql | 1 - .../backend/src/actors/handoff/index.ts | 389 ----- .../src/actors/handoff/workflow/queue.ts | 31 - .../backend/src/actors/history/db/db.ts | 10 - .../backend/src/actors/project/db/db.ts | 10 - .../src/actors/sandbox-instance/db/db.ts | 10 - .../backend/src/actors/workspace/db/db.ts | 10 - .../db/drizzle/0002_tiny_silver_surfer.sql | 4 - .../src/actors/workspace/db/migrations.ts | 50 - .../backend/src/actors/workspace/db/schema.ts | 20 - .../packages/backend/src/db/actor-sqlite.ts | 102 -- factory/packages/backend/src/index.ts | 141 -- .../backend/test/repo-normalize.test.ts | 34 - .../packages/backend/tmp-decode-actors.mjs | 78 - factory/packages/backend/tmp-dump-wfkeys.mjs | 10 - factory/packages/backend/tmp-inspect-deep.mjs | 87 - .../packages/backend/tmp-inspect-stuck.mjs | 51 - .../packages/backend/tmp-inspect-workflow.mjs | 30 - .../client/src/mock/workbench-client.ts | 443 ----- .../packages/client/src/workbench-client.ts | 64 - factory/packages/frontend/src/app/router.tsx | 130 -- {factory => foundry}/AGENTS.md | 0 {factory => foundry}/CLAUDE.md | 51 +- {factory => foundry}/CONTRIBUTING.md | 10 +- {factory => foundry}/Dockerfile | 22 +- {factory => foundry}/README.md | 6 +- foundry/compose.dev.yaml | 90 + {factory => foundry}/compose.preview.yaml | 20 +- .../docker/backend.dev.Dockerfile | 2 +- .../docker/backend.preview.Dockerfile | 6 +- .../docker/frontend.dev.Dockerfile | 2 +- .../docker/frontend.preview.Dockerfile | 8 +- .../docker/nginx.preview.conf | 0 {factory => foundry}/e2e/wb-mmilw7yh.txt | 0 {factory => foundry}/e2e/wb-mmilzdwf.txt | 0 .../foundry-cloud.md | 4 +- {factory => foundry}/memory/roadmap.md | 6 +- .../packages/backend/CLAUDE.md | 14 +- .../packages/backend/package.json | 6 +- .../_scripts/generate-actor-migrations.ts | 0 .../packages/backend/src/actors/context.ts | 19 +- .../packages/backend/src/actors/events.ts | 44 +- .../packages/backend/src/actors/handles.ts | 26 +- .../backend/src/actors/history/db/db.ts | 5 + .../src/actors/history/db/drizzle.config.ts | 0 .../db/drizzle/0000_watery_bushwacker.sql | 2 +- .../db/drizzle/meta/0000_snapshot.json | 4 +- .../history/db/drizzle/meta/_journal.json | 0 .../src/actors/history/db/migrations.ts | 2 +- .../backend/src/actors/history/db/schema.ts | 2 +- .../backend/src/actors/history/index.ts | 14 +- .../packages/backend/src/actors/index.ts | 14 +- .../packages/backend/src/actors/keys.ts | 10 +- .../packages/backend/src/actors/logging.ts | 2 +- .../packages/backend/src/actors/polling.ts | 0 .../src/actors/project-branch-sync/index.ts | 0 .../src/actors/project-pr-sync/index.ts | 0 .../backend/src/actors/project/actions.ts | 282 ++-- .../backend/src/actors/project/db/db.ts | 5 + .../src/actors/project/db/drizzle.config.ts | 0 .../db/drizzle/0000_stormy_the_hunter.sql | 0 .../db/drizzle/0001_wild_carlie_cooper.sql | 0 .../db/drizzle/0002_far_war_machine.sql | 4 +- .../project/db/drizzle/0003_busy_legacy.sql | 0 .../db/drizzle/meta/0000_snapshot.json | 0 .../db/drizzle/meta/0001_snapshot.json | 0 .../db/drizzle/meta/0002_snapshot.json | 8 +- .../project/db/drizzle/meta/_journal.json | 0 .../src/actors/project/db/migrations.ts | 4 +- .../backend/src/actors/project/db/schema.ts | 4 +- .../backend/src/actors/project/index.ts | 2 +- .../backend/src/actors/project/stack-model.ts | 0 .../src/actors/sandbox-instance/db/db.ts | 5 + .../sandbox-instance/db/drizzle.config.ts | 0 .../db/drizzle/0000_broad_tyrannus.sql | 0 .../db/drizzle/0001_sandbox_sessions.sql | 0 .../db/drizzle/meta/0000_snapshot.json | 0 .../db/drizzle/meta/_journal.json | 0 .../actors/sandbox-instance/db/migrations.ts | 0 .../src/actors/sandbox-instance/db/schema.ts | 2 +- .../src/actors/sandbox-instance/index.ts | 17 +- .../src/actors/sandbox-instance/persist.ts | 0 .../src/actors/task-status-sync}/index.ts | 44 +- .../packages/backend/src/actors/task/db/db.ts | 5 + .../src/actors/task/db/drizzle.config.ts | 6 + .../db/drizzle/0000_condemned_maria_hill.sql | 4 +- .../db/drizzle/0001_rapid_eddie_brock.sql | 3 + .../db/drizzle/0002_lazy_moira_mactaggert.sql | 18 +- .../task}/db/drizzle/0003_plucky_bran.sql | 12 +- .../task}/db/drizzle/0004_focused_shuri.sql | 12 +- .../task/db/drizzle/0005_sandbox_actor_id.sql | 1 + .../db/drizzle/0006_workbench_sessions.sql | 2 +- .../task}/db/drizzle/meta/0000_snapshot.json | 8 +- .../task}/db/drizzle/meta/0001_snapshot.json | 8 +- .../task}/db/drizzle/meta/0002_snapshot.json | 18 +- .../task}/db/drizzle/meta/_journal.json | 0 .../backend/src/actors/task}/db/migrations.ts | 56 +- .../backend/src/actors/task}/db/schema.ts | 10 +- .../packages/backend/src/actors/task/index.ts | 385 +++++ .../backend/src/actors/task}/workbench.ts | 64 +- .../src/actors/task}/workflow/commands.ts | 58 +- .../src/actors/task}/workflow/common.ts | 72 +- .../src/actors/task}/workflow/index.ts | 83 +- .../backend/src/actors/task}/workflow/init.ts | 209 +-- .../backend/src/actors/task}/workflow/push.ts | 24 +- .../backend/src/actors/task/workflow/queue.ts | 31 + .../src/actors/task}/workflow/status-sync.ts | 52 +- .../backend/src/actors/workspace/actions.ts | 286 ++-- .../backend/src/actors/workspace/app-shell.ts | 1442 +++++++++++++++++ .../backend/src/actors/workspace/db/db.ts | 5 + .../src/actors/workspace/db/drizzle.config.ts | 0 .../db/drizzle/0000_rare_iron_man.sql | 0 .../drizzle/0001_sleepy_lady_deathstrike.sql | 0 .../db/drizzle/0002_tiny_silver_surfer.sql | 4 + .../db/drizzle/meta/0000_snapshot.json | 0 .../db/drizzle/meta/0001_snapshot.json | 0 .../workspace/db/drizzle/meta/_journal.json | 0 .../src/actors/workspace/db/migrations.ts | 189 +++ .../backend/src/actors/workspace/db/schema.ts | 100 ++ .../backend/src/actors/workspace/index.ts | 0 .../packages/backend/src/config/backend.ts | 4 +- .../packages/backend/src/config/workspace.ts | 2 +- .../packages/backend/src/driver.ts | 13 +- foundry/packages/backend/src/index.ts | 351 ++++ .../src/integrations/daytona/client.ts | 0 .../src/integrations/git-spice/index.ts | 0 .../backend/src/integrations/git/index.ts | 2 +- .../backend/src/integrations/github/index.ts | 0 .../src/integrations/graphite/index.ts | 0 .../src/integrations/sandbox-agent/client.ts | 13 +- .../backend/src/notifications/backends.ts | 6 +- .../backend/src/notifications/index.ts | 6 +- .../src/notifications/state-tracker.ts | 0 .../backend/src/providers/daytona/index.ts | 30 +- .../packages/backend/src/providers/index.ts | 4 +- .../backend/src/providers/local/index.ts | 4 +- .../src/providers/provider-api/index.ts | 4 +- .../backend/src/services/app-github.ts | 489 ++++++ .../backend/src/services/app-shell-runtime.ts | 81 + .../backend/src/services/app-stripe.ts | 284 ++++ .../backend/src/services/create-flow.ts | 14 +- .../backend/src/services/foundry-paths.ts | 8 +- .../packages/backend/src/services/queue.ts | 0 .../backend/src/services/repo-git-lock.ts | 0 .../packages/backend/src/services/repo.ts | 34 + .../packages/backend/src/services/tmux.ts | 0 .../packages/backend/test/create-flow.test.ts | 4 +- .../backend/test/daytona-provider.test.ts | 12 +- .../packages/backend/test/git-spice.test.ts | 0 .../backend/test/git-validate-remote.test.ts | 2 +- .../backend/test/helpers/test-context.ts | 5 +- .../backend/test/helpers/test-driver.ts | 14 +- .../packages/backend/test/keys.test.ts | 8 +- .../backend/test/malformed-uri.test.ts | 0 .../packages/backend/test/providers.test.ts | 4 +- .../backend/test/repo-normalize.test.ts | 34 + .../test/sandbox-instance-persist.test.ts | 0 .../packages/backend/test/setup.ts | 0 .../packages/backend/test/stack-model.test.ts | 0 .../backend/test/workbench-unread.test.ts | 2 +- .../backend/test/workspace-isolation.test.ts | 14 +- .../workspace-star-sandbox-agent-repo.test.ts | 0 .../packages/backend/tsconfig.json | 0 .../packages/backend/vitest.config.ts | 0 .../packages/cli/package.json | 6 +- .../packages/cli/src/backend/manager.ts | 10 +- .../packages/cli/src/build-id.ts | 0 .../packages/cli/src/index.ts | 152 +- .../packages/cli/src/task-editor.ts | 4 +- .../packages/cli/src/theme.ts | 4 +- .../cli/src/themes/opencode-pack.json | 0 {factory => foundry}/packages/cli/src/tmux.ts | 0 {factory => foundry}/packages/cli/src/tui.ts | 54 +- .../packages/cli/src/workspace/config.ts | 4 +- .../packages/cli/test/backend-manager.test.ts | 4 +- .../packages/cli/test/task-editor.test.ts | 0 .../packages/cli/test/theme.test.ts | 8 +- .../packages/cli/test/tmux.test.ts | 0 .../packages/cli/test/tui-format.test.ts | 18 +- .../cli/test/workspace-config.test.ts | 4 +- .../packages/cli/tsconfig.json | 0 .../packages/cli/tsup.config.ts | 0 .../packages/client/package.json | 4 +- foundry/packages/client/src/app-client.ts | 67 + .../packages/client/src/backend-client.ts | 518 +++--- .../packages/client/src/index.ts | 2 + .../packages/client/src/keys.ts | 10 +- foundry/packages/client/src/mock-app.ts | 697 ++++++++ .../client/src/mock/backend-client.ts | 307 ++-- foundry/packages/client/src/mock/latency.ts | 12 + .../client/src/mock/workbench-client.ts | 443 +++++ .../packages/client/src/remote/app-client.ts | 152 ++ .../client/src/remote/workbench-client.ts | 78 +- .../packages/client/src/view-model.ts | 24 +- .../packages/client/src/workbench-client.ts | 64 + .../packages/client/src/workbench-model.ts | 74 +- .../test/e2e/full-integration-e2e.test.ts | 2 +- .../client/test/e2e/github-pr-e2e.test.ts | 90 +- .../client/test/e2e/workbench-e2e.test.ts | 88 +- .../test/e2e/workbench-load-e2e.test.ts | 83 +- .../packages/client/test/keys.test.ts | 15 +- .../packages/client/test/view-model.test.ts | 26 +- .../packages/client/tsconfig.json | 0 .../packages/frontend-errors/package.json | 2 +- .../packages/frontend-errors/src/client.ts | 10 +- .../packages/frontend-errors/src/index.ts | 0 .../packages/frontend-errors/src/router.ts | 4 +- .../packages/frontend-errors/src/script.ts | 10 +- .../packages/frontend-errors/src/types.ts | 2 +- .../packages/frontend-errors/src/vite.ts | 6 +- .../frontend-errors/test/router.test.ts | 4 +- .../packages/frontend-errors/tsconfig.json | 0 .../packages/frontend-errors/vitest.config.ts | 0 .../packages/frontend/index.html | 2 + .../packages/frontend/package.json | 8 +- .../packages/frontend/public/favicon.svg | 0 foundry/packages/frontend/src/app/router.tsx | 342 ++++ .../packages/frontend/src/app/theme.ts | 0 .../frontend/src/components/mock-layout.tsx | 579 +++---- .../components/mock-layout/diff-content.tsx | 0 .../mock-layout/history-minimap.tsx | 0 .../components/mock-layout/message-list.tsx | 0 .../components/mock-layout/model-picker.tsx | 0 .../mock-layout/prompt-composer.tsx | 0 .../components/mock-layout/right-sidebar.tsx | 24 +- .../src/components/mock-layout/sidebar.tsx | 46 +- .../src/components/mock-layout/tab-strip.tsx | 14 +- .../components/mock-layout/terminal-pane.tsx | 42 +- .../mock-layout/transcript-header.tsx | 16 +- .../src/components/mock-layout/ui.tsx | 10 +- .../components/mock-layout/view-model.test.ts | 2 +- .../src/components/mock-layout/view-model.ts | 6 +- .../src/components/mock-onboarding.tsx | 969 +++++++++++ .../src/components/workspace-dashboard.tsx | 191 +-- .../src/features/sessions/model.test.ts | 12 +- .../frontend/src/features/sessions/model.ts | 14 +- .../src/features/tasks}/model.test.ts | 32 +- .../frontend/src/features/tasks}/model.ts | 26 +- .../src/foundry-client-view-model.d.ts | 7 + .../packages/frontend/src/lib/backend.ts | 2 +- .../packages/frontend/src/lib/env.ts | 6 +- foundry/packages/frontend/src/lib/mock-app.ts | 68 + .../packages/frontend/src/lib/workbench.ts | 4 +- .../packages/frontend/src/main.tsx | 0 .../frontend/src/sandbox-agent-react.d.ts | 102 ++ .../packages/frontend/src/styles.css | 0 .../packages/frontend/tsconfig.json | 0 .../packages/frontend/vite.config.ts | 10 +- .../packages/frontend/vitest.config.ts | 0 .../packages/shared/package.json | 2 +- foundry/packages/shared/src/app-shell.ts | 98 ++ .../packages/shared/src/config.ts | 4 +- .../packages/shared/src/contracts.ts | 49 +- .../packages/shared/src/index.ts | 1 + .../packages/shared/src/workbench.ts | 49 +- .../packages/shared/src/workspace.ts | 0 .../packages/shared/test/workspace.test.ts | 2 +- .../packages/shared/tsconfig.json | 0 {factory => foundry}/railway.toml | 0 .../research/friction/general.mdx | 36 +- .../research/friction/rivet.mdx | 90 +- .../research/friction/sandbox-agent.mdx | 12 +- .../research/friction/sandboxes.mdx | 2 +- {factory => foundry}/research/roadmap.md | 0 .../research/specs/frontend.md | 12 +- .../specs/rivetkit-opentui-migration-plan.md | 68 +- .../scripts/build-test-image.sh | 0 {factory => foundry}/tsconfig.base.json | 10 +- justfile | 116 +- package.json | 2 +- pnpm-lock.yaml | 62 +- pnpm-workspace.yaml | 4 +- research/agents/amp.md | 2 +- 281 files changed, 9242 insertions(+), 4356 deletions(-) create mode 100644 .env.development.example create mode 100644 docs/deploy/foundry-self-hosting.mdx delete mode 100644 factory/compose.dev.yaml delete mode 100644 factory/packages/backend/src/actors/handoff/db/db.ts delete mode 100644 factory/packages/backend/src/actors/handoff/db/drizzle.config.ts delete mode 100644 factory/packages/backend/src/actors/handoff/db/drizzle/0001_rapid_eddie_brock.sql delete mode 100644 factory/packages/backend/src/actors/handoff/db/drizzle/0005_sandbox_actor_id.sql delete mode 100644 factory/packages/backend/src/actors/handoff/index.ts delete mode 100644 factory/packages/backend/src/actors/handoff/workflow/queue.ts delete mode 100644 factory/packages/backend/src/actors/history/db/db.ts delete mode 100644 factory/packages/backend/src/actors/project/db/db.ts delete mode 100644 factory/packages/backend/src/actors/sandbox-instance/db/db.ts delete mode 100644 factory/packages/backend/src/actors/workspace/db/db.ts delete mode 100644 factory/packages/backend/src/actors/workspace/db/drizzle/0002_tiny_silver_surfer.sql delete mode 100644 factory/packages/backend/src/actors/workspace/db/migrations.ts delete mode 100644 factory/packages/backend/src/actors/workspace/db/schema.ts delete mode 100644 factory/packages/backend/src/db/actor-sqlite.ts delete mode 100644 factory/packages/backend/src/index.ts delete mode 100644 factory/packages/backend/test/repo-normalize.test.ts delete mode 100644 factory/packages/backend/tmp-decode-actors.mjs delete mode 100644 factory/packages/backend/tmp-dump-wfkeys.mjs delete mode 100644 factory/packages/backend/tmp-inspect-deep.mjs delete mode 100644 factory/packages/backend/tmp-inspect-stuck.mjs delete mode 100644 factory/packages/backend/tmp-inspect-workflow.mjs delete mode 100644 factory/packages/client/src/mock/workbench-client.ts delete mode 100644 factory/packages/client/src/workbench-client.ts delete mode 100644 factory/packages/frontend/src/app/router.tsx rename {factory => foundry}/AGENTS.md (100%) rename {factory => foundry}/CLAUDE.md (84%) rename {factory => foundry}/CONTRIBUTING.md (83%) rename {factory => foundry}/Dockerfile (79%) rename {factory => foundry}/README.md (70%) create mode 100644 foundry/compose.dev.yaml rename {factory => foundry}/compose.preview.yaml (63%) rename {factory => foundry}/docker/backend.dev.Dockerfile (90%) rename {factory => foundry}/docker/backend.preview.Dockerfile (90%) rename {factory => foundry}/docker/frontend.dev.Dockerfile (72%) rename {factory => foundry}/docker/frontend.preview.Dockerfile (65%) rename {factory => foundry}/docker/nginx.preview.conf (100%) rename {factory => foundry}/e2e/wb-mmilw7yh.txt (100%) rename {factory => foundry}/e2e/wb-mmilzdwf.txt (100%) rename factory/factory-cloud.md => foundry/foundry-cloud.md (72%) rename {factory => foundry}/memory/roadmap.md (95%) rename {factory => foundry}/packages/backend/CLAUDE.md (69%) rename {factory => foundry}/packages/backend/package.json (86%) rename {factory => foundry}/packages/backend/src/actors/_scripts/generate-actor-migrations.ts (100%) rename {factory => foundry}/packages/backend/src/actors/context.ts (63%) rename {factory => foundry}/packages/backend/src/actors/events.ts (68%) rename {factory => foundry}/packages/backend/src/actors/handles.ts (73%) create mode 100644 foundry/packages/backend/src/actors/history/db/db.ts rename {factory => foundry}/packages/backend/src/actors/history/db/drizzle.config.ts (100%) rename {factory => foundry}/packages/backend/src/actors/history/db/drizzle/0000_watery_bushwacker.sql (90%) rename {factory => foundry}/packages/backend/src/actors/history/db/drizzle/meta/0000_snapshot.json (96%) rename {factory => foundry}/packages/backend/src/actors/history/db/drizzle/meta/_journal.json (100%) rename {factory => foundry}/packages/backend/src/actors/history/db/migrations.ts (96%) rename {factory => foundry}/packages/backend/src/actors/history/db/schema.ts (91%) rename {factory => foundry}/packages/backend/src/actors/history/index.ts (91%) rename {factory => foundry}/packages/backend/src/actors/index.ts (84%) rename {factory => foundry}/packages/backend/src/actors/keys.ts (68%) rename {factory => foundry}/packages/backend/src/actors/logging.ts (92%) rename {factory => foundry}/packages/backend/src/actors/polling.ts (100%) rename {factory => foundry}/packages/backend/src/actors/project-branch-sync/index.ts (100%) rename {factory => foundry}/packages/backend/src/actors/project-pr-sync/index.ts (100%) rename {factory => foundry}/packages/backend/src/actors/project/actions.ts (76%) create mode 100644 foundry/packages/backend/src/actors/project/db/db.ts rename {factory => foundry}/packages/backend/src/actors/project/db/drizzle.config.ts (100%) rename {factory => foundry}/packages/backend/src/actors/project/db/drizzle/0000_stormy_the_hunter.sql (100%) rename {factory => foundry}/packages/backend/src/actors/project/db/drizzle/0001_wild_carlie_cooper.sql (100%) rename {factory => foundry}/packages/backend/src/actors/project/db/drizzle/0002_far_war_machine.sql (54%) rename {factory => foundry}/packages/backend/src/actors/project/db/drizzle/0003_busy_legacy.sql (100%) rename {factory => foundry}/packages/backend/src/actors/project/db/drizzle/meta/0000_snapshot.json (100%) rename {factory => foundry}/packages/backend/src/actors/project/db/drizzle/meta/0001_snapshot.json (100%) rename {factory => foundry}/packages/backend/src/actors/project/db/drizzle/meta/0002_snapshot.json (98%) rename {factory => foundry}/packages/backend/src/actors/project/db/drizzle/meta/_journal.json (100%) rename {factory => foundry}/packages/backend/src/actors/project/db/migrations.ts (95%) rename {factory => foundry}/packages/backend/src/actors/project/db/schema.ts (92%) rename {factory => foundry}/packages/backend/src/actors/project/index.ts (95%) rename {factory => foundry}/packages/backend/src/actors/project/stack-model.ts (100%) create mode 100644 foundry/packages/backend/src/actors/sandbox-instance/db/db.ts rename {factory => foundry}/packages/backend/src/actors/sandbox-instance/db/drizzle.config.ts (100%) rename {factory => foundry}/packages/backend/src/actors/sandbox-instance/db/drizzle/0000_broad_tyrannus.sql (100%) rename {factory => foundry}/packages/backend/src/actors/sandbox-instance/db/drizzle/0001_sandbox_sessions.sql (100%) rename {factory => foundry}/packages/backend/src/actors/sandbox-instance/db/drizzle/meta/0000_snapshot.json (100%) rename {factory => foundry}/packages/backend/src/actors/sandbox-instance/db/drizzle/meta/_journal.json (100%) rename {factory => foundry}/packages/backend/src/actors/sandbox-instance/db/migrations.ts (100%) rename {factory => foundry}/packages/backend/src/actors/sandbox-instance/db/schema.ts (94%) rename {factory => foundry}/packages/backend/src/actors/sandbox-instance/index.ts (97%) rename {factory => foundry}/packages/backend/src/actors/sandbox-instance/persist.ts (100%) rename {factory/packages/backend/src/actors/handoff-status-sync => foundry/packages/backend/src/actors/task-status-sync}/index.ts (66%) create mode 100644 foundry/packages/backend/src/actors/task/db/db.ts create mode 100644 foundry/packages/backend/src/actors/task/db/drizzle.config.ts rename {factory/packages/backend/src/actors/handoff => foundry/packages/backend/src/actors/task}/db/drizzle/0000_condemned_maria_hill.sql (91%) create mode 100644 foundry/packages/backend/src/actors/task/db/drizzle/0001_rapid_eddie_brock.sql rename {factory/packages/backend/src/actors/handoff => foundry/packages/backend/src/actors/task}/db/drizzle/0002_lazy_moira_mactaggert.sql (50%) rename {factory/packages/backend/src/actors/handoff => foundry/packages/backend/src/actors/task}/db/drizzle/0003_plucky_bran.sql (77%) rename {factory/packages/backend/src/actors/handoff => foundry/packages/backend/src/actors/task}/db/drizzle/0004_focused_shuri.sql (85%) create mode 100644 foundry/packages/backend/src/actors/task/db/drizzle/0005_sandbox_actor_id.sql rename {factory/packages/backend/src/actors/handoff => foundry/packages/backend/src/actors/task}/db/drizzle/0006_workbench_sessions.sql (90%) rename {factory/packages/backend/src/actors/handoff => foundry/packages/backend/src/actors/task}/db/drizzle/meta/0000_snapshot.json (97%) rename {factory/packages/backend/src/actors/handoff => foundry/packages/backend/src/actors/task}/db/drizzle/meta/0001_snapshot.json (97%) rename {factory/packages/backend/src/actors/handoff => foundry/packages/backend/src/actors/task}/db/drizzle/meta/0002_snapshot.json (92%) rename {factory/packages/backend/src/actors/handoff => foundry/packages/backend/src/actors/task}/db/drizzle/meta/_journal.json (100%) rename {factory/packages/backend/src/actors/handoff => foundry/packages/backend/src/actors/task}/db/migrations.ts (75%) rename {factory/packages/backend/src/actors/handoff => foundry/packages/backend/src/actors/task}/db/schema.ts (83%) create mode 100644 foundry/packages/backend/src/actors/task/index.ts rename {factory/packages/backend/src/actors/handoff => foundry/packages/backend/src/actors/task}/workbench.ts (93%) rename {factory/packages/backend/src/actors/handoff => foundry/packages/backend/src/actors/task}/workflow/commands.ts (68%) rename {factory/packages/backend/src/actors/handoff => foundry/packages/backend/src/actors/task}/workflow/common.ts (66%) rename {factory/packages/backend/src/actors/handoff => foundry/packages/backend/src/actors/task}/workflow/index.ts (73%) rename {factory/packages/backend/src/actors/handoff => foundry/packages/backend/src/actors/task}/workflow/init.ts (75%) rename {factory/packages/backend/src/actors/handoff => foundry/packages/backend/src/actors/task}/workflow/push.ts (78%) create mode 100644 foundry/packages/backend/src/actors/task/workflow/queue.ts rename {factory/packages/backend/src/actors/handoff => foundry/packages/backend/src/actors/task}/workflow/status-sync.ts (65%) rename {factory => foundry}/packages/backend/src/actors/workspace/actions.ts (61%) create mode 100644 foundry/packages/backend/src/actors/workspace/app-shell.ts create mode 100644 foundry/packages/backend/src/actors/workspace/db/db.ts rename {factory => foundry}/packages/backend/src/actors/workspace/db/drizzle.config.ts (100%) rename {factory => foundry}/packages/backend/src/actors/workspace/db/drizzle/0000_rare_iron_man.sql (100%) rename {factory => foundry}/packages/backend/src/actors/workspace/db/drizzle/0001_sleepy_lady_deathstrike.sql (100%) create mode 100644 foundry/packages/backend/src/actors/workspace/db/drizzle/0002_tiny_silver_surfer.sql rename {factory => foundry}/packages/backend/src/actors/workspace/db/drizzle/meta/0000_snapshot.json (100%) rename {factory => foundry}/packages/backend/src/actors/workspace/db/drizzle/meta/0001_snapshot.json (100%) rename {factory => foundry}/packages/backend/src/actors/workspace/db/drizzle/meta/_journal.json (100%) create mode 100644 foundry/packages/backend/src/actors/workspace/db/migrations.ts create mode 100644 foundry/packages/backend/src/actors/workspace/db/schema.ts rename {factory => foundry}/packages/backend/src/actors/workspace/index.ts (100%) rename {factory => foundry}/packages/backend/src/config/backend.ts (80%) rename {factory => foundry}/packages/backend/src/config/workspace.ts (85%) rename {factory => foundry}/packages/backend/src/driver.ts (95%) create mode 100644 foundry/packages/backend/src/index.ts rename {factory => foundry}/packages/backend/src/integrations/daytona/client.ts (100%) rename {factory => foundry}/packages/backend/src/integrations/git-spice/index.ts (100%) rename {factory => foundry}/packages/backend/src/integrations/git/index.ts (99%) rename {factory => foundry}/packages/backend/src/integrations/github/index.ts (100%) rename {factory => foundry}/packages/backend/src/integrations/graphite/index.ts (100%) rename {factory => foundry}/packages/backend/src/integrations/sandbox-agent/client.ts (97%) rename {factory => foundry}/packages/backend/src/notifications/backends.ts (96%) rename {factory => foundry}/packages/backend/src/notifications/index.ts (92%) rename {factory => foundry}/packages/backend/src/notifications/state-tracker.ts (100%) rename {factory => foundry}/packages/backend/src/providers/daytona/index.ts (93%) rename {factory => foundry}/packages/backend/src/providers/index.ts (94%) rename {factory => foundry}/packages/backend/src/providers/local/index.ts (98%) rename {factory => foundry}/packages/backend/src/providers/provider-api/index.ts (96%) create mode 100644 foundry/packages/backend/src/services/app-github.ts create mode 100644 foundry/packages/backend/src/services/app-shell-runtime.ts create mode 100644 foundry/packages/backend/src/services/app-stripe.ts rename {factory => foundry}/packages/backend/src/services/create-flow.ts (89%) rename factory/packages/backend/src/services/openhandoff-paths.ts => foundry/packages/backend/src/services/foundry-paths.ts (55%) rename {factory => foundry}/packages/backend/src/services/queue.ts (100%) rename {factory => foundry}/packages/backend/src/services/repo-git-lock.ts (100%) rename {factory => foundry}/packages/backend/src/services/repo.ts (58%) rename {factory => foundry}/packages/backend/src/services/tmux.ts (100%) rename {factory => foundry}/packages/backend/test/create-flow.test.ts (95%) rename {factory => foundry}/packages/backend/test/daytona-provider.test.ts (95%) rename {factory => foundry}/packages/backend/test/git-spice.test.ts (100%) rename {factory => foundry}/packages/backend/test/git-validate-remote.test.ts (97%) rename {factory => foundry}/packages/backend/test/helpers/test-context.ts (80%) rename {factory => foundry}/packages/backend/test/helpers/test-driver.ts (94%) rename {factory => foundry}/packages/backend/test/keys.test.ts (80%) rename {factory => foundry}/packages/backend/test/malformed-uri.test.ts (100%) rename {factory => foundry}/packages/backend/test/providers.test.ts (91%) create mode 100644 foundry/packages/backend/test/repo-normalize.test.ts rename {factory => foundry}/packages/backend/test/sandbox-instance-persist.test.ts (100%) rename {factory => foundry}/packages/backend/test/setup.ts (100%) rename {factory => foundry}/packages/backend/test/stack-model.test.ts (100%) rename {factory => foundry}/packages/backend/test/workbench-unread.test.ts (96%) rename {factory => foundry}/packages/backend/test/workspace-isolation.test.ts (86%) rename {factory => foundry}/packages/backend/test/workspace-star-sandbox-agent-repo.test.ts (100%) rename {factory => foundry}/packages/backend/tsconfig.json (100%) rename {factory => foundry}/packages/backend/vitest.config.ts (100%) rename {factory => foundry}/packages/cli/package.json (75%) rename {factory => foundry}/packages/cli/src/backend/manager.ts (96%) rename {factory => foundry}/packages/cli/src/build-id.ts (100%) rename {factory => foundry}/packages/cli/src/index.ts (81%) rename {factory => foundry}/packages/cli/src/task-editor.ts (86%) rename {factory => foundry}/packages/cli/src/theme.ts (99%) rename {factory => foundry}/packages/cli/src/themes/opencode-pack.json (100%) rename {factory => foundry}/packages/cli/src/tmux.ts (100%) rename {factory => foundry}/packages/cli/src/tui.ts (90%) rename {factory => foundry}/packages/cli/src/workspace/config.ts (88%) rename {factory => foundry}/packages/cli/test/backend-manager.test.ts (97%) rename {factory => foundry}/packages/cli/test/task-editor.test.ts (100%) rename {factory => foundry}/packages/cli/test/theme.test.ts (93%) rename {factory => foundry}/packages/cli/test/tmux.test.ts (100%) rename {factory => foundry}/packages/cli/test/tui-format.test.ts (84%) rename {factory => foundry}/packages/cli/test/workspace-config.test.ts (87%) rename {factory => foundry}/packages/cli/tsconfig.json (100%) rename {factory => foundry}/packages/cli/tsup.config.ts (100%) rename {factory => foundry}/packages/client/package.json (88%) create mode 100644 foundry/packages/client/src/app-client.ts rename {factory => foundry}/packages/client/src/backend-client.ts (60%) rename {factory => foundry}/packages/client/src/index.ts (68%) rename {factory => foundry}/packages/client/src/keys.ts (68%) create mode 100644 foundry/packages/client/src/mock-app.ts rename {factory => foundry}/packages/client/src/mock/backend-client.ts (55%) create mode 100644 foundry/packages/client/src/mock/latency.ts create mode 100644 foundry/packages/client/src/mock/workbench-client.ts create mode 100644 foundry/packages/client/src/remote/app-client.ts rename {factory => foundry}/packages/client/src/remote/workbench-client.ts (65%) rename {factory => foundry}/packages/client/src/view-model.ts (69%) create mode 100644 foundry/packages/client/src/workbench-client.ts rename {factory => foundry}/packages/client/src/workbench-model.ts (94%) rename {factory => foundry}/packages/client/test/e2e/full-integration-e2e.test.ts (98%) rename {factory => foundry}/packages/client/test/e2e/github-pr-e2e.test.ts (79%) rename {factory => foundry}/packages/client/test/e2e/workbench-e2e.test.ts (70%) rename {factory => foundry}/packages/client/test/e2e/workbench-load-e2e.test.ts (76%) rename {factory => foundry}/packages/client/test/keys.test.ts (62%) rename {factory => foundry}/packages/client/test/view-model.test.ts (71%) rename {factory => foundry}/packages/client/tsconfig.json (100%) rename {factory => foundry}/packages/frontend-errors/package.json (93%) rename {factory => foundry}/packages/frontend-errors/src/client.ts (70%) rename {factory => foundry}/packages/frontend-errors/src/index.ts (100%) rename {factory => foundry}/packages/frontend-errors/src/router.ts (98%) rename {factory => foundry}/packages/frontend-errors/src/script.ts (95%) rename {factory => foundry}/packages/frontend-errors/src/types.ts (98%) rename {factory => foundry}/packages/frontend-errors/src/vite.ts (92%) rename {factory => foundry}/packages/frontend-errors/test/router.test.ts (93%) rename {factory => foundry}/packages/frontend-errors/tsconfig.json (100%) rename {factory => foundry}/packages/frontend-errors/vitest.config.ts (100%) rename {factory => foundry}/packages/frontend/index.html (97%) rename {factory => foundry}/packages/frontend/package.json (79%) rename {factory => foundry}/packages/frontend/public/favicon.svg (100%) create mode 100644 foundry/packages/frontend/src/app/router.tsx rename {factory => foundry}/packages/frontend/src/app/theme.ts (100%) rename {factory => foundry}/packages/frontend/src/components/mock-layout.tsx (66%) rename {factory => foundry}/packages/frontend/src/components/mock-layout/diff-content.tsx (100%) rename {factory => foundry}/packages/frontend/src/components/mock-layout/history-minimap.tsx (100%) rename {factory => foundry}/packages/frontend/src/components/mock-layout/message-list.tsx (100%) rename {factory => foundry}/packages/frontend/src/components/mock-layout/model-picker.tsx (100%) rename {factory => foundry}/packages/frontend/src/components/mock-layout/prompt-composer.tsx (100%) rename {factory => foundry}/packages/frontend/src/components/mock-layout/right-sidebar.tsx (94%) rename {factory => foundry}/packages/frontend/src/components/mock-layout/sidebar.tsx (87%) rename {factory => foundry}/packages/frontend/src/components/mock-layout/tab-strip.tsx (95%) rename {factory => foundry}/packages/frontend/src/components/mock-layout/terminal-pane.tsx (97%) rename {factory => foundry}/packages/frontend/src/components/mock-layout/transcript-header.tsx (94%) rename {factory => foundry}/packages/frontend/src/components/mock-layout/ui.tsx (98%) rename {factory => foundry}/packages/frontend/src/components/mock-layout/view-model.test.ts (98%) rename {factory => foundry}/packages/frontend/src/components/mock-layout/view-model.ts (99%) create mode 100644 foundry/packages/frontend/src/components/mock-onboarding.tsx rename {factory => foundry}/packages/frontend/src/components/workspace-dashboard.tsx (91%) rename {factory => foundry}/packages/frontend/src/features/sessions/model.test.ts (91%) rename {factory => foundry}/packages/frontend/src/features/sessions/model.ts (87%) rename {factory/packages/frontend/src/features/handoffs => foundry/packages/frontend/src/features/tasks}/model.test.ts (58%) rename {factory/packages/frontend/src/features/handoffs => foundry/packages/frontend/src/features/tasks}/model.ts (54%) create mode 100644 foundry/packages/frontend/src/foundry-client-view-model.d.ts rename {factory => foundry}/packages/frontend/src/lib/backend.ts (75%) rename {factory => foundry}/packages/frontend/src/lib/env.ts (75%) create mode 100644 foundry/packages/frontend/src/lib/mock-app.ts rename {factory => foundry}/packages/frontend/src/lib/workbench.ts (59%) rename {factory => foundry}/packages/frontend/src/main.tsx (100%) create mode 100644 foundry/packages/frontend/src/sandbox-agent-react.d.ts rename {factory => foundry}/packages/frontend/src/styles.css (100%) rename {factory => foundry}/packages/frontend/tsconfig.json (100%) rename {factory => foundry}/packages/frontend/vite.config.ts (58%) rename {factory => foundry}/packages/frontend/vitest.config.ts (100%) rename {factory => foundry}/packages/shared/package.json (88%) create mode 100644 foundry/packages/shared/src/app-shell.ts rename {factory => foundry}/packages/shared/src/config.ts (93%) rename {factory => foundry}/packages/shared/src/contracts.ts (86%) rename {factory => foundry}/packages/shared/src/index.ts (79%) rename {factory => foundry}/packages/shared/src/workbench.ts (71%) rename {factory => foundry}/packages/shared/src/workspace.ts (100%) rename {factory => foundry}/packages/shared/test/workspace.test.ts (94%) rename {factory => foundry}/packages/shared/tsconfig.json (100%) rename {factory => foundry}/railway.toml (100%) rename {factory => foundry}/research/friction/general.mdx (85%) rename {factory => foundry}/research/friction/rivet.mdx (84%) rename {factory => foundry}/research/friction/sandbox-agent.mdx (83%) rename {factory => foundry}/research/friction/sandboxes.mdx (96%) rename {factory => foundry}/research/roadmap.md (100%) rename {factory => foundry}/research/specs/frontend.md (80%) rename {factory => foundry}/research/specs/rivetkit-opentui-migration-plan.md (88%) rename {factory => foundry}/scripts/build-test-image.sh (100%) rename {factory => foundry}/tsconfig.base.json (53%) diff --git a/.dockerignore b/.dockerignore index cb03545..4ba2cf3 100644 --- a/.dockerignore +++ b/.dockerignore @@ -17,7 +17,7 @@ coverage/ # Environment .env .env.* -.openhandoff/ +.foundry/ # IDE .idea/ diff --git a/.env.development.example b/.env.development.example new file mode 100644 index 0000000..24bbef1 --- /dev/null +++ b/.env.development.example @@ -0,0 +1,27 @@ +# Load this file only when NODE_ENV=development. +# The backend does not load dotenv files in production. + +APP_URL=http://localhost:4173 +BETTER_AUTH_URL=http://localhost:4173 +BETTER_AUTH_SECRET=sandbox-agent-foundry-development-only-change-me +GITHUB_REDIRECT_URI=http://localhost:4173/api/rivet/app/auth/github/callback + +# Fill these in when enabling live GitHub OAuth. +GITHUB_CLIENT_ID= +GITHUB_CLIENT_SECRET= + +# Fill these in when enabling GitHub App-backed org installation and repo import. +GITHUB_APP_ID= +GITHUB_APP_CLIENT_ID= +GITHUB_APP_CLIENT_SECRET= +# Store PEM material as a quoted single-line value with \n escapes. +GITHUB_APP_PRIVATE_KEY= +# Webhook secret for verifying GitHub webhook payloads. +# Use smee.io for local development: https://smee.io/new +GITHUB_WEBHOOK_SECRET= + +# Fill these in when enabling live Stripe billing. +STRIPE_SECRET_KEY= +STRIPE_PUBLISHABLE_KEY= +STRIPE_WEBHOOK_SECRET= +STRIPE_PRICE_TEAM= diff --git a/.gitignore b/.gitignore index da6874a..278b261 100644 --- a/.gitignore +++ b/.gitignore @@ -51,7 +51,7 @@ Cargo.lock # Example temp files .tmp-upload/ *.db -.openhandoff/ +.foundry/ # CLI binaries (downloaded during npm publish) sdks/cli/platforms/*/bin/ diff --git a/docs/deploy/foundry-self-hosting.mdx b/docs/deploy/foundry-self-hosting.mdx new file mode 100644 index 0000000..cbf639a --- /dev/null +++ b/docs/deploy/foundry-self-hosting.mdx @@ -0,0 +1,153 @@ +--- +title: "Foundry Self-Hosting" +description: "Environment, credentials, and deployment setup for Sandbox Agent Foundry auth, GitHub, and billing." +--- + +This guide documents the deployment contract for the Foundry product surface: app auth, GitHub onboarding, repository import, and billing. + +It also covers the local-development bootstrap that uses `.env.development` only when `NODE_ENV=development`. + +## Local Development + +For backend local development, the Foundry backend now supports a development-only dotenv bootstrap: + +- It loads `.env.development.local` and `.env.development` +- It does this **only** when `NODE_ENV=development` +- It does **not** load dotenv files in production + +The example file lives at [`/.env.development.example`](https://github.com/rivet-dev/sandbox-agent/blob/main/.env.development.example). + +To use it locally: + +```bash +cp .env.development.example .env.development +``` + +Run the backend with: + +```bash +just foundry-backend-start +``` + +That recipe sets `NODE_ENV=development`, which enables the dotenv loader. + +### Local Defaults + +These values can be safely defaulted for local development: + +- `APP_URL=http://localhost:4173` +- `BETTER_AUTH_URL=http://localhost:4173` +- `BETTER_AUTH_SECRET=sandbox-agent-foundry-development-only-change-me` +- `GITHUB_REDIRECT_URI=http://localhost:4173/api/rivet/app/auth/github/callback` + +These should be treated as development-only values. + +## Production Environment + +For production or self-hosting, set these as real environment variables in your deployment platform. Do not rely on dotenv file loading. + +### App/Auth + +| Variable | Required | Notes | +|---|---:|---| +| `APP_URL` | Yes | Public frontend origin | +| `BETTER_AUTH_URL` | Yes | Public auth base URL | +| `BETTER_AUTH_SECRET` | Yes | Strong random secret for auth/session signing | + +### GitHub OAuth + +| Variable | Required | Notes | +|---|---:|---| +| `GITHUB_CLIENT_ID` | Yes | GitHub OAuth app client id | +| `GITHUB_CLIENT_SECRET` | Yes | GitHub OAuth app client secret | +| `GITHUB_REDIRECT_URI` | Yes | GitHub OAuth callback URL | + +Use GitHub OAuth for: + +- user sign-in +- user identity +- org selection +- access to the signed-in user’s GitHub context + +## GitHub App + +If your Foundry deployment uses GitHub App-backed organization install and repo import, also configure: + +| Variable | Required | Notes | +|---|---:|---| +| `GITHUB_APP_ID` | Yes | GitHub App id | +| `GITHUB_APP_CLIENT_ID` | Yes | GitHub App client id | +| `GITHUB_APP_CLIENT_SECRET` | Yes | GitHub App client secret | +| `GITHUB_APP_PRIVATE_KEY` | Yes | PEM private key for installation auth | + +For `.env.development` and `.env.development.local`, store `GITHUB_APP_PRIVATE_KEY` as a quoted single-line value with `\n` escapes instead of raw multi-line PEM text. + +Recommended GitHub App permissions: + +- Repository `Metadata: Read` +- Repository `Contents: Read & Write` +- Repository `Pull requests: Read & Write` +- Repository `Checks: Read` +- Repository `Commit statuses: Read` + +Set the webhook URL to `https:///api/rivet/app/webhooks/github` and generate a webhook secret. Store the secret as `GITHUB_WEBHOOK_SECRET`. + +Recommended webhook subscriptions: + +- `installation` +- `installation_repositories` +- `pull_request` +- `pull_request_review` +- `pull_request_review_comment` +- `push` +- `create` +- `delete` +- `check_suite` +- `check_run` +- `status` + +Use the GitHub App for: + +- installation/reconnect state +- org repo import +- repository sync +- PR creation and updates + +Use GitHub OAuth for: + +- who the user is +- which orgs they can choose + +## Stripe + +For live billing, configure: + +| Variable | Required | Notes | +|---|---:|---| +| `STRIPE_SECRET_KEY` | Yes | Server-side Stripe secret key | +| `STRIPE_PUBLISHABLE_KEY` | Yes | Client-side Stripe publishable key | +| `STRIPE_WEBHOOK_SECRET` | Yes | Signing secret for billing webhooks | +| `STRIPE_PRICE_TEAM` | Yes | Stripe price id for the Team plan checkout session | + +Stripe should own: + +- hosted checkout +- billing portal +- subscription status +- invoice history +- webhook-driven state sync + +## Mock Invariant + +Foundry’s mock client path should continue to work end to end even when the real auth/GitHub/Stripe path exists. + +That includes: + +- sign-in +- org selection/import +- settings +- billing UI +- workspace/task/session flow +- seat accrual + +Use mock mode for deterministic UI review and local product development. Use the real env-backed path for integration and self-hosting. diff --git a/factory/compose.dev.yaml b/factory/compose.dev.yaml deleted file mode 100644 index 80fea12..0000000 --- a/factory/compose.dev.yaml +++ /dev/null @@ -1,90 +0,0 @@ -name: openhandoff - -services: - backend: - build: - context: .. - dockerfile: factory/docker/backend.dev.Dockerfile - image: openhandoff-backend-dev - working_dir: /app - environment: - HF_BACKEND_HOST: "0.0.0.0" - HF_BACKEND_PORT: "7741" - HF_RIVET_MANAGER_PORT: "8750" - RIVETKIT_STORAGE_PATH: "/root/.local/share/openhandoff/rivetkit" - # Pass through credentials needed for agent execution + PR creation in dev/e2e. - # Do not hardcode secrets; set these in your environment when starting compose. - ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}" - CLAUDE_API_KEY: "${CLAUDE_API_KEY:-${ANTHROPIC_API_KEY:-}}" - OPENAI_API_KEY: "${OPENAI_API_KEY:-}" - # sandbox-agent codex plugin currently expects CODEX_API_KEY. Map from OPENAI_API_KEY for convenience. - CODEX_API_KEY: "${CODEX_API_KEY:-${OPENAI_API_KEY:-}}" - # Support either GITHUB_TOKEN or GITHUB_PAT in local env files. - GITHUB_TOKEN: "${GITHUB_TOKEN:-${GITHUB_PAT:-}}" - GH_TOKEN: "${GH_TOKEN:-${GITHUB_TOKEN:-${GITHUB_PAT:-}}}" - DAYTONA_ENDPOINT: "${DAYTONA_ENDPOINT:-}" - DAYTONA_API_KEY: "${DAYTONA_API_KEY:-}" - HF_DAYTONA_ENDPOINT: "${HF_DAYTONA_ENDPOINT:-}" - HF_DAYTONA_API_KEY: "${HF_DAYTONA_API_KEY:-}" - ports: - - "7741:7741" - # RivetKit manager (used by browser clients after /api/rivet metadata redirect in dev) - - "8750:8750" - volumes: - - "..:/app" - # The linked RivetKit checkout resolves from factory packages to /handoff/rivet-checkout in-container. - - "../../../handoff/rivet-checkout:/handoff/rivet-checkout:ro" - # Reuse the host Codex auth profile for local sandbox-agent Codex sessions in dev. - - "${HOME}/.codex:/root/.codex" - # Keep backend dependency installs Linux-native instead of using host node_modules. - - "openhandoff_backend_root_node_modules:/app/node_modules" - - "openhandoff_backend_backend_node_modules:/app/factory/packages/backend/node_modules" - - "openhandoff_backend_shared_node_modules:/app/factory/packages/shared/node_modules" - - "openhandoff_backend_persist_rivet_node_modules:/app/sdks/persist-rivet/node_modules" - - "openhandoff_backend_typescript_node_modules:/app/sdks/typescript/node_modules" - - "openhandoff_backend_pnpm_store:/root/.local/share/pnpm/store" - # Persist backend-managed local git clones across container restarts. - - "openhandoff_git_repos:/root/.local/share/openhandoff/repos" - # Persist RivetKit local storage across container restarts. - - "openhandoff_rivetkit_storage:/root/.local/share/openhandoff/rivetkit" - - frontend: - build: - context: .. - dockerfile: factory/docker/frontend.dev.Dockerfile - working_dir: /app - depends_on: - - backend - environment: - HOME: "/tmp" - HF_BACKEND_HTTP: "http://backend:7741" - ports: - - "4173:4173" - volumes: - - "..:/app" - # Ensure logs in .openhandoff/ persist on the host even if we change source mounts later. - - "./.openhandoff:/app/factory/.openhandoff" - - "../../../handoff/rivet-checkout:/handoff/rivet-checkout:ro" - # Use Linux-native workspace dependencies inside the container instead of host node_modules. - - "openhandoff_node_modules:/app/node_modules" - - "openhandoff_client_node_modules:/app/factory/packages/client/node_modules" - - "openhandoff_frontend_errors_node_modules:/app/factory/packages/frontend-errors/node_modules" - - "openhandoff_frontend_node_modules:/app/factory/packages/frontend/node_modules" - - "openhandoff_shared_node_modules:/app/factory/packages/shared/node_modules" - - "openhandoff_pnpm_store:/tmp/.local/share/pnpm/store" - -volumes: - openhandoff_backend_root_node_modules: {} - openhandoff_backend_backend_node_modules: {} - openhandoff_backend_shared_node_modules: {} - openhandoff_backend_persist_rivet_node_modules: {} - openhandoff_backend_typescript_node_modules: {} - openhandoff_backend_pnpm_store: {} - openhandoff_git_repos: {} - openhandoff_rivetkit_storage: {} - openhandoff_node_modules: {} - openhandoff_client_node_modules: {} - openhandoff_frontend_errors_node_modules: {} - openhandoff_frontend_node_modules: {} - openhandoff_shared_node_modules: {} - openhandoff_pnpm_store: {} diff --git a/factory/packages/backend/src/actors/handoff/db/db.ts b/factory/packages/backend/src/actors/handoff/db/db.ts deleted file mode 100644 index 979bcf9..0000000 --- a/factory/packages/backend/src/actors/handoff/db/db.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { actorSqliteDb } from "../../../db/actor-sqlite.js"; -import * as schema from "./schema.js"; -import migrations from "./migrations.js"; - -export const handoffDb = actorSqliteDb({ - actorName: "handoff", - schema, - migrations, - migrationsFolderUrl: new URL("./drizzle/", import.meta.url), -}); diff --git a/factory/packages/backend/src/actors/handoff/db/drizzle.config.ts b/factory/packages/backend/src/actors/handoff/db/drizzle.config.ts deleted file mode 100644 index d9e47c5..0000000 --- a/factory/packages/backend/src/actors/handoff/db/drizzle.config.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { defineConfig } from "rivetkit/db/drizzle"; - -export default defineConfig({ - out: "./src/actors/handoff/db/drizzle", - schema: "./src/actors/handoff/db/schema.ts", -}); diff --git a/factory/packages/backend/src/actors/handoff/db/drizzle/0001_rapid_eddie_brock.sql b/factory/packages/backend/src/actors/handoff/db/drizzle/0001_rapid_eddie_brock.sql deleted file mode 100644 index 4aac4cc..0000000 --- a/factory/packages/backend/src/actors/handoff/db/drizzle/0001_rapid_eddie_brock.sql +++ /dev/null @@ -1,3 +0,0 @@ -ALTER TABLE `handoff` DROP COLUMN `auto_committed`;--> statement-breakpoint -ALTER TABLE `handoff` DROP COLUMN `pushed`;--> statement-breakpoint -ALTER TABLE `handoff` DROP COLUMN `needs_push`; \ No newline at end of file diff --git a/factory/packages/backend/src/actors/handoff/db/drizzle/0005_sandbox_actor_id.sql b/factory/packages/backend/src/actors/handoff/db/drizzle/0005_sandbox_actor_id.sql deleted file mode 100644 index 8853b96..0000000 --- a/factory/packages/backend/src/actors/handoff/db/drizzle/0005_sandbox_actor_id.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE `handoff_sandboxes` ADD `sandbox_actor_id` text; diff --git a/factory/packages/backend/src/actors/handoff/index.ts b/factory/packages/backend/src/actors/handoff/index.ts deleted file mode 100644 index c800533..0000000 --- a/factory/packages/backend/src/actors/handoff/index.ts +++ /dev/null @@ -1,389 +0,0 @@ -import { actor, queue } from "rivetkit"; -import { workflow } from "rivetkit/workflow"; -import type { - AgentType, - HandoffRecord, - HandoffWorkbenchChangeModelInput, - HandoffWorkbenchRenameInput, - HandoffWorkbenchRenameSessionInput, - HandoffWorkbenchSetSessionUnreadInput, - HandoffWorkbenchSendMessageInput, - HandoffWorkbenchUpdateDraftInput, - ProviderId, -} from "@openhandoff/shared"; -import { expectQueueResponse } from "../../services/queue.js"; -import { selfHandoff } from "../handles.js"; -import { handoffDb } from "./db/db.js"; -import { getCurrentRecord } from "./workflow/common.js"; -import { - changeWorkbenchModel, - closeWorkbenchSession, - createWorkbenchSession, - getWorkbenchHandoff, - markWorkbenchUnread, - publishWorkbenchPr, - renameWorkbenchBranch, - renameWorkbenchHandoff, - renameWorkbenchSession, - revertWorkbenchFile, - sendWorkbenchMessage, - syncWorkbenchSessionStatus, - setWorkbenchSessionUnread, - stopWorkbenchSession, - updateWorkbenchDraft, -} from "./workbench.js"; -import { HANDOFF_QUEUE_NAMES, handoffWorkflowQueueName, runHandoffWorkflow } from "./workflow/index.js"; - -export interface HandoffInput { - workspaceId: string; - repoId: string; - handoffId: string; - repoRemote: string; - repoLocalPath: string; - branchName: string | null; - title: string | null; - task: string; - providerId: ProviderId; - agentType: AgentType | null; - explicitTitle: string | null; - explicitBranchName: string | null; - initialPrompt: string | null; -} - -interface InitializeCommand { - providerId?: ProviderId; -} - -interface HandoffActionCommand { - reason?: string; -} - -interface HandoffTabCommand { - tabId: string; -} - -interface HandoffStatusSyncCommand { - sessionId: string; - status: "running" | "idle" | "error"; - at: number; -} - -interface HandoffWorkbenchValueCommand { - value: string; -} - -interface HandoffWorkbenchSessionTitleCommand { - sessionId: string; - title: string; -} - -interface HandoffWorkbenchSessionUnreadCommand { - sessionId: string; - unread: boolean; -} - -interface HandoffWorkbenchUpdateDraftCommand { - sessionId: string; - text: string; - attachments: Array; -} - -interface HandoffWorkbenchChangeModelCommand { - sessionId: string; - model: string; -} - -interface HandoffWorkbenchSendMessageCommand { - sessionId: string; - text: string; - attachments: Array; -} - -interface HandoffWorkbenchCreateSessionCommand { - model?: string; -} - -interface HandoffWorkbenchSessionCommand { - sessionId: string; -} - -export const handoff = actor({ - db: handoffDb, - queues: Object.fromEntries(HANDOFF_QUEUE_NAMES.map((name) => [name, queue()])), - options: { - actionTimeout: 5 * 60_000, - }, - createState: (_c, input: HandoffInput) => ({ - workspaceId: input.workspaceId, - repoId: input.repoId, - handoffId: input.handoffId, - repoRemote: input.repoRemote, - repoLocalPath: input.repoLocalPath, - branchName: input.branchName, - title: input.title, - task: input.task, - providerId: input.providerId, - 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 { - const self = selfHandoff(c); - const result = await self.send(handoffWorkflowQueueName("handoff.command.initialize"), cmd ?? {}, { - wait: true, - timeout: 60_000, - }); - return expectQueueResponse(result); - }, - - async provision(c, cmd: InitializeCommand): Promise<{ ok: true }> { - const self = selfHandoff(c); - await self.send(handoffWorkflowQueueName("handoff.command.provision"), cmd ?? {}, { - wait: true, - timeout: 30 * 60_000, - }); - return { ok: true }; - }, - - async attach(c, cmd?: HandoffActionCommand): Promise<{ target: string; sessionId: string | null }> { - const self = selfHandoff(c); - const result = await self.send(handoffWorkflowQueueName("handoff.command.attach"), cmd ?? {}, { - wait: true, - timeout: 20_000, - }); - return expectQueueResponse<{ target: string; sessionId: string | null }>(result); - }, - - async switch(c): Promise<{ switchTarget: string }> { - const self = selfHandoff(c); - const result = await self.send( - handoffWorkflowQueueName("handoff.command.switch"), - {}, - { - wait: true, - timeout: 20_000, - }, - ); - return expectQueueResponse<{ switchTarget: string }>(result); - }, - - async push(c, cmd?: HandoffActionCommand): Promise { - const self = selfHandoff(c); - await self.send(handoffWorkflowQueueName("handoff.command.push"), cmd ?? {}, { - wait: true, - timeout: 180_000, - }); - }, - - async sync(c, cmd?: HandoffActionCommand): Promise { - const self = selfHandoff(c); - await self.send(handoffWorkflowQueueName("handoff.command.sync"), cmd ?? {}, { - wait: true, - timeout: 30_000, - }); - }, - - async merge(c, cmd?: HandoffActionCommand): Promise { - const self = selfHandoff(c); - await self.send(handoffWorkflowQueueName("handoff.command.merge"), cmd ?? {}, { - wait: true, - timeout: 30_000, - }); - }, - - async archive(c, cmd?: HandoffActionCommand): Promise { - const self = selfHandoff(c); - void self - .send(handoffWorkflowQueueName("handoff.command.archive"), cmd ?? {}, { - wait: true, - timeout: 60_000, - }) - .catch((error: unknown) => { - c.log.warn({ - msg: "archive command failed", - error: error instanceof Error ? error.message : String(error), - }); - }); - }, - - async kill(c, cmd?: HandoffActionCommand): Promise { - const self = selfHandoff(c); - await self.send(handoffWorkflowQueueName("handoff.command.kill"), cmd ?? {}, { - wait: true, - timeout: 60_000, - }); - }, - - async get(c): Promise { - return await getCurrentRecord({ db: c.db, state: c.state }); - }, - - async getWorkbench(c) { - return await getWorkbenchHandoff(c); - }, - - async markWorkbenchUnread(c): Promise { - const self = selfHandoff(c); - await self.send( - handoffWorkflowQueueName("handoff.command.workbench.mark_unread"), - {}, - { - wait: true, - timeout: 20_000, - }, - ); - }, - - async renameWorkbenchHandoff(c, input: HandoffWorkbenchRenameInput): Promise { - const self = selfHandoff(c); - await self.send(handoffWorkflowQueueName("handoff.command.workbench.rename_handoff"), { value: input.value } satisfies HandoffWorkbenchValueCommand, { - wait: true, - timeout: 20_000, - }); - }, - - async renameWorkbenchBranch(c, input: HandoffWorkbenchRenameInput): Promise { - const self = selfHandoff(c); - await self.send(handoffWorkflowQueueName("handoff.command.workbench.rename_branch"), { value: input.value } satisfies HandoffWorkbenchValueCommand, { - wait: true, - timeout: 5 * 60_000, - }); - }, - - async createWorkbenchSession(c, input?: { model?: string }): Promise<{ tabId: string }> { - const self = selfHandoff(c); - const result = await self.send( - handoffWorkflowQueueName("handoff.command.workbench.create_session"), - { ...(input?.model ? { model: input.model } : {}) } satisfies HandoffWorkbenchCreateSessionCommand, - { - wait: true, - timeout: 5 * 60_000, - }, - ); - return expectQueueResponse<{ tabId: string }>(result); - }, - - async renameWorkbenchSession(c, input: HandoffWorkbenchRenameSessionInput): Promise { - const self = selfHandoff(c); - await self.send( - handoffWorkflowQueueName("handoff.command.workbench.rename_session"), - { sessionId: input.tabId, title: input.title } satisfies HandoffWorkbenchSessionTitleCommand, - { - wait: true, - timeout: 20_000, - }, - ); - }, - - async setWorkbenchSessionUnread(c, input: HandoffWorkbenchSetSessionUnreadInput): Promise { - const self = selfHandoff(c); - await self.send( - handoffWorkflowQueueName("handoff.command.workbench.set_session_unread"), - { sessionId: input.tabId, unread: input.unread } satisfies HandoffWorkbenchSessionUnreadCommand, - { - wait: true, - timeout: 20_000, - }, - ); - }, - - async updateWorkbenchDraft(c, input: HandoffWorkbenchUpdateDraftInput): Promise { - const self = selfHandoff(c); - await self.send( - handoffWorkflowQueueName("handoff.command.workbench.update_draft"), - { - sessionId: input.tabId, - text: input.text, - attachments: input.attachments, - } satisfies HandoffWorkbenchUpdateDraftCommand, - { - wait: true, - timeout: 20_000, - }, - ); - }, - - async changeWorkbenchModel(c, input: HandoffWorkbenchChangeModelInput): Promise { - const self = selfHandoff(c); - await self.send( - handoffWorkflowQueueName("handoff.command.workbench.change_model"), - { sessionId: input.tabId, model: input.model } satisfies HandoffWorkbenchChangeModelCommand, - { - wait: true, - timeout: 20_000, - }, - ); - }, - - async sendWorkbenchMessage(c, input: HandoffWorkbenchSendMessageInput): Promise { - const self = selfHandoff(c); - await self.send( - handoffWorkflowQueueName("handoff.command.workbench.send_message"), - { - sessionId: input.tabId, - text: input.text, - attachments: input.attachments, - } satisfies HandoffWorkbenchSendMessageCommand, - { - wait: true, - timeout: 10 * 60_000, - }, - ); - }, - - async stopWorkbenchSession(c, input: HandoffTabCommand): Promise { - const self = selfHandoff(c); - await self.send(handoffWorkflowQueueName("handoff.command.workbench.stop_session"), { sessionId: input.tabId } satisfies HandoffWorkbenchSessionCommand, { - wait: true, - timeout: 5 * 60_000, - }); - }, - - async syncWorkbenchSessionStatus(c, input: HandoffStatusSyncCommand): Promise { - const self = selfHandoff(c); - await self.send(handoffWorkflowQueueName("handoff.command.workbench.sync_session_status"), input, { - wait: true, - timeout: 20_000, - }); - }, - - async closeWorkbenchSession(c, input: HandoffTabCommand): Promise { - const self = selfHandoff(c); - await self.send( - handoffWorkflowQueueName("handoff.command.workbench.close_session"), - { sessionId: input.tabId } satisfies HandoffWorkbenchSessionCommand, - { - wait: true, - timeout: 5 * 60_000, - }, - ); - }, - - async publishWorkbenchPr(c): Promise { - const self = selfHandoff(c); - await self.send( - handoffWorkflowQueueName("handoff.command.workbench.publish_pr"), - {}, - { - wait: true, - timeout: 10 * 60_000, - }, - ); - }, - - async revertWorkbenchFile(c, input: { path: string }): Promise { - const self = selfHandoff(c); - await self.send(handoffWorkflowQueueName("handoff.command.workbench.revert_file"), input, { - wait: true, - timeout: 5 * 60_000, - }); - }, - }, - run: workflow(runHandoffWorkflow), -}); - -export { HANDOFF_QUEUE_NAMES }; diff --git a/factory/packages/backend/src/actors/handoff/workflow/queue.ts b/factory/packages/backend/src/actors/handoff/workflow/queue.ts deleted file mode 100644 index 511a64d..0000000 --- a/factory/packages/backend/src/actors/handoff/workflow/queue.ts +++ /dev/null @@ -1,31 +0,0 @@ -export const HANDOFF_QUEUE_NAMES = [ - "handoff.command.initialize", - "handoff.command.provision", - "handoff.command.attach", - "handoff.command.switch", - "handoff.command.push", - "handoff.command.sync", - "handoff.command.merge", - "handoff.command.archive", - "handoff.command.kill", - "handoff.command.get", - "handoff.command.workbench.mark_unread", - "handoff.command.workbench.rename_handoff", - "handoff.command.workbench.rename_branch", - "handoff.command.workbench.create_session", - "handoff.command.workbench.rename_session", - "handoff.command.workbench.set_session_unread", - "handoff.command.workbench.update_draft", - "handoff.command.workbench.change_model", - "handoff.command.workbench.send_message", - "handoff.command.workbench.stop_session", - "handoff.command.workbench.sync_session_status", - "handoff.command.workbench.close_session", - "handoff.command.workbench.publish_pr", - "handoff.command.workbench.revert_file", - "handoff.status_sync.result", -] as const; - -export function handoffWorkflowQueueName(name: string): string { - return name; -} diff --git a/factory/packages/backend/src/actors/history/db/db.ts b/factory/packages/backend/src/actors/history/db/db.ts deleted file mode 100644 index 8889b35..0000000 --- a/factory/packages/backend/src/actors/history/db/db.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { actorSqliteDb } from "../../../db/actor-sqlite.js"; -import * as schema from "./schema.js"; -import migrations from "./migrations.js"; - -export const historyDb = actorSqliteDb({ - actorName: "history", - schema, - migrations, - migrationsFolderUrl: new URL("./drizzle/", import.meta.url), -}); diff --git a/factory/packages/backend/src/actors/project/db/db.ts b/factory/packages/backend/src/actors/project/db/db.ts deleted file mode 100644 index 20e8b22..0000000 --- a/factory/packages/backend/src/actors/project/db/db.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { actorSqliteDb } from "../../../db/actor-sqlite.js"; -import * as schema from "./schema.js"; -import migrations from "./migrations.js"; - -export const projectDb = actorSqliteDb({ - actorName: "project", - schema, - migrations, - migrationsFolderUrl: new URL("./drizzle/", import.meta.url), -}); diff --git a/factory/packages/backend/src/actors/sandbox-instance/db/db.ts b/factory/packages/backend/src/actors/sandbox-instance/db/db.ts deleted file mode 100644 index b42ab0d..0000000 --- a/factory/packages/backend/src/actors/sandbox-instance/db/db.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { actorSqliteDb } from "../../../db/actor-sqlite.js"; -import * as schema from "./schema.js"; -import migrations from "./migrations.js"; - -export const sandboxInstanceDb = actorSqliteDb({ - actorName: "sandbox-instance", - schema, - migrations, - migrationsFolderUrl: new URL("./drizzle/", import.meta.url), -}); diff --git a/factory/packages/backend/src/actors/workspace/db/db.ts b/factory/packages/backend/src/actors/workspace/db/db.ts deleted file mode 100644 index a573a83..0000000 --- a/factory/packages/backend/src/actors/workspace/db/db.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { actorSqliteDb } from "../../../db/actor-sqlite.js"; -import * as schema from "./schema.js"; -import migrations from "./migrations.js"; - -export const workspaceDb = actorSqliteDb({ - actorName: "workspace", - schema, - migrations, - migrationsFolderUrl: new URL("./drizzle/", import.meta.url), -}); diff --git a/factory/packages/backend/src/actors/workspace/db/drizzle/0002_tiny_silver_surfer.sql b/factory/packages/backend/src/actors/workspace/db/drizzle/0002_tiny_silver_surfer.sql deleted file mode 100644 index 9e7428d..0000000 --- a/factory/packages/backend/src/actors/workspace/db/drizzle/0002_tiny_silver_surfer.sql +++ /dev/null @@ -1,4 +0,0 @@ -CREATE TABLE `handoff_lookup` ( - `handoff_id` text PRIMARY KEY NOT NULL, - `repo_id` text NOT NULL -); diff --git a/factory/packages/backend/src/actors/workspace/db/migrations.ts b/factory/packages/backend/src/actors/workspace/db/migrations.ts deleted file mode 100644 index ff9187c..0000000 --- a/factory/packages/backend/src/actors/workspace/db/migrations.ts +++ /dev/null @@ -1,50 +0,0 @@ -// This file is generated by src/actors/_scripts/generate-actor-migrations.ts. -// Source of truth is drizzle-kit output under ./drizzle (meta/_journal.json + *.sql). -// Do not hand-edit this file. - -const journal = { - entries: [ - { - idx: 0, - when: 1770924376525, - tag: "0000_rare_iron_man", - breakpoints: true, - }, - { - idx: 1, - when: 1770947252912, - tag: "0001_sleepy_lady_deathstrike", - breakpoints: true, - }, - { - idx: 2, - when: 1772668800000, - tag: "0002_tiny_silver_surfer", - breakpoints: true, - }, - ], -} as const; - -export default { - journal, - migrations: { - m0000: `CREATE TABLE \`provider_profiles\` ( - \`provider_id\` text PRIMARY KEY NOT NULL, - \`profile_json\` text NOT NULL, - \`updated_at\` integer NOT NULL -); -`, - m0001: `CREATE TABLE \`repos\` ( - \`repo_id\` text PRIMARY KEY NOT NULL, - \`remote_url\` text NOT NULL, - \`created_at\` integer NOT NULL, - \`updated_at\` integer NOT NULL -); -`, - m0002: `CREATE TABLE \`handoff_lookup\` ( - \`handoff_id\` text PRIMARY KEY NOT NULL, - \`repo_id\` text NOT NULL -); -`, - } as const, -}; diff --git a/factory/packages/backend/src/actors/workspace/db/schema.ts b/factory/packages/backend/src/actors/workspace/db/schema.ts deleted file mode 100644 index bd35fb2..0000000 --- a/factory/packages/backend/src/actors/workspace/db/schema.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { integer, sqliteTable, text } from "rivetkit/db/drizzle"; - -// SQLite is per workspace actor instance, so no workspaceId column needed. -export const providerProfiles = sqliteTable("provider_profiles", { - providerId: text("provider_id").notNull().primaryKey(), - profileJson: text("profile_json").notNull(), - updatedAt: integer("updated_at").notNull(), -}); - -export const repos = sqliteTable("repos", { - repoId: text("repo_id").notNull().primaryKey(), - remoteUrl: text("remote_url").notNull(), - createdAt: integer("created_at").notNull(), - updatedAt: integer("updated_at").notNull(), -}); - -export const handoffLookup = sqliteTable("handoff_lookup", { - handoffId: text("handoff_id").notNull().primaryKey(), - repoId: text("repo_id").notNull(), -}); diff --git a/factory/packages/backend/src/db/actor-sqlite.ts b/factory/packages/backend/src/db/actor-sqlite.ts deleted file mode 100644 index ac499f6..0000000 --- a/factory/packages/backend/src/db/actor-sqlite.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { mkdirSync } from "node:fs"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; - -import { db as kvDrizzleDb } from "rivetkit/db/drizzle"; - -// Keep this file decoupled from RivetKit's internal type export paths. -// RivetKit consumes database providers structurally. -export interface RawAccess { - execute: (query: string, ...args: unknown[]) => Promise; - close: () => Promise; -} - -export interface DatabaseProviderContext { - actorId: string; -} - -export type DatabaseProvider = { - createClient: (ctx: DatabaseProviderContext) => Promise; - onMigrate: (client: DB) => void | Promise; - onDestroy?: (client: DB) => void | Promise; -}; - -export interface ActorSqliteDbOptions> { - actorName: string; - schema?: TSchema; - migrations?: unknown; - migrationsFolderUrl: URL; - /** - * Override base directory for per-actor SQLite files. - * - * Default: `/.openhandoff/backend/sqlite` - */ - baseDir?: string; -} - -export function actorSqliteDb>(options: ActorSqliteDbOptions): DatabaseProvider { - const isBunRuntime = typeof (globalThis as any).Bun !== "undefined" && typeof (process as any)?.versions?.bun === "string"; - - // Backend tests run in a Node-ish Vitest environment where `bun:sqlite` and - // Bun's sqlite-backed Drizzle driver are not supported. - // - // Additionally, RivetKit's KV-backed SQLite implementation currently has stability - // issues under Bun in this repo's setup (wa-sqlite runtime errors). Prefer Bun's - // native SQLite driver in production backend execution. - if (!isBunRuntime || process.env.VITEST || process.env.NODE_ENV === "test") { - return kvDrizzleDb({ - schema: options.schema, - migrations: options.migrations, - }) as unknown as DatabaseProvider; - } - - const baseDir = options.baseDir ?? join(process.cwd(), ".openhandoff", "backend", "sqlite"); - const migrationsFolder = fileURLToPath(options.migrationsFolderUrl); - - return { - createClient: async (ctx) => { - // Keep Bun-only module out of Vitest/Vite's static import graph. - const { Database } = await import(/* @vite-ignore */ "bun:sqlite"); - const { drizzle } = await import("drizzle-orm/bun-sqlite"); - - const dir = join(baseDir, options.actorName); - mkdirSync(dir, { recursive: true }); - - const dbPath = join(dir, `${ctx.actorId}.sqlite`); - const sqlite = new Database(dbPath); - sqlite.exec("PRAGMA journal_mode = WAL;"); - sqlite.exec("PRAGMA foreign_keys = ON;"); - - const client = drizzle({ - client: sqlite, - schema: options.schema, - }); - - return Object.assign(client, { - execute: async (query: string, ...args: unknown[]) => { - const stmt = sqlite.query(query); - try { - return stmt.all(args as never) as unknown[]; - } catch { - stmt.run(args as never); - return []; - } - }, - close: async () => { - sqlite.close(); - }, - } satisfies RawAccess); - }, - - onMigrate: async (client) => { - const { migrate } = await import("drizzle-orm/bun-sqlite/migrator"); - await migrate(client, { - migrationsFolder, - }); - }, - - onDestroy: async (client) => { - await client.close(); - }, - }; -} diff --git a/factory/packages/backend/src/index.ts b/factory/packages/backend/src/index.ts deleted file mode 100644 index 5c019c5..0000000 --- a/factory/packages/backend/src/index.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { Hono } from "hono"; -import { cors } from "hono/cors"; -import { initActorRuntimeContext } from "./actors/context.js"; -import { registry } from "./actors/index.js"; -import { loadConfig } from "./config/backend.js"; -import { createBackends, createNotificationService } from "./notifications/index.js"; -import { createDefaultDriver } from "./driver.js"; -import { createProviderRegistry } from "./providers/index.js"; - -export interface BackendStartOptions { - host?: string; - port?: number; -} - -export async function startBackend(options: BackendStartOptions = {}): Promise { - // sandbox-agent agent plugins vary on which env var they read for OpenAI/Codex auth. - // Normalize to keep local dev + docker-compose simple. - if (!process.env.CODEX_API_KEY && process.env.OPENAI_API_KEY) { - process.env.CODEX_API_KEY = process.env.OPENAI_API_KEY; - } - - const config = loadConfig(); - config.backend.host = options.host ?? config.backend.host; - config.backend.port = options.port ?? config.backend.port; - - // Allow docker-compose/dev environments to supply provider config via env vars - // instead of writing into the container's config.toml. - const envFirst = (...keys: string[]): string | undefined => { - for (const key of keys) { - const raw = process.env[key]; - if (raw && raw.trim().length > 0) return raw.trim(); - } - return undefined; - }; - - config.providers.daytona.endpoint = envFirst("HF_DAYTONA_ENDPOINT", "DAYTONA_ENDPOINT") ?? config.providers.daytona.endpoint; - config.providers.daytona.apiKey = envFirst("HF_DAYTONA_API_KEY", "DAYTONA_API_KEY") ?? config.providers.daytona.apiKey; - - const driver = createDefaultDriver(); - const providers = createProviderRegistry(config, driver); - const backends = await createBackends(config.notify); - const notifications = createNotificationService(backends); - initActorRuntimeContext(config, providers, notifications, driver); - - const inner = registry.serve(); - - // Wrap in a Hono app mounted at /api/rivet to serve on the backend port. - // Uses Bun.serve — cannot use @hono/node-server because it conflicts with - // RivetKit's internal Bun.serve manager server (Bun bug: mixing Node HTTP - // server and Bun.serve in the same process breaks Bun.serve's fetch handler). - const app = new Hono(); - app.use( - "/api/rivet/*", - cors({ - origin: "*", - allowHeaders: ["Content-Type", "Authorization", "x-rivet-token"], - allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], - exposeHeaders: ["Content-Type"], - }), - ); - app.use( - "/api/rivet", - cors({ - origin: "*", - allowHeaders: ["Content-Type", "Authorization", "x-rivet-token"], - allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], - exposeHeaders: ["Content-Type"], - }), - ); - const forward = async (c: any) => { - try { - // RivetKit serverless handler is configured with basePath `/api/rivet` by default. - return await inner.fetch(c.req.raw); - } catch (err) { - if (err instanceof URIError) { - return c.text("Bad Request: Malformed URI", 400); - } - throw err; - } - }; - app.all("/api/rivet", forward); - app.all("/api/rivet/*", forward); - - const server = Bun.serve({ - fetch: app.fetch, - hostname: config.backend.host, - port: config.backend.port, - }); - - process.on("SIGINT", async () => { - server.stop(); - process.exit(0); - }); - - process.on("SIGTERM", async () => { - server.stop(); - process.exit(0); - }); - - // Keep process alive. - await new Promise(() => undefined); -} - -function parseArg(flag: string): string | undefined { - const idx = process.argv.indexOf(flag); - if (idx < 0) return undefined; - return process.argv[idx + 1]; -} - -function parseEnvPort(value: string | undefined): number | undefined { - if (!value) { - return undefined; - } - const port = Number(value); - if (!Number.isInteger(port) || port <= 0 || port > 65535) { - return undefined; - } - return port; -} - -async function main(): Promise { - const cmd = process.argv[2] ?? "start"; - if (cmd !== "start") { - throw new Error(`Unsupported backend command: ${cmd}`); - } - - const host = parseArg("--host") ?? process.env.HOST ?? process.env.HF_BACKEND_HOST; - const port = parseArg("--port") ?? process.env.PORT ?? process.env.HF_BACKEND_PORT; - await startBackend({ - host, - port: parseEnvPort(port), - }); -} - -if (import.meta.url === `file://${process.argv[1]}`) { - main().catch((err: unknown) => { - const message = err instanceof Error ? (err.stack ?? err.message) : String(err); - console.error(message); - process.exit(1); - }); -} diff --git a/factory/packages/backend/test/repo-normalize.test.ts b/factory/packages/backend/test/repo-normalize.test.ts deleted file mode 100644 index 22b8793..0000000 --- a/factory/packages/backend/test/repo-normalize.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { normalizeRemoteUrl, repoIdFromRemote } from "../src/services/repo.js"; - -describe("normalizeRemoteUrl", () => { - test("accepts GitHub shorthand owner/repo", () => { - expect(normalizeRemoteUrl("rivet-dev/openhandoff")).toBe("https://github.com/rivet-dev/openhandoff.git"); - }); - - test("accepts github.com/owner/repo without scheme", () => { - expect(normalizeRemoteUrl("github.com/rivet-dev/openhandoff")).toBe("https://github.com/rivet-dev/openhandoff.git"); - }); - - test("canonicalizes GitHub repo URLs without .git", () => { - expect(normalizeRemoteUrl("https://github.com/rivet-dev/openhandoff")).toBe("https://github.com/rivet-dev/openhandoff.git"); - }); - - test("canonicalizes GitHub non-clone URLs (e.g. /tree/main)", () => { - expect(normalizeRemoteUrl("https://github.com/rivet-dev/openhandoff/tree/main")).toBe("https://github.com/rivet-dev/openhandoff.git"); - }); - - test("does not rewrite scp-style ssh remotes", () => { - expect(normalizeRemoteUrl("git@github.com:rivet-dev/openhandoff.git")).toBe("git@github.com:rivet-dev/openhandoff.git"); - }); -}); - -describe("repoIdFromRemote", () => { - test("repoId is stable across equivalent GitHub inputs", () => { - const a = repoIdFromRemote("rivet-dev/openhandoff"); - const b = repoIdFromRemote("https://github.com/rivet-dev/openhandoff.git"); - const c = repoIdFromRemote("https://github.com/rivet-dev/openhandoff/tree/main"); - expect(a).toBe(b); - expect(b).toBe(c); - }); -}); diff --git a/factory/packages/backend/tmp-decode-actors.mjs b/factory/packages/backend/tmp-decode-actors.mjs deleted file mode 100644 index 0a97c65..0000000 --- a/factory/packages/backend/tmp-decode-actors.mjs +++ /dev/null @@ -1,78 +0,0 @@ -import { Database } from "bun:sqlite"; -import { TO_CLIENT_VERSIONED, decodeWorkflowHistoryTransport } from "rivetkit/inspector"; - -const targets = [ - { actorId: "2e443238457137bf", handoffId: "7df7656e-bbd2-4b8c-bf0f-30d4df2f619a" }, - { actorId: "0e53dd77ef06862f", handoffId: "0e01a31c-2dc1-4a1d-8ab0-9f0816359a85" }, - { actorId: "ea8c0e764c836e5f", handoffId: "cdc22436-4020-4f73-b3e7-7782fec29ae4" }, -]; - -function decodeAscii(u8) { - return new TextDecoder().decode(u8).replace(/[\x00-\x1F\x7F-\xFF]/g, "."); -} - -function locationToNames(entry, names) { - return entry.location.map((seg) => { - if (seg.tag === "WorkflowNameIndex") return names[seg.val] ?? `#${seg.val}`; - if (seg.tag === "WorkflowLoopIterationMarker") return `iter(${seg.val.iteration})`; - return seg.tag; - }); -} - -for (const t of targets) { - const db = new Database(`/root/.local/share/openhandoff/rivetkit/databases/${t.actorId}.db`, { readonly: true }); - const token = new TextDecoder().decode(db.query("SELECT value FROM kv WHERE hex(key)=?").get("03").value); - - await new Promise((resolve, reject) => { - const ws = new WebSocket(`ws://127.0.0.1:7750/gateway/${t.actorId}/inspector/connect`, [`rivet_inspector_token.${token}`]); - ws.binaryType = "arraybuffer"; - const to = setTimeout(() => reject(new Error("timeout")), 15000); - - ws.onmessage = (ev) => { - const data = ev.data instanceof ArrayBuffer ? new Uint8Array(ev.data) : new Uint8Array(ev.data.buffer); - const msg = TO_CLIENT_VERSIONED.deserializeWithEmbeddedVersion(data); - if (msg.body.tag !== "Init") return; - - const wh = decodeWorkflowHistoryTransport(msg.body.val.workflowHistory); - const entryMetadata = wh.entryMetadata; - const enriched = wh.entries.map((e) => { - const meta = entryMetadata.get(e.id); - return { - id: e.id, - path: locationToNames(e, wh.nameRegistry).join("/"), - kind: e.kind.tag, - status: meta?.status ?? null, - error: meta?.error ?? null, - attempts: meta?.attempts ?? null, - entryError: e.kind.tag === "WorkflowStepEntry" ? (e.kind.val.error ?? null) : null, - }; - }); - - const wfStateRow = db.query("SELECT value FROM kv WHERE hex(key)=?").get("0715041501"); - const wfState = wfStateRow?.value ? decodeAscii(new Uint8Array(wfStateRow.value)) : null; - - console.log( - JSON.stringify( - { - handoffId: t.handoffId, - actorId: t.actorId, - wfState, - names: wh.nameRegistry, - entries: enriched, - }, - null, - 2, - ), - ); - - clearTimeout(to); - ws.close(); - resolve(); - }; - - ws.onerror = (err) => { - clearTimeout(to); - reject(err); - }; - }); -} diff --git a/factory/packages/backend/tmp-dump-wfkeys.mjs b/factory/packages/backend/tmp-dump-wfkeys.mjs deleted file mode 100644 index 41df274..0000000 --- a/factory/packages/backend/tmp-dump-wfkeys.mjs +++ /dev/null @@ -1,10 +0,0 @@ -import { Database } from "bun:sqlite"; - -const db = new Database("/root/.local/share/openhandoff/rivetkit/databases/2e443238457137bf.db", { readonly: true }); -const rows = db.query("SELECT hex(key) as k, value as v FROM kv WHERE hex(key) LIKE ? ORDER BY key").all("07%"); -const out = rows.map((r) => { - const bytes = new Uint8Array(r.v); - const txt = new TextDecoder().decode(bytes).replace(/[\x00-\x1F\x7F-\xFF]/g, "."); - return { k: r.k, vlen: bytes.length, txt: txt.slice(0, 260) }; -}); -console.log(JSON.stringify(out, null, 2)); diff --git a/factory/packages/backend/tmp-inspect-deep.mjs b/factory/packages/backend/tmp-inspect-deep.mjs deleted file mode 100644 index 2a330f1..0000000 --- a/factory/packages/backend/tmp-inspect-deep.mjs +++ /dev/null @@ -1,87 +0,0 @@ -import { Database } from "bun:sqlite"; -import { TO_CLIENT_VERSIONED, TO_SERVER_VERSIONED, CURRENT_VERSION, decodeWorkflowHistoryTransport } from "rivetkit/inspector"; -import { decodeReadRangeWire } from "/rivet-handoff-fixes/rivetkit-typescript/packages/traces/src/encoding.ts"; -import { readRangeWireToOtlp } from "/rivet-handoff-fixes/rivetkit-typescript/packages/traces/src/read-range.ts"; - -const actorId = "2e443238457137bf"; -const db = new Database(`/root/.local/share/openhandoff/rivetkit/databases/${actorId}.db`, { readonly: true }); -const row = db.query("SELECT value FROM kv WHERE hex(key)=?").get("03"); -const token = new TextDecoder().decode(row.value); - -const ws = new WebSocket(`ws://127.0.0.1:7750/gateway/${actorId}/inspector/connect`, [`rivet_inspector_token.${token}`]); -ws.binaryType = "arraybuffer"; - -let sent = false; -const timeout = setTimeout(() => { - console.error("timeout"); - process.exit(2); -}, 20000); - -function send(body) { - const bytes = TO_SERVER_VERSIONED.serializeWithEmbeddedVersion({ body }, CURRENT_VERSION); - ws.send(bytes); -} - -ws.onmessage = (ev) => { - const data = ev.data instanceof ArrayBuffer ? new Uint8Array(ev.data) : new Uint8Array(ev.data.buffer); - const msg = TO_CLIENT_VERSIONED.deserializeWithEmbeddedVersion(data); - - if (!sent && msg.body.tag === "Init") { - const init = msg.body.val; - const wh = decodeWorkflowHistoryTransport(init.workflowHistory); - const queueSize = Number(init.queueSize); - console.log(JSON.stringify({ tag: "InitSummary", queueSize, rpcs: init.rpcs, historyEntries: wh.entries.length, names: wh.nameRegistry }, null, 2)); - - send({ tag: "QueueRequest", val: { id: 1n, limit: 20n } }); - send({ tag: "WorkflowHistoryRequest", val: { id: 2n } }); - send({ tag: "TraceQueryRequest", val: { id: 3n, startMs: 0n, endMs: BigInt(Date.now()), limit: 2000n } }); - sent = true; - return; - } - - if (msg.body.tag === "QueueResponse") { - const status = msg.body.val.status; - console.log( - JSON.stringify( - { - tag: "QueueResponse", - size: Number(status.size), - truncated: status.truncated, - messages: status.messages.map((m) => ({ id: Number(m.id), name: m.name, createdAtMs: Number(m.createdAtMs) })), - }, - null, - 2, - ), - ); - return; - } - - if (msg.body.tag === "WorkflowHistoryResponse") { - const wh = decodeWorkflowHistoryTransport(msg.body.val.history); - console.log( - JSON.stringify( - { tag: "WorkflowHistoryResponse", isWorkflowEnabled: msg.body.val.isWorkflowEnabled, entryCount: wh.entries.length, names: wh.nameRegistry }, - null, - 2, - ), - ); - return; - } - - if (msg.body.tag === "TraceQueryResponse") { - const wire = decodeReadRangeWire(new Uint8Array(msg.body.val.payload)); - const otlp = readRangeWireToOtlp(wire, { attributes: [], droppedAttributesCount: 0 }); - const spans = (((otlp?.resourceSpans ?? [])[0]?.scopeSpans ?? [])[0]?.spans ?? []).map((s) => ({ name: s.name, status: s.status?.code })); - console.log(JSON.stringify({ tag: "TraceQueryResponse", spanCount: spans.length, tail: spans.slice(-25) }, null, 2)); - clearTimeout(timeout); - ws.close(); - process.exit(0); - return; - } -}; - -ws.onerror = (e) => { - console.error("ws error", e); - clearTimeout(timeout); - process.exit(1); -}; diff --git a/factory/packages/backend/tmp-inspect-stuck.mjs b/factory/packages/backend/tmp-inspect-stuck.mjs deleted file mode 100644 index 81c27bc..0000000 --- a/factory/packages/backend/tmp-inspect-stuck.mjs +++ /dev/null @@ -1,51 +0,0 @@ -import { Database } from "bun:sqlite"; - -const actorIds = [ - "2e443238457137bf", // 7df... - "2b3fe1c099327eed", // 706... - "331b7f2a0cd19973", // 70c... - "329a70fc689f56ca", // 1f14... - "0e53dd77ef06862f", // 0e01... - "ea8c0e764c836e5f", // cdc error -]; - -function decodeAscii(u8) { - return new TextDecoder().decode(u8).replace(/[\x00-\x1F\x7F-\xFF]/g, "."); -} - -for (const actorId of actorIds) { - const dbPath = `/root/.local/share/openhandoff/rivetkit/databases/${actorId}.db`; - const db = new Database(dbPath, { readonly: true }); - - const wfStateRow = db.query("SELECT value FROM kv WHERE hex(key)=?").get("0715041501"); - const wfState = wfStateRow?.value ? decodeAscii(new Uint8Array(wfStateRow.value)) : null; - - const names = db - .query("SELECT value FROM kv WHERE hex(key) LIKE ? ORDER BY key") - .all("07150115%") - .map((r) => decodeAscii(new Uint8Array(r.value))); - - const queueRows = db - .query("SELECT hex(key) as k, value FROM kv WHERE hex(key) LIKE ? ORDER BY key") - .all("05%") - .map((r) => ({ - key: r.k, - preview: decodeAscii(new Uint8Array(r.value)).slice(0, 220), - })); - - const hasCreateSandboxStepName = names.includes("init-create-sandbox") || names.includes("init_create_sandbox"); - - console.log( - JSON.stringify( - { - actorId, - wfState, - hasCreateSandboxStepName, - names, - queue: queueRows, - }, - null, - 2, - ), - ); -} diff --git a/factory/packages/backend/tmp-inspect-workflow.mjs b/factory/packages/backend/tmp-inspect-workflow.mjs deleted file mode 100644 index 3bc9355..0000000 --- a/factory/packages/backend/tmp-inspect-workflow.mjs +++ /dev/null @@ -1,30 +0,0 @@ -import { Database } from "bun:sqlite"; -import { TO_CLIENT_VERSIONED, decodeWorkflowHistoryTransport } from "rivetkit/inspector"; -import util from "node:util"; - -const actorId = "2e443238457137bf"; -const db = new Database(`/root/.local/share/openhandoff/rivetkit/databases/${actorId}.db`, { readonly: true }); -const row = db.query("SELECT value FROM kv WHERE hex(key) = ?").get("03"); -const token = new TextDecoder().decode(row.value); - -const ws = new WebSocket(`ws://127.0.0.1:7750/gateway/${actorId}/inspector/connect`, [`rivet_inspector_token.${token}`]); -ws.binaryType = "arraybuffer"; -const timeout = setTimeout(() => process.exit(2), 15000); -ws.onmessage = (ev) => { - const data = ev.data instanceof ArrayBuffer ? new Uint8Array(ev.data) : new Uint8Array(ev.data.buffer); - const msg = TO_CLIENT_VERSIONED.deserializeWithEmbeddedVersion(data); - const init = msg.body?.tag === "Init" ? msg.body.val : null; - if (!init) { - console.log("unexpected", util.inspect(msg, { depth: 4 })); - process.exit(1); - } - const decoded = decodeWorkflowHistoryTransport(init.workflowHistory); - console.log(util.inspect(decoded, { depth: 10, colors: false, compact: false, breakLength: 140 })); - clearTimeout(timeout); - ws.close(); - process.exit(0); -}; -ws.onerror = () => { - clearTimeout(timeout); - process.exit(1); -}; diff --git a/factory/packages/client/src/mock/workbench-client.ts b/factory/packages/client/src/mock/workbench-client.ts deleted file mode 100644 index c921634..0000000 --- a/factory/packages/client/src/mock/workbench-client.ts +++ /dev/null @@ -1,443 +0,0 @@ -import { - MODEL_GROUPS, - buildInitialMockLayoutViewModel, - groupWorkbenchProjects, - nowMs, - providerAgent, - randomReply, - removeFileTreePath, - slugify, - uid, -} from "../workbench-model.js"; -import type { - HandoffWorkbenchAddTabResponse, - HandoffWorkbenchChangeModelInput, - HandoffWorkbenchCreateHandoffInput, - HandoffWorkbenchCreateHandoffResponse, - HandoffWorkbenchDiffInput, - HandoffWorkbenchRenameInput, - HandoffWorkbenchRenameSessionInput, - HandoffWorkbenchSelectInput, - HandoffWorkbenchSetSessionUnreadInput, - HandoffWorkbenchSendMessageInput, - HandoffWorkbenchSnapshot, - HandoffWorkbenchTabInput, - HandoffWorkbenchUpdateDraftInput, - WorkbenchAgentTab as AgentTab, - WorkbenchHandoff as Handoff, - WorkbenchTranscriptEvent as TranscriptEvent, -} from "@openhandoff/shared"; -import type { HandoffWorkbenchClient } from "../workbench-client.js"; - -function buildTranscriptEvent(params: { - sessionId: string; - sender: "client" | "agent"; - createdAt: number; - payload: unknown; - eventIndex: number; -}): TranscriptEvent { - return { - id: uid(), - sessionId: params.sessionId, - sender: params.sender, - createdAt: params.createdAt, - payload: params.payload, - connectionId: "mock-connection", - eventIndex: params.eventIndex, - }; -} - -class MockWorkbenchStore implements HandoffWorkbenchClient { - private snapshot = buildInitialMockLayoutViewModel(); - private listeners = new Set<() => void>(); - private pendingTimers = new Map>(); - - getSnapshot(): HandoffWorkbenchSnapshot { - return this.snapshot; - } - - subscribe(listener: () => void): () => void { - this.listeners.add(listener); - return () => { - this.listeners.delete(listener); - }; - } - - async createHandoff(input: HandoffWorkbenchCreateHandoffInput): Promise { - const id = uid(); - const tabId = `session-${id}`; - const repo = this.snapshot.repos.find((candidate) => candidate.id === input.repoId); - if (!repo) { - throw new Error(`Cannot create mock handoff for unknown repo ${input.repoId}`); - } - const nextHandoff: Handoff = { - id, - repoId: repo.id, - title: input.title?.trim() || "New Handoff", - status: "new", - repoName: repo.label, - updatedAtMs: nowMs(), - branch: input.branch?.trim() || null, - pullRequest: null, - tabs: [ - { - id: tabId, - sessionId: tabId, - sessionName: "Session 1", - agent: providerAgent( - MODEL_GROUPS.find((group) => group.models.some((model) => model.id === (input.model ?? "claude-sonnet-4")))?.provider ?? "Claude", - ), - model: input.model ?? "claude-sonnet-4", - status: "idle", - thinkingSinceMs: null, - unread: false, - created: false, - draft: { text: "", attachments: [], updatedAtMs: null }, - transcript: [], - }, - ], - fileChanges: [], - diffs: {}, - fileTree: [], - }; - - this.updateState((current) => ({ - ...current, - handoffs: [nextHandoff, ...current.handoffs], - })); - return { handoffId: id, tabId }; - } - - async markHandoffUnread(input: HandoffWorkbenchSelectInput): Promise { - this.updateHandoff(input.handoffId, (handoff) => { - const targetTab = handoff.tabs[handoff.tabs.length - 1] ?? null; - if (!targetTab) { - return handoff; - } - - return { - ...handoff, - tabs: handoff.tabs.map((tab) => (tab.id === targetTab.id ? { ...tab, unread: true } : tab)), - }; - }); - } - - async renameHandoff(input: HandoffWorkbenchRenameInput): Promise { - const value = input.value.trim(); - if (!value) { - throw new Error(`Cannot rename handoff ${input.handoffId} to an empty title`); - } - this.updateHandoff(input.handoffId, (handoff) => ({ ...handoff, title: value, updatedAtMs: nowMs() })); - } - - async renameBranch(input: HandoffWorkbenchRenameInput): Promise { - const value = input.value.trim(); - if (!value) { - throw new Error(`Cannot rename branch for handoff ${input.handoffId} to an empty value`); - } - this.updateHandoff(input.handoffId, (handoff) => ({ ...handoff, branch: value, updatedAtMs: nowMs() })); - } - - async archiveHandoff(input: HandoffWorkbenchSelectInput): Promise { - this.updateHandoff(input.handoffId, (handoff) => ({ ...handoff, status: "archived", updatedAtMs: nowMs() })); - } - - async publishPr(input: HandoffWorkbenchSelectInput): Promise { - const nextPrNumber = Math.max(0, ...this.snapshot.handoffs.map((handoff) => handoff.pullRequest?.number ?? 0)) + 1; - this.updateHandoff(input.handoffId, (handoff) => ({ - ...handoff, - updatedAtMs: nowMs(), - pullRequest: { number: nextPrNumber, status: "ready" }, - })); - } - - async revertFile(input: HandoffWorkbenchDiffInput): Promise { - this.updateHandoff(input.handoffId, (handoff) => { - const file = handoff.fileChanges.find((entry) => entry.path === input.path); - const nextDiffs = { ...handoff.diffs }; - delete nextDiffs[input.path]; - - return { - ...handoff, - fileChanges: handoff.fileChanges.filter((entry) => entry.path !== input.path), - diffs: nextDiffs, - fileTree: file?.type === "A" ? removeFileTreePath(handoff.fileTree, input.path) : handoff.fileTree, - }; - }); - } - - async updateDraft(input: HandoffWorkbenchUpdateDraftInput): Promise { - this.assertTab(input.handoffId, input.tabId); - this.updateHandoff(input.handoffId, (handoff) => ({ - ...handoff, - updatedAtMs: nowMs(), - tabs: handoff.tabs.map((tab) => - tab.id === input.tabId - ? { - ...tab, - draft: { - text: input.text, - attachments: input.attachments, - updatedAtMs: nowMs(), - }, - } - : tab, - ), - })); - } - - async sendMessage(input: HandoffWorkbenchSendMessageInput): Promise { - const text = input.text.trim(); - if (!text) { - throw new Error(`Cannot send an empty mock prompt for handoff ${input.handoffId}`); - } - - this.assertTab(input.handoffId, input.tabId); - const startedAtMs = nowMs(); - - this.updateHandoff(input.handoffId, (currentHandoff) => { - const isFirstOnHandoff = currentHandoff.status === "new"; - const newTitle = isFirstOnHandoff ? (text.length > 50 ? `${text.slice(0, 47)}...` : text) : currentHandoff.title; - const newBranch = isFirstOnHandoff ? `feat/${slugify(newTitle)}` : currentHandoff.branch; - const userMessageLines = [text, ...input.attachments.map((attachment) => `@ ${attachment.filePath}:${attachment.lineNumber}`)]; - const userEvent = buildTranscriptEvent({ - sessionId: input.tabId, - sender: "client", - createdAt: startedAtMs, - eventIndex: candidateEventIndex(currentHandoff, input.tabId), - payload: { - method: "session/prompt", - params: { - prompt: userMessageLines.map((line) => ({ type: "text", text: line })), - }, - }, - }); - - return { - ...currentHandoff, - title: newTitle, - branch: newBranch, - status: "running", - updatedAtMs: startedAtMs, - tabs: currentHandoff.tabs.map((candidate) => - candidate.id === input.tabId - ? { - ...candidate, - created: true, - status: "running", - unread: false, - thinkingSinceMs: startedAtMs, - draft: { text: "", attachments: [], updatedAtMs: startedAtMs }, - transcript: [...candidate.transcript, userEvent], - } - : candidate, - ), - }; - }); - - const existingTimer = this.pendingTimers.get(input.tabId); - if (existingTimer) { - clearTimeout(existingTimer); - } - - const timer = setTimeout(() => { - const handoff = this.requireHandoff(input.handoffId); - const replyTab = this.requireTab(handoff, input.tabId); - const completedAtMs = nowMs(); - const replyEvent = buildTranscriptEvent({ - sessionId: input.tabId, - sender: "agent", - createdAt: completedAtMs, - eventIndex: candidateEventIndex(handoff, input.tabId), - payload: { - result: { - text: randomReply(), - durationMs: completedAtMs - startedAtMs, - }, - }, - }); - - this.updateHandoff(input.handoffId, (currentHandoff) => { - const updatedTabs = currentHandoff.tabs.map((candidate) => { - if (candidate.id !== input.tabId) { - return candidate; - } - - return { - ...candidate, - status: "idle" as const, - thinkingSinceMs: null, - unread: true, - transcript: [...candidate.transcript, replyEvent], - }; - }); - const anyRunning = updatedTabs.some((candidate) => candidate.status === "running"); - - return { - ...currentHandoff, - updatedAtMs: completedAtMs, - tabs: updatedTabs, - status: currentHandoff.status === "archived" ? "archived" : anyRunning ? "running" : "idle", - }; - }); - - this.pendingTimers.delete(input.tabId); - }, 2_500); - - this.pendingTimers.set(input.tabId, timer); - } - - async stopAgent(input: HandoffWorkbenchTabInput): Promise { - this.assertTab(input.handoffId, input.tabId); - const existing = this.pendingTimers.get(input.tabId); - if (existing) { - clearTimeout(existing); - this.pendingTimers.delete(input.tabId); - } - - this.updateHandoff(input.handoffId, (currentHandoff) => { - const updatedTabs = currentHandoff.tabs.map((candidate) => - candidate.id === input.tabId ? { ...candidate, status: "idle" as const, thinkingSinceMs: null } : candidate, - ); - const anyRunning = updatedTabs.some((candidate) => candidate.status === "running"); - - return { - ...currentHandoff, - updatedAtMs: nowMs(), - tabs: updatedTabs, - status: currentHandoff.status === "archived" ? "archived" : anyRunning ? "running" : "idle", - }; - }); - } - - async setSessionUnread(input: HandoffWorkbenchSetSessionUnreadInput): Promise { - this.updateHandoff(input.handoffId, (currentHandoff) => ({ - ...currentHandoff, - tabs: currentHandoff.tabs.map((candidate) => (candidate.id === input.tabId ? { ...candidate, unread: input.unread } : candidate)), - })); - } - - async renameSession(input: HandoffWorkbenchRenameSessionInput): Promise { - const title = input.title.trim(); - if (!title) { - throw new Error(`Cannot rename session ${input.tabId} to an empty title`); - } - this.updateHandoff(input.handoffId, (currentHandoff) => ({ - ...currentHandoff, - tabs: currentHandoff.tabs.map((candidate) => (candidate.id === input.tabId ? { ...candidate, sessionName: title } : candidate)), - })); - } - - async closeTab(input: HandoffWorkbenchTabInput): Promise { - this.updateHandoff(input.handoffId, (currentHandoff) => { - if (currentHandoff.tabs.length <= 1) { - return currentHandoff; - } - - return { - ...currentHandoff, - tabs: currentHandoff.tabs.filter((candidate) => candidate.id !== input.tabId), - }; - }); - } - - async addTab(input: HandoffWorkbenchSelectInput): Promise { - this.assertHandoff(input.handoffId); - const nextTab: AgentTab = { - id: uid(), - sessionId: null, - sessionName: `Session ${this.requireHandoff(input.handoffId).tabs.length + 1}`, - agent: "Claude", - model: "claude-sonnet-4", - status: "idle", - thinkingSinceMs: null, - unread: false, - created: false, - draft: { text: "", attachments: [], updatedAtMs: null }, - transcript: [], - }; - - this.updateHandoff(input.handoffId, (currentHandoff) => ({ - ...currentHandoff, - updatedAtMs: nowMs(), - tabs: [...currentHandoff.tabs, nextTab], - })); - return { tabId: nextTab.id }; - } - - async changeModel(input: HandoffWorkbenchChangeModelInput): 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}`); - } - - this.updateHandoff(input.handoffId, (currentHandoff) => ({ - ...currentHandoff, - tabs: currentHandoff.tabs.map((candidate) => - candidate.id === input.tabId ? { ...candidate, model: input.model, agent: providerAgent(group.provider) } : candidate, - ), - })); - } - - private updateState(updater: (current: HandoffWorkbenchSnapshot) => HandoffWorkbenchSnapshot): void { - const nextSnapshot = updater(this.snapshot); - this.snapshot = { - ...nextSnapshot, - projects: groupWorkbenchProjects(nextSnapshot.repos, nextSnapshot.handoffs), - }; - this.notify(); - } - - private updateHandoff(handoffId: string, updater: (handoff: Handoff) => Handoff): void { - this.assertHandoff(handoffId); - this.updateState((current) => ({ - ...current, - handoffs: current.handoffs.map((handoff) => (handoff.id === handoffId ? updater(handoff) : handoff)), - })); - } - - private notify(): void { - for (const listener of this.listeners) { - listener(); - } - } - - private assertHandoff(handoffId: string): void { - this.requireHandoff(handoffId); - } - - private assertTab(handoffId: string, tabId: string): void { - const handoff = this.requireHandoff(handoffId); - this.requireTab(handoff, tabId); - } - - private requireHandoff(handoffId: string): Handoff { - const handoff = this.snapshot.handoffs.find((candidate) => candidate.id === handoffId); - if (!handoff) { - throw new Error(`Unable to find mock handoff ${handoffId}`); - } - return handoff; - } - - private requireTab(handoff: Handoff, tabId: string): AgentTab { - const tab = handoff.tabs.find((candidate) => candidate.id === tabId); - if (!tab) { - throw new Error(`Unable to find mock tab ${tabId} in handoff ${handoff.id}`); - } - return tab; - } -} - -function candidateEventIndex(handoff: Handoff, tabId: string): number { - const tab = handoff.tabs.find((candidate) => candidate.id === tabId); - return (tab?.transcript.length ?? 0) + 1; -} - -let sharedMockWorkbenchClient: HandoffWorkbenchClient | null = null; - -export function getSharedMockWorkbenchClient(): HandoffWorkbenchClient { - if (!sharedMockWorkbenchClient) { - sharedMockWorkbenchClient = new MockWorkbenchStore(); - } - return sharedMockWorkbenchClient; -} diff --git a/factory/packages/client/src/workbench-client.ts b/factory/packages/client/src/workbench-client.ts deleted file mode 100644 index ca1a6de..0000000 --- a/factory/packages/client/src/workbench-client.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { - HandoffWorkbenchAddTabResponse, - HandoffWorkbenchChangeModelInput, - HandoffWorkbenchCreateHandoffInput, - HandoffWorkbenchCreateHandoffResponse, - HandoffWorkbenchDiffInput, - HandoffWorkbenchRenameInput, - HandoffWorkbenchRenameSessionInput, - HandoffWorkbenchSelectInput, - HandoffWorkbenchSetSessionUnreadInput, - HandoffWorkbenchSendMessageInput, - HandoffWorkbenchSnapshot, - HandoffWorkbenchTabInput, - HandoffWorkbenchUpdateDraftInput, -} from "@openhandoff/shared"; -import type { BackendClient } from "./backend-client.js"; -import { getSharedMockWorkbenchClient } from "./mock/workbench-client.js"; -import { createRemoteWorkbenchClient } from "./remote/workbench-client.js"; - -export type HandoffWorkbenchClientMode = "mock" | "remote"; - -export interface CreateHandoffWorkbenchClientOptions { - mode: HandoffWorkbenchClientMode; - backend?: BackendClient; - workspaceId?: string; -} - -export interface HandoffWorkbenchClient { - getSnapshot(): HandoffWorkbenchSnapshot; - subscribe(listener: () => void): () => void; - createHandoff(input: HandoffWorkbenchCreateHandoffInput): Promise; - markHandoffUnread(input: HandoffWorkbenchSelectInput): Promise; - renameHandoff(input: HandoffWorkbenchRenameInput): Promise; - renameBranch(input: HandoffWorkbenchRenameInput): Promise; - archiveHandoff(input: HandoffWorkbenchSelectInput): Promise; - publishPr(input: HandoffWorkbenchSelectInput): Promise; - revertFile(input: HandoffWorkbenchDiffInput): Promise; - updateDraft(input: HandoffWorkbenchUpdateDraftInput): Promise; - sendMessage(input: HandoffWorkbenchSendMessageInput): Promise; - stopAgent(input: HandoffWorkbenchTabInput): Promise; - setSessionUnread(input: HandoffWorkbenchSetSessionUnreadInput): Promise; - renameSession(input: HandoffWorkbenchRenameSessionInput): Promise; - closeTab(input: HandoffWorkbenchTabInput): Promise; - addTab(input: HandoffWorkbenchSelectInput): Promise; - changeModel(input: HandoffWorkbenchChangeModelInput): Promise; -} - -export function createHandoffWorkbenchClient(options: CreateHandoffWorkbenchClientOptions): HandoffWorkbenchClient { - if (options.mode === "mock") { - return getSharedMockWorkbenchClient(); - } - - if (!options.backend) { - throw new Error("Remote handoff workbench client requires a backend client"); - } - if (!options.workspaceId) { - throw new Error("Remote handoff workbench client requires a workspace id"); - } - - return createRemoteWorkbenchClient({ - backend: options.backend, - workspaceId: options.workspaceId, - }); -} diff --git a/factory/packages/frontend/src/app/router.tsx b/factory/packages/frontend/src/app/router.tsx deleted file mode 100644 index 96998e1..0000000 --- a/factory/packages/frontend/src/app/router.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import { useEffect } from "react"; -import { setFrontendErrorContext } from "@openhandoff/frontend-errors/client"; -import { Navigate, Outlet, createRootRoute, createRoute, createRouter, useRouterState } from "@tanstack/react-router"; -import { MockLayout } from "../components/mock-layout"; -import { defaultWorkspaceId } from "../lib/env"; -import { handoffWorkbenchClient } from "../lib/workbench"; - -const rootRoute = createRootRoute({ - component: RootLayout, -}); - -const indexRoute = createRoute({ - getParentRoute: () => rootRoute, - path: "/", - component: () => , -}); - -const workspaceRoute = createRoute({ - getParentRoute: () => rootRoute, - path: "/workspaces/$workspaceId", - component: WorkspaceLayoutRoute, -}); - -const workspaceIndexRoute = createRoute({ - getParentRoute: () => workspaceRoute, - path: "/", - component: WorkspaceRoute, -}); - -const handoffRoute = createRoute({ - getParentRoute: () => workspaceRoute, - path: "handoffs/$handoffId", - validateSearch: (search: Record) => ({ - sessionId: typeof search.sessionId === "string" && search.sessionId.trim().length > 0 ? search.sessionId : undefined, - }), - component: HandoffRoute, -}); - -const repoRoute = createRoute({ - getParentRoute: () => workspaceRoute, - path: "repos/$repoId", - component: RepoRoute, -}); - -const routeTree = rootRoute.addChildren([indexRoute, workspaceRoute.addChildren([workspaceIndexRoute, handoffRoute, repoRoute])]); - -export const router = createRouter({ routeTree }); - -declare module "@tanstack/react-router" { - interface Register { - router: typeof router; - } -} - -function WorkspaceLayoutRoute() { - return ; -} - -function WorkspaceRoute() { - const { workspaceId } = workspaceRoute.useParams(); - useEffect(() => { - setFrontendErrorContext({ - workspaceId, - handoffId: undefined, - }); - }, [workspaceId]); - return ; -} - -function HandoffRoute() { - const { workspaceId, handoffId } = handoffRoute.useParams(); - const { sessionId } = handoffRoute.useSearch(); - useEffect(() => { - setFrontendErrorContext({ - workspaceId, - handoffId, - repoId: undefined, - }); - }, [handoffId, workspaceId]); - return ; -} - -function RepoRoute() { - const { workspaceId, repoId } = repoRoute.useParams(); - useEffect(() => { - setFrontendErrorContext({ - workspaceId, - handoffId: undefined, - repoId, - }); - }, [repoId, workspaceId]); - const activeHandoffId = handoffWorkbenchClient.getSnapshot().handoffs.find((handoff) => handoff.repoId === repoId)?.id; - if (!activeHandoffId) { - return ; - } - return ( - - ); -} - -function RootLayout() { - return ( - <> - - - - ); -} - -function RouteContextSync() { - const location = useRouterState({ - select: (state) => state.location, - }); - - useEffect(() => { - setFrontendErrorContext({ - route: `${location.pathname}${location.search}${location.hash}`, - }); - }, [location.hash, location.pathname, location.search]); - - return null; -} diff --git a/factory/AGENTS.md b/foundry/AGENTS.md similarity index 100% rename from factory/AGENTS.md rename to foundry/AGENTS.md diff --git a/factory/CLAUDE.md b/foundry/CLAUDE.md similarity index 84% rename from factory/CLAUDE.md rename to foundry/CLAUDE.md index 0454f02..ee214a9 100644 --- a/factory/CLAUDE.md +++ b/foundry/CLAUDE.md @@ -27,27 +27,23 @@ Use `pnpm` workspaces and Turborepo. - `packages/cli` is fully disabled for active development. - Do not implement new behavior in `packages/cli` unless explicitly requested. - Frontend is the primary product surface; prioritize `packages/frontend` + supporting `packages/client`/`packages/backend`. -- Workspace `build`, `typecheck`, and `test` intentionally exclude `@openhandoff/cli`. +- Workspace `build`, `typecheck`, and `test` intentionally exclude `@sandbox-agent/foundry-cli`. - `pnpm-workspace.yaml` excludes `packages/cli` from workspace package resolution. ## Common Commands +- Foundry is the canonical name for this product tree. Do not introduce or preserve legacy pre-Foundry naming in code, docs, commands, or runtime paths. - Install deps: `pnpm install` - Full active-workspace validation: `pnpm -w typecheck`, `pnpm -w build`, `pnpm -w test` -- Start the full dev stack: `just factory-dev` -- Start the local production-build preview stack: `just factory-preview` -- Start only the backend locally: `just factory-backend-start` -- Start only the frontend locally: `pnpm --filter @openhandoff/frontend dev` -- Start the frontend against the mock workbench client: `OPENHANDOFF_FRONTEND_CLIENT_MODE=mock pnpm --filter @openhandoff/frontend dev` -- Stop the compose dev stack: `just factory-dev-down` -- Tail compose logs: `just factory-dev-logs` -- Stop the preview stack: `just factory-preview-down` -- Tail preview logs: `just factory-preview-logs` - -## Local Env - -- For local The Foundry dev server setup, keep a personal env copy at `~/misc/the-foundry.env`. -- To run the dev server from this workspace, copy that content into the repo root `.env`. Root `.env` is gitignored in this repo, so keep local secrets there and do not commit them. +- Start the full dev stack: `just foundry-dev` +- Start the local production-build preview stack: `just foundry-preview` +- Start only the backend locally: `just foundry-backend-start` +- Start only the frontend locally: `pnpm --filter @sandbox-agent/foundry-frontend dev` +- Start the frontend against the mock workbench client: `FOUNDRY_FRONTEND_CLIENT_MODE=mock pnpm --filter @sandbox-agent/foundry-frontend dev` +- Stop the compose dev stack: `just foundry-dev-down` +- Tail compose logs: `just foundry-dev-logs` +- Stop the preview stack: `just foundry-preview-down` +- Tail preview logs: `just foundry-preview-logs` ## Frontend + Client Boundary @@ -85,12 +81,12 @@ For all Rivet/RivetKit implementation: 2. SQLite is **per actor instance** (per actor key), not a shared backend-global database: - Each actor instance gets its own SQLite DB. - Schema design should assume a single actor instance owns the entire DB. - - Do not add `workspaceId`/`repoId`/`handoffId` columns just to "namespace" rows for a given actor instance; use actor state and/or the actor key instead. - - Example: the `handoff` actor instance already represents `(workspaceId, repoId, handoffId)`, so its SQLite tables should not need those columns for primary keys. + - Do not add `workspaceId`/`repoId`/`taskId` columns just to "namespace" rows for a given actor instance; use actor state and/or the actor key instead. + - Example: the `task` actor instance already represents `(workspaceId, repoId, taskId)`, so its SQLite tables should not need those columns for primary keys. 3. Do not use backend-global SQLite singletons; database access must go through actor `db` providers (`c.db`). 4. The default dependency source for RivetKit is the published `rivetkit` package so workspace installs and CI remain self-contained. 5. When working on coordinated RivetKit changes, you may temporarily relink to a local checkout instead of the published package. - - Dedicated local checkout for this workspace: `/Users/nathan/conductor/workspaces/handoff/rivet-checkout` + - Dedicated local checkout for this workspace: `/Users/nathan/conductor/workspaces/task/rivet-checkout` - Preferred local link target: `../rivet-checkout/rivetkit-typescript/packages/rivetkit` - Sub-packages (`@rivetkit/sqlite-vfs`, etc.) resolve transitively from the RivetKit workspace when using the local checkout. 6. Before using a local checkout, build RivetKit in the rivet repo: @@ -108,7 +104,7 @@ For all Rivet/RivetKit implementation: curl -sS http://127.0.0.1:7741/api/rivet/metadata | jq -r '.clientEndpoint' ``` - List actors: - - `GET {manager}/actors?name=handoff` + - `GET {manager}/actors?name=task` - Inspector endpoints (path prefix: `/gateway/{actorId}/inspector`): - `GET /state` - `PATCH /state` @@ -122,12 +118,12 @@ For all Rivet/RivetKit implementation: - Auth: - Production: send `Authorization: Bearer $RIVET_INSPECTOR_TOKEN`. - Development: auth can be skipped when no inspector token is configured. -- Handoff workflow quick inspect: +- Task workflow quick inspect: ```bash MGR="$(curl -sS http://127.0.0.1:7741/api/rivet/metadata | jq -r '.clientEndpoint')" HID="7df7656e-bbd2-4b8c-bf0f-30d4df2f619a" - AID="$(curl -sS "$MGR/actors?name=handoff" \ - | jq -r --arg hid "$HID" '.actors[] | select(.key | endswith("/handoff/\($hid)")) | .actor_id' \ + AID="$(curl -sS "$MGR/actors?name=task" \ + | jq -r --arg hid "$HID" '.actors[] | select(.key | endswith("/task/\($hid)")) | .actor_id' \ | head -n1)" curl -sS "$MGR/gateway/$AID/inspector/workflow-history" | jq . curl -sS "$MGR/gateway/$AID/inspector/summary" | jq . @@ -140,11 +136,11 @@ For all Rivet/RivetKit implementation: - Workspace resolution order: `--workspace` flag -> config default -> `"default"`. - `ControlPlaneActor` is replaced by `WorkspaceActor` (workspace coordinator). - Every actor key must be prefixed with workspace namespace (`["ws", workspaceId, ...]`). -- CLI/TUI/GUI must use `@openhandoff/client` (`packages/client`) for backend access; `rivetkit/client` imports are only allowed inside `packages/client`. +- CLI/TUI/GUI must use `@sandbox-agent/foundry-client` (`packages/client`) for backend access; `rivetkit/client` imports are only allowed inside `packages/client`. - Do not add custom backend REST endpoints (no `/v1/*` shim layer). - We own the sandbox-agent project; treat sandbox-agent defects as first-party bugs and fix them instead of working around them. - Keep strict single-writer ownership: each table/row has exactly one actor writer. -- Parent actors (`workspace`, `project`, `handoff`, `history`, `sandbox-instance`) use command-only loops with no timeout. +- Parent actors (`workspace`, `project`, `task`, `history`, `sandbox-instance`) use command-only loops with no timeout. - Periodic syncing lives in dedicated child actors with one timeout cadence each. - Actor handle policy: - Prefer explicit `get` or explicit `create` based on workflow intent; do not default to `getOrCreate`. @@ -152,13 +148,13 @@ For all Rivet/RivetKit implementation: - Use create semantics only on explicit provisioning/create paths where creating a new actor instance is intended. - `getOrCreate` is a last resort for create paths when an explicit create API is unavailable; never use it in read/command paths. - For long-lived cross-actor links (for example sandbox/session runtime access), persist actor identity (`actorId`) and keep a fallback lookup path by actor id. -- Docker dev: `compose.dev.yaml` mounts a named volume at `/root/.local/share/openhandoff/repos` to persist backend-managed git clones across restarts. Code must still work if this volume is not present (create directories as needed). +- Docker dev: `compose.dev.yaml` mounts a named volume at `/root/.local/share/foundry/repos` to persist backend-managed git clones across restarts. Code must still work if this volume is not present (create directories as needed). - RivetKit actor `c.state` is durable, but in Docker it is stored under `/root/.local/share/rivetkit`. If that path is not persisted, actor state-derived indexes (for example, in `project` actor state) can be lost after container recreation even when other data still exists. - Workflow history divergence policy: - Production: never auto-delete actor state to resolve `HistoryDivergedError`; ship explicit workflow migrations (`ctx.removed(...)`, step compatibility). - Development: manual local state reset is allowed as an operator recovery path when migrations are not yet available. - Storage rule of thumb: -- Put simple metadata in `c.state` (KV state): small scalars and identifiers like `{ handoffId }`, `{ repoId }`, booleans, counters, timestamps, status strings. +- Put simple metadata in `c.state` (KV state): small scalars and identifiers like `{ taskId }`, `{ repoId }`, booleans, counters, timestamps, status strings. - If it grows beyond trivial (arrays, maps, histories, query/filter needs, relational consistency), use SQLite + Drizzle in `c.db`. ## Testing Policy @@ -168,7 +164,6 @@ For all Rivet/RivetKit implementation: - Integration tests use `setupTest()` from `rivetkit/test` and are gated behind `HF_ENABLE_ACTOR_INTEGRATION_TESTS=1`. - End-to-end testing must run against the dev backend started via `docker compose -f compose.dev.yaml up` (host -> container). Do not run E2E against an in-process test runtime. - E2E tests should talk to the backend over HTTP (default `http://127.0.0.1:7741/api/rivet`) and use real GitHub repos/PRs. - - Current org test repo: `rivet-dev/sandbox-agent-testing` (`https://github.com/rivet-dev/sandbox-agent-testing`). - Secrets (e.g. `OPENAI_API_KEY`, `GITHUB_TOKEN`/`GH_TOKEN`) must be provided via environment variables, never hardcoded in the repo. - Treat client E2E tests in `packages/client/test` as the primary end-to-end source of truth for product behavior. - Keep backend tests small and targeted. Only retain backend-only tests for invariants or persistence rules that are not well-covered through client E2E. @@ -176,7 +171,7 @@ For all Rivet/RivetKit implementation: ## Config -- Keep config path at `~/.config/openhandoff/config.toml`. +- Keep config path at `~/.config/foundry/config.toml`. - Evolve properties in place; do not move config location. ## Project Guidance diff --git a/factory/CONTRIBUTING.md b/foundry/CONTRIBUTING.md similarity index 83% rename from factory/CONTRIBUTING.md rename to foundry/CONTRIBUTING.md index 759f348..29875a9 100644 --- a/factory/CONTRIBUTING.md +++ b/foundry/CONTRIBUTING.md @@ -5,8 +5,8 @@ 1. Clone: ```bash -git clone https://github.com/rivet-dev/openhandoff.git -cd openhandoff +git clone https://github.com/rivet-dev/sandbox-agent.git +cd sandbox-agent/foundry ``` 2. Install dependencies: @@ -35,7 +35,7 @@ Build local RivetKit before backend changes that depend on Rivet internals: cd ../rivet pnpm build -F rivetkit -cd /path/to/openhandoff +cd /path/to/sandbox-agent/foundry just sync-rivetkit ``` @@ -54,11 +54,11 @@ pnpm -w test Start the dev backend (hot reload via `bun --watch`) and Vite frontend via Docker Compose: ```bash -just factory-dev +just foundry-dev ``` Stop it: ```bash -just factory-dev-down +just foundry-dev-down ``` diff --git a/factory/Dockerfile b/foundry/Dockerfile similarity index 79% rename from factory/Dockerfile rename to foundry/Dockerfile index 5693650..dcfbded 100644 --- a/factory/Dockerfile +++ b/foundry/Dockerfile @@ -22,19 +22,19 @@ COPY packages/rivetkit-vendor/sqlite-vfs-win32-x64/package.json packages/rivetki COPY packages/rivetkit-vendor/runner/package.json packages/rivetkit-vendor/runner/package.json COPY packages/rivetkit-vendor/runner-protocol/package.json packages/rivetkit-vendor/runner-protocol/package.json COPY packages/rivetkit-vendor/virtual-websocket/package.json packages/rivetkit-vendor/virtual-websocket/package.json -RUN pnpm fetch --frozen-lockfile --filter @openhandoff/backend... +RUN pnpm fetch --frozen-lockfile --filter @sandbox-agent/foundry-backend... FROM base AS build COPY --from=deps /pnpm/store /pnpm/store COPY . . -RUN pnpm install --frozen-lockfile --prefer-offline --filter @openhandoff/backend... -RUN pnpm --filter @openhandoff/shared build -RUN pnpm --filter @openhandoff/backend build -RUN pnpm --filter @openhandoff/backend deploy --prod --legacy /out +RUN pnpm install --frozen-lockfile --prefer-offline --filter @sandbox-agent/foundry-backend... +RUN pnpm --filter @sandbox-agent/foundry-shared build +RUN pnpm --filter @sandbox-agent/foundry-backend build +RUN pnpm --filter @sandbox-agent/foundry-backend deploy --prod --legacy /out FROM oven/bun:1.2 AS runtime ENV NODE_ENV=production -ENV HOME=/home/handoff +ENV HOME=/home/task WORKDIR /app RUN apt-get update \ && apt-get install -y --no-install-recommends \ @@ -43,11 +43,11 @@ RUN apt-get update \ gh \ openssh-client \ && rm -rf /var/lib/apt/lists/* -RUN addgroup --system --gid 1001 handoff \ - && adduser --system --uid 1001 --home /home/handoff --ingroup handoff handoff \ - && mkdir -p /home/handoff \ - && chown -R handoff:handoff /home/handoff /app +RUN addgroup --system --gid 1001 task \ + && adduser --system --uid 1001 --home /home/task --ingroup task task \ + && mkdir -p /home/task \ + && chown -R task:task /home/task /app COPY --from=build /out ./ -USER handoff +USER task EXPOSE 7741 CMD ["bun", "dist/index.js", "start", "--host", "0.0.0.0"] diff --git a/factory/README.md b/foundry/README.md similarity index 70% rename from factory/README.md rename to foundry/README.md index c49f7cb..f65d93e 100644 --- a/factory/README.md +++ b/foundry/README.md @@ -1,8 +1,8 @@ -# OpenHandoff +# Foundry -TypeScript workspace handoff system powered by RivetKit actors, SQLite/Drizzle state, and OpenTUI. +TypeScript workspace task system powered by RivetKit actors, SQLite/Drizzle state, and OpenTUI. -**Documentation**: [openhandoff.dev](https://openhandoff.dev) +**Documentation**: see `../docs/` in the repository root ## Quick Install diff --git a/foundry/compose.dev.yaml b/foundry/compose.dev.yaml new file mode 100644 index 0000000..43ec998 --- /dev/null +++ b/foundry/compose.dev.yaml @@ -0,0 +1,90 @@ +name: foundry + +services: + backend: + build: + context: .. + dockerfile: foundry/docker/backend.dev.Dockerfile + image: foundry-backend-dev + working_dir: /app + environment: + HF_BACKEND_HOST: "0.0.0.0" + HF_BACKEND_PORT: "7741" + HF_RIVET_MANAGER_PORT: "8750" + RIVETKIT_STORAGE_PATH: "/root/.local/share/foundry/rivetkit" + # Pass through credentials needed for agent execution + PR creation in dev/e2e. + # Do not hardcode secrets; set these in your environment when starting compose. + ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}" + CLAUDE_API_KEY: "${CLAUDE_API_KEY:-${ANTHROPIC_API_KEY:-}}" + OPENAI_API_KEY: "${OPENAI_API_KEY:-}" + # sandbox-agent codex plugin currently expects CODEX_API_KEY. Map from OPENAI_API_KEY for convenience. + CODEX_API_KEY: "${CODEX_API_KEY:-${OPENAI_API_KEY:-}}" + # Support either GITHUB_TOKEN or GITHUB_PAT in local env files. + GITHUB_TOKEN: "${GITHUB_TOKEN:-${GITHUB_PAT:-}}" + GH_TOKEN: "${GH_TOKEN:-${GITHUB_TOKEN:-${GITHUB_PAT:-}}}" + DAYTONA_ENDPOINT: "${DAYTONA_ENDPOINT:-}" + DAYTONA_API_KEY: "${DAYTONA_API_KEY:-}" + HF_DAYTONA_ENDPOINT: "${HF_DAYTONA_ENDPOINT:-}" + HF_DAYTONA_API_KEY: "${HF_DAYTONA_API_KEY:-}" + ports: + - "7741:7741" + # RivetKit manager (used by browser clients after /api/rivet metadata redirect in dev) + - "8750:8750" + volumes: + - "..:/app" + # The linked RivetKit checkout resolves from Foundry packages to /task/rivet-checkout in-container. + - "../../../task/rivet-checkout:/task/rivet-checkout:ro" + # Reuse the host Codex auth profile for local sandbox-agent Codex sessions in dev. + - "${HOME}/.codex:/root/.codex" + # Keep backend dependency installs Linux-native instead of using host node_modules. + - "foundry_backend_root_node_modules:/app/node_modules" + - "foundry_backend_backend_node_modules:/app/foundry/packages/backend/node_modules" + - "foundry_backend_shared_node_modules:/app/foundry/packages/shared/node_modules" + - "foundry_backend_persist_rivet_node_modules:/app/sdks/persist-rivet/node_modules" + - "foundry_backend_typescript_node_modules:/app/sdks/typescript/node_modules" + - "foundry_backend_pnpm_store:/root/.local/share/pnpm/store" + # Persist backend-managed local git clones across container restarts. + - "foundry_git_repos:/root/.local/share/foundry/repos" + # Persist RivetKit local storage across container restarts. + - "foundry_rivetkit_storage:/root/.local/share/foundry/rivetkit" + + frontend: + build: + context: .. + dockerfile: foundry/docker/frontend.dev.Dockerfile + working_dir: /app + depends_on: + - backend + environment: + HOME: "/tmp" + HF_BACKEND_HTTP: "http://backend:7741" + ports: + - "4173:4173" + volumes: + - "..:/app" + # Ensure logs in .foundry/ persist on the host even if we change source mounts later. + - "./.foundry:/app/foundry/.foundry" + - "../../../task/rivet-checkout:/task/rivet-checkout:ro" + # Use Linux-native workspace dependencies inside the container instead of host node_modules. + - "foundry_node_modules:/app/node_modules" + - "foundry_client_node_modules:/app/foundry/packages/client/node_modules" + - "foundry_frontend_errors_node_modules:/app/foundry/packages/frontend-errors/node_modules" + - "foundry_frontend_node_modules:/app/foundry/packages/frontend/node_modules" + - "foundry_shared_node_modules:/app/foundry/packages/shared/node_modules" + - "foundry_pnpm_store:/tmp/.local/share/pnpm/store" + +volumes: + foundry_backend_root_node_modules: {} + foundry_backend_backend_node_modules: {} + foundry_backend_shared_node_modules: {} + foundry_backend_persist_rivet_node_modules: {} + foundry_backend_typescript_node_modules: {} + foundry_backend_pnpm_store: {} + foundry_git_repos: {} + foundry_rivetkit_storage: {} + foundry_node_modules: {} + foundry_client_node_modules: {} + foundry_frontend_errors_node_modules: {} + foundry_frontend_node_modules: {} + foundry_shared_node_modules: {} + foundry_pnpm_store: {} diff --git a/factory/compose.preview.yaml b/foundry/compose.preview.yaml similarity index 63% rename from factory/compose.preview.yaml rename to foundry/compose.preview.yaml index 88cdad3..e13f5e1 100644 --- a/factory/compose.preview.yaml +++ b/foundry/compose.preview.yaml @@ -1,16 +1,16 @@ -name: openhandoff-preview +name: foundry-preview services: backend: build: context: .. - dockerfile: quebec/docker/backend.preview.Dockerfile - image: openhandoff-backend-preview + dockerfile: foundry/docker/backend.preview.Dockerfile + image: foundry-backend-preview environment: HF_BACKEND_HOST: "0.0.0.0" HF_BACKEND_PORT: "7841" HF_RIVET_MANAGER_PORT: "8850" - RIVETKIT_STORAGE_PATH: "/root/.local/share/openhandoff/rivetkit" + RIVETKIT_STORAGE_PATH: "/root/.local/share/foundry/rivetkit" ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}" CLAUDE_API_KEY: "${CLAUDE_API_KEY:-${ANTHROPIC_API_KEY:-}}" OPENAI_API_KEY: "${OPENAI_API_KEY:-}" @@ -26,19 +26,19 @@ services: - "8850:8850" volumes: - "${HOME}/.codex:/root/.codex" - - "openhandoff_preview_git_repos:/root/.local/share/openhandoff/repos" - - "openhandoff_preview_rivetkit_storage:/root/.local/share/openhandoff/rivetkit" + - "foundry_preview_git_repos:/root/.local/share/foundry/repos" + - "foundry_preview_rivetkit_storage:/root/.local/share/foundry/rivetkit" frontend: build: context: .. - dockerfile: quebec/docker/frontend.preview.Dockerfile - image: openhandoff-frontend-preview + dockerfile: foundry/docker/frontend.preview.Dockerfile + image: foundry-frontend-preview depends_on: - backend ports: - "4273:4273" volumes: - openhandoff_preview_git_repos: {} - openhandoff_preview_rivetkit_storage: {} + foundry_preview_git_repos: {} + foundry_preview_rivetkit_storage: {} diff --git a/factory/docker/backend.dev.Dockerfile b/foundry/docker/backend.dev.Dockerfile similarity index 90% rename from factory/docker/backend.dev.Dockerfile rename to foundry/docker/backend.dev.Dockerfile index a53e018..3a0697d 100644 --- a/factory/docker/backend.dev.Dockerfile +++ b/foundry/docker/backend.dev.Dockerfile @@ -39,4 +39,4 @@ ENV SANDBOX_AGENT_BIN="/root/.local/bin/sandbox-agent" WORKDIR /app -CMD ["bash", "-lc", "git config --global --add safe.directory /app >/dev/null 2>&1 || true; pnpm install --force --frozen-lockfile --filter @openhandoff/backend... && exec bun factory/packages/backend/src/index.ts start --host 0.0.0.0 --port 7741"] +CMD ["bash", "-lc", "git config --global --add safe.directory /app >/dev/null 2>&1 || true; pnpm install --force --frozen-lockfile --filter @sandbox-agent/foundry-backend... && exec bun foundry/packages/backend/src/index.ts start --host 0.0.0.0 --port 7741"] diff --git a/factory/docker/backend.preview.Dockerfile b/foundry/docker/backend.preview.Dockerfile similarity index 90% rename from factory/docker/backend.preview.Dockerfile rename to foundry/docker/backend.preview.Dockerfile index 3ea5aa8..8c30ae0 100644 --- a/factory/docker/backend.preview.Dockerfile +++ b/foundry/docker/backend.preview.Dockerfile @@ -42,8 +42,8 @@ COPY quebec /workspace/quebec COPY rivet-checkout /workspace/rivet-checkout RUN pnpm install --frozen-lockfile -RUN pnpm --filter @openhandoff/shared build -RUN pnpm --filter @openhandoff/client build -RUN pnpm --filter @openhandoff/backend build +RUN pnpm --filter @sandbox-agent/foundry-shared build +RUN pnpm --filter @sandbox-agent/foundry-client build +RUN pnpm --filter @sandbox-agent/foundry-backend build CMD ["bash", "-lc", "git config --global --add safe.directory /workspace/quebec >/dev/null 2>&1 || true; exec bun packages/backend/dist/index.js start --host 0.0.0.0 --port 7841"] diff --git a/factory/docker/frontend.dev.Dockerfile b/foundry/docker/frontend.dev.Dockerfile similarity index 72% rename from factory/docker/frontend.dev.Dockerfile rename to foundry/docker/frontend.dev.Dockerfile index 057b88d..3b0d8e4 100644 --- a/factory/docker/frontend.dev.Dockerfile +++ b/foundry/docker/frontend.dev.Dockerfile @@ -8,4 +8,4 @@ RUN npm install -g pnpm@10.28.2 WORKDIR /app -CMD ["bash", "-lc", "pnpm install --force --frozen-lockfile --filter @openhandoff/frontend... && cd factory/packages/frontend && exec pnpm vite --host 0.0.0.0 --port 4173"] +CMD ["bash", "-lc", "pnpm install --force --frozen-lockfile --filter @sandbox-agent/foundry-frontend... && cd foundry/packages/frontend && exec pnpm vite --host 0.0.0.0 --port 4173"] diff --git a/factory/docker/frontend.preview.Dockerfile b/foundry/docker/frontend.preview.Dockerfile similarity index 65% rename from factory/docker/frontend.preview.Dockerfile rename to foundry/docker/frontend.preview.Dockerfile index 7f90b2a..68f400b 100644 --- a/factory/docker/frontend.preview.Dockerfile +++ b/foundry/docker/frontend.preview.Dockerfile @@ -10,10 +10,10 @@ COPY quebec /workspace/quebec COPY rivet-checkout /workspace/rivet-checkout RUN pnpm install --frozen-lockfile -RUN pnpm --filter @openhandoff/shared build -RUN pnpm --filter @openhandoff/client build -RUN pnpm --filter @openhandoff/frontend-errors build -RUN pnpm --filter @openhandoff/frontend build +RUN pnpm --filter @sandbox-agent/foundry-shared build +RUN pnpm --filter @sandbox-agent/foundry-client build +RUN pnpm --filter @sandbox-agent/foundry-frontend-errors build +RUN pnpm --filter @sandbox-agent/foundry-frontend build FROM nginx:1.27-alpine diff --git a/factory/docker/nginx.preview.conf b/foundry/docker/nginx.preview.conf similarity index 100% rename from factory/docker/nginx.preview.conf rename to foundry/docker/nginx.preview.conf diff --git a/factory/e2e/wb-mmilw7yh.txt b/foundry/e2e/wb-mmilw7yh.txt similarity index 100% rename from factory/e2e/wb-mmilw7yh.txt rename to foundry/e2e/wb-mmilw7yh.txt diff --git a/factory/e2e/wb-mmilzdwf.txt b/foundry/e2e/wb-mmilzdwf.txt similarity index 100% rename from factory/e2e/wb-mmilzdwf.txt rename to foundry/e2e/wb-mmilzdwf.txt diff --git a/factory/factory-cloud.md b/foundry/foundry-cloud.md similarity index 72% rename from factory/factory-cloud.md rename to foundry/foundry-cloud.md index 8fb1a10..5846f7d 100644 --- a/factory/factory-cloud.md +++ b/foundry/foundry-cloud.md @@ -1,4 +1,4 @@ -# Factory Cloud +# Foundry Cloud ## Mock Server @@ -8,5 +8,5 @@ A detached `tmux` session is acceptable for this. Example: ```bash tmux new-session -d -s mock-ui-4180 \ - 'cd /Users/nathan/conductor/workspaces/sandbox-agent/provo && OPENHANDOFF_FRONTEND_CLIENT_MODE=mock pnpm --filter @openhandoff/frontend exec vite --host localhost --port 4180' + 'cd /Users/nathan/conductor/workspaces/sandbox-agent/provo && FOUNDRY_FRONTEND_CLIENT_MODE=mock pnpm --filter @sandbox-agent/foundry-frontend exec vite --host localhost --port 4180' ``` diff --git a/factory/memory/roadmap.md b/foundry/memory/roadmap.md similarity index 95% rename from factory/memory/roadmap.md rename to foundry/memory/roadmap.md index 02f47ce..1d0032d 100644 --- a/factory/memory/roadmap.md +++ b/foundry/memory/roadmap.md @@ -7,7 +7,7 @@ ### claude code/opencode -1. "handoff this task to do xxxx" +1. "task this task to do xxxx" 2. ask clarifying questions 3. works in background (attach opencode session with `hf attach` and switch to session with `hf switch`) 4. automatically submits draft pr (if configured) @@ -62,7 +62,7 @@ - model (for the agent) - todo list & plan management -> with simplenote sync - sqlite (global) -- list of all global handoff repos +- list of all global task repos - heartbeat status to tell openclaw what it needs to send you - sandbox agent sdk support - serve command to run server @@ -78,5 +78,5 @@ - automatically uses your opencode theme - auto symlink target/node_modules/etc -- auto-archives handoffs when closed +- auto-archives tasks when closed - shows agent status in the tmux window name diff --git a/factory/packages/backend/CLAUDE.md b/foundry/packages/backend/CLAUDE.md similarity index 69% rename from factory/packages/backend/CLAUDE.md rename to foundry/packages/backend/CLAUDE.md index b270332..ce0b87f 100644 --- a/factory/packages/backend/CLAUDE.md +++ b/foundry/packages/backend/CLAUDE.md @@ -10,10 +10,10 @@ WorkspaceActor ├─ ProjectActor(repo) │ ├─ ProjectBranchSyncActor │ ├─ ProjectPrSyncActor -│ └─ HandoffActor(handoff) -│ ├─ HandoffSessionActor(session) × N +│ └─ TaskActor(task) +│ ├─ TaskSessionActor(session) × N │ │ └─ SessionStatusSyncActor(session) × 0..1 -│ └─ Handoff-local workbench state +│ └─ Task-local workbench state └─ SandboxInstanceActor(providerId, sandboxId) × N ``` @@ -22,12 +22,12 @@ WorkspaceActor - `WorkspaceActor` is the workspace coordinator and lookup/index owner. - `HistoryActor` is workspace-scoped. There is one workspace-level history feed. - `ProjectActor` is the repo coordinator and owns repo-local caches/indexes. -- `HandoffActor` is one branch. Treat `1 handoff = 1 branch` once branch assignment is finalized. -- `HandoffActor` can have many sessions. -- `HandoffActor` can reference many sandbox instances historically, but should have only one active sandbox/session at a time. +- `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. -- `SandboxInstanceActor` stays separate from `HandoffActor`; handoffs/sessions reference it by identity. +- `SandboxInstanceActor` stays separate from `TaskActor`; tasks/sessions reference it by identity. - Sync actors are polling workers only. They feed parent actors and should not become the source of truth. ## Maintenance diff --git a/factory/packages/backend/package.json b/foundry/packages/backend/package.json similarity index 86% rename from factory/packages/backend/package.json rename to foundry/packages/backend/package.json index 26ed214..6cb03ce 100644 --- a/factory/packages/backend/package.json +++ b/foundry/packages/backend/package.json @@ -1,12 +1,12 @@ { - "name": "@openhandoff/backend", + "name": "@sandbox-agent/foundry-backend", "version": "0.1.0", "private": true, "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", "scripts": { - "build": "tsup src/index.ts --format esm --external bun:sqlite", + "build": "tsup src/index.ts --format esm", "db:generate": "find src/actors -name drizzle.config.ts -exec pnpm exec drizzle-kit generate --config {} \\; && \"$HOME/.bun/bin/bun\" src/actors/_scripts/generate-actor-migrations.ts", "typecheck": "tsc --noEmit", "test": "$HOME/.bun/bin/bun x vitest run", @@ -17,7 +17,7 @@ "@hono/node-server": "^1.19.7", "@hono/node-ws": "^1.3.0", "@iarna/toml": "^2.2.5", - "@openhandoff/shared": "workspace:*", + "@sandbox-agent/foundry-shared": "workspace:*", "@sandbox-agent/persist-rivet": "workspace:*", "drizzle-orm": "^0.44.5", "hono": "^4.11.9", diff --git a/factory/packages/backend/src/actors/_scripts/generate-actor-migrations.ts b/foundry/packages/backend/src/actors/_scripts/generate-actor-migrations.ts similarity index 100% rename from factory/packages/backend/src/actors/_scripts/generate-actor-migrations.ts rename to foundry/packages/backend/src/actors/_scripts/generate-actor-migrations.ts diff --git a/factory/packages/backend/src/actors/context.ts b/foundry/packages/backend/src/actors/context.ts similarity index 63% rename from factory/packages/backend/src/actors/context.ts rename to foundry/packages/backend/src/actors/context.ts index b51a6b2..1c03ce2 100644 --- a/factory/packages/backend/src/actors/context.ts +++ b/foundry/packages/backend/src/actors/context.ts @@ -1,18 +1,27 @@ -import type { AppConfig } from "@openhandoff/shared"; +import type { AppConfig } from "@sandbox-agent/foundry-shared"; import type { BackendDriver } from "../driver.js"; import type { NotificationService } from "../notifications/index.js"; import type { ProviderRegistry } from "../providers/index.js"; +import type { AppShellServices } from "../services/app-shell-runtime.js"; let runtimeConfig: AppConfig | null = null; let providerRegistry: ProviderRegistry | null = null; let notificationService: NotificationService | null = null; let runtimeDriver: BackendDriver | null = null; +let appShellServices: AppShellServices | null = null; -export function initActorRuntimeContext(config: AppConfig, providers: ProviderRegistry, notifications?: NotificationService, driver?: BackendDriver): void { +export function initActorRuntimeContext( + config: AppConfig, + providers: ProviderRegistry, + notifications?: NotificationService, + driver?: BackendDriver, + appShell?: AppShellServices, +): void { runtimeConfig = config; providerRegistry = providers; notificationService = notifications ?? null; runtimeDriver = driver ?? null; + appShellServices = appShell ?? null; } export function getActorRuntimeContext(): { @@ -20,6 +29,7 @@ export function getActorRuntimeContext(): { providers: ProviderRegistry; notifications: NotificationService | null; driver: BackendDriver; + appShell: AppShellServices; } { if (!runtimeConfig || !providerRegistry) { throw new Error("Actor runtime context not initialized"); @@ -29,10 +39,15 @@ export function getActorRuntimeContext(): { throw new Error("Actor runtime context missing driver"); } + if (!appShellServices) { + throw new Error("Actor runtime context missing app shell services"); + } + return { config: runtimeConfig, providers: providerRegistry, notifications: notificationService, driver: runtimeDriver, + appShell: appShellServices, }; } diff --git a/factory/packages/backend/src/actors/events.ts b/foundry/packages/backend/src/actors/events.ts similarity index 68% rename from factory/packages/backend/src/actors/events.ts rename to foundry/packages/backend/src/actors/events.ts index 8f9ea28..8872dfa 100644 --- a/factory/packages/backend/src/actors/events.ts +++ b/foundry/packages/backend/src/actors/events.ts @@ -1,19 +1,19 @@ -import type { HandoffStatus, ProviderId } from "@openhandoff/shared"; +import type { TaskStatus, ProviderId } from "@sandbox-agent/foundry-shared"; -export interface HandoffCreatedEvent { +export interface TaskCreatedEvent { workspaceId: string; repoId: string; - handoffId: string; + taskId: string; providerId: ProviderId; branchName: string; title: string; } -export interface HandoffStatusEvent { +export interface TaskStatusEvent { workspaceId: string; repoId: string; - handoffId: string; - status: HandoffStatus; + taskId: string; + status: TaskStatus; message: string; } @@ -26,28 +26,28 @@ export interface ProjectSnapshotEvent { export interface AgentStartedEvent { workspaceId: string; repoId: string; - handoffId: string; + taskId: string; sessionId: string; } export interface AgentIdleEvent { workspaceId: string; repoId: string; - handoffId: string; + taskId: string; sessionId: string; } export interface AgentErrorEvent { workspaceId: string; repoId: string; - handoffId: string; + taskId: string; message: string; } export interface PrCreatedEvent { workspaceId: string; repoId: string; - handoffId: string; + taskId: string; prNumber: number; url: string; } @@ -55,7 +55,7 @@ export interface PrCreatedEvent { export interface PrClosedEvent { workspaceId: string; repoId: string; - handoffId: string; + taskId: string; prNumber: number; merged: boolean; } @@ -63,7 +63,7 @@ export interface PrClosedEvent { export interface PrReviewEvent { workspaceId: string; repoId: string; - handoffId: string; + taskId: string; prNumber: number; reviewer: string; status: string; @@ -72,41 +72,41 @@ export interface PrReviewEvent { export interface CiStatusChangedEvent { workspaceId: string; repoId: string; - handoffId: string; + taskId: string; prNumber: number; status: string; } -export type HandoffStepName = "auto_commit" | "push" | "pr_submit"; -export type HandoffStepStatus = "started" | "completed" | "skipped" | "failed"; +export type TaskStepName = "auto_commit" | "push" | "pr_submit"; +export type TaskStepStatus = "started" | "completed" | "skipped" | "failed"; -export interface HandoffStepEvent { +export interface TaskStepEvent { workspaceId: string; repoId: string; - handoffId: string; - step: HandoffStepName; - status: HandoffStepStatus; + taskId: string; + step: TaskStepName; + status: TaskStepStatus; message: string; } export interface BranchSwitchedEvent { workspaceId: string; repoId: string; - handoffId: string; + taskId: string; branchName: string; } export interface SessionAttachedEvent { workspaceId: string; repoId: string; - handoffId: string; + taskId: string; sessionId: string; } export interface BranchSyncedEvent { workspaceId: string; repoId: string; - handoffId: string; + taskId: string; branchName: string; strategy: string; } diff --git a/factory/packages/backend/src/actors/handles.ts b/foundry/packages/backend/src/actors/handles.ts similarity index 73% rename from factory/packages/backend/src/actors/handles.ts rename to foundry/packages/backend/src/actors/handles.ts index 8e8aec7..228ce8c 100644 --- a/factory/packages/backend/src/actors/handles.ts +++ b/foundry/packages/backend/src/actors/handles.ts @@ -1,5 +1,5 @@ -import { handoffKey, handoffStatusSyncKey, historyKey, projectBranchSyncKey, projectKey, projectPrSyncKey, sandboxInstanceKey, workspaceKey } from "./keys.js"; -import type { ProviderId } from "@openhandoff/shared"; +import { taskKey, taskStatusSyncKey, historyKey, projectBranchSyncKey, projectKey, projectPrSyncKey, sandboxInstanceKey, workspaceKey } from "./keys.js"; +import type { ProviderId } from "@sandbox-agent/foundry-shared"; export function actorClient(c: any) { return c.client(); @@ -25,12 +25,12 @@ export function getProject(c: any, workspaceId: string, repoId: string) { return actorClient(c).project.get(projectKey(workspaceId, repoId)); } -export function getHandoff(c: any, workspaceId: string, repoId: string, handoffId: string) { - return actorClient(c).handoff.get(handoffKey(workspaceId, repoId, handoffId)); +export function getTask(c: any, workspaceId: string, repoId: string, taskId: string) { + return actorClient(c).task.get(taskKey(workspaceId, repoId, taskId)); } -export async function getOrCreateHandoff(c: any, workspaceId: string, repoId: string, handoffId: string, createWithInput: Record) { - return await actorClient(c).handoff.getOrCreate(handoffKey(workspaceId, repoId, handoffId), { +export async function getOrCreateTask(c: any, workspaceId: string, repoId: string, taskId: string, createWithInput: Record) { + return await actorClient(c).task.getOrCreate(taskKey(workspaceId, repoId, taskId), { createWithInput, }); } @@ -80,16 +80,16 @@ export async function getOrCreateSandboxInstance( return await actorClient(c).sandboxInstance.getOrCreate(sandboxInstanceKey(workspaceId, providerId, sandboxId), { createWithInput }); } -export async function getOrCreateHandoffStatusSync( +export async function getOrCreateTaskStatusSync( c: any, workspaceId: string, repoId: string, - handoffId: string, + taskId: string, sandboxId: string, sessionId: string, createWithInput: Record, ) { - return await actorClient(c).handoffStatusSync.getOrCreate(handoffStatusSyncKey(workspaceId, repoId, handoffId, sandboxId, sessionId), { + return await actorClient(c).taskStatusSync.getOrCreate(taskStatusSyncKey(workspaceId, repoId, taskId, sandboxId, sessionId), { createWithInput, }); } @@ -102,16 +102,16 @@ export function selfProjectBranchSync(c: any) { return actorClient(c).projectBranchSync.getForId(c.actorId); } -export function selfHandoffStatusSync(c: any) { - return actorClient(c).handoffStatusSync.getForId(c.actorId); +export function selfTaskStatusSync(c: any) { + return actorClient(c).taskStatusSync.getForId(c.actorId); } export function selfHistory(c: any) { return actorClient(c).history.getForId(c.actorId); } -export function selfHandoff(c: any) { - return actorClient(c).handoff.getForId(c.actorId); +export function selfTask(c: any) { + return actorClient(c).task.getForId(c.actorId); } export function selfWorkspace(c: any) { diff --git a/foundry/packages/backend/src/actors/history/db/db.ts b/foundry/packages/backend/src/actors/history/db/db.ts new file mode 100644 index 0000000..ef76e36 --- /dev/null +++ b/foundry/packages/backend/src/actors/history/db/db.ts @@ -0,0 +1,5 @@ +import { db } from "rivetkit/db/drizzle"; +import * as schema from "./schema.js"; +import migrations from "./migrations.js"; + +export const historyDb = db({ schema, migrations }); diff --git a/factory/packages/backend/src/actors/history/db/drizzle.config.ts b/foundry/packages/backend/src/actors/history/db/drizzle.config.ts similarity index 100% rename from factory/packages/backend/src/actors/history/db/drizzle.config.ts rename to foundry/packages/backend/src/actors/history/db/drizzle.config.ts diff --git a/factory/packages/backend/src/actors/history/db/drizzle/0000_watery_bushwacker.sql b/foundry/packages/backend/src/actors/history/db/drizzle/0000_watery_bushwacker.sql similarity index 90% rename from factory/packages/backend/src/actors/history/db/drizzle/0000_watery_bushwacker.sql rename to foundry/packages/backend/src/actors/history/db/drizzle/0000_watery_bushwacker.sql index 961421c..697aac9 100644 --- a/factory/packages/backend/src/actors/history/db/drizzle/0000_watery_bushwacker.sql +++ b/foundry/packages/backend/src/actors/history/db/drizzle/0000_watery_bushwacker.sql @@ -1,6 +1,6 @@ CREATE TABLE `events` ( `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, - `handoff_id` text, + `task_id` text, `branch_name` text, `kind` text NOT NULL, `payload_json` text NOT NULL, diff --git a/factory/packages/backend/src/actors/history/db/drizzle/meta/0000_snapshot.json b/foundry/packages/backend/src/actors/history/db/drizzle/meta/0000_snapshot.json similarity index 96% rename from factory/packages/backend/src/actors/history/db/drizzle/meta/0000_snapshot.json rename to foundry/packages/backend/src/actors/history/db/drizzle/meta/0000_snapshot.json index 51fbd4b..802002d 100644 --- a/factory/packages/backend/src/actors/history/db/drizzle/meta/0000_snapshot.json +++ b/foundry/packages/backend/src/actors/history/db/drizzle/meta/0000_snapshot.json @@ -14,8 +14,8 @@ "notNull": true, "autoincrement": true }, - "handoff_id": { - "name": "handoff_id", + "task_id": { + "name": "task_id", "type": "text", "primaryKey": false, "notNull": false, diff --git a/factory/packages/backend/src/actors/history/db/drizzle/meta/_journal.json b/foundry/packages/backend/src/actors/history/db/drizzle/meta/_journal.json similarity index 100% rename from factory/packages/backend/src/actors/history/db/drizzle/meta/_journal.json rename to foundry/packages/backend/src/actors/history/db/drizzle/meta/_journal.json diff --git a/factory/packages/backend/src/actors/history/db/migrations.ts b/foundry/packages/backend/src/actors/history/db/migrations.ts similarity index 96% rename from factory/packages/backend/src/actors/history/db/migrations.ts rename to foundry/packages/backend/src/actors/history/db/migrations.ts index 05af26b..2e1b565 100644 --- a/factory/packages/backend/src/actors/history/db/migrations.ts +++ b/foundry/packages/backend/src/actors/history/db/migrations.ts @@ -18,7 +18,7 @@ export default { migrations: { m0000: `CREATE TABLE \`events\` ( \`id\` integer PRIMARY KEY AUTOINCREMENT NOT NULL, - \`handoff_id\` text, + \`task_id\` text, \`branch_name\` text, \`kind\` text NOT NULL, \`payload_json\` text NOT NULL, diff --git a/factory/packages/backend/src/actors/history/db/schema.ts b/foundry/packages/backend/src/actors/history/db/schema.ts similarity index 91% rename from factory/packages/backend/src/actors/history/db/schema.ts rename to foundry/packages/backend/src/actors/history/db/schema.ts index 1b8a5da..d015872 100644 --- a/factory/packages/backend/src/actors/history/db/schema.ts +++ b/foundry/packages/backend/src/actors/history/db/schema.ts @@ -2,7 +2,7 @@ import { integer, sqliteTable, text } from "rivetkit/db/drizzle"; export const events = sqliteTable("events", { id: integer("id").primaryKey({ autoIncrement: true }), - handoffId: text("handoff_id"), + taskId: text("task_id"), branchName: text("branch_name"), kind: text("kind").notNull(), payloadJson: text("payload_json").notNull(), diff --git a/factory/packages/backend/src/actors/history/index.ts b/foundry/packages/backend/src/actors/history/index.ts similarity index 91% rename from factory/packages/backend/src/actors/history/index.ts rename to foundry/packages/backend/src/actors/history/index.ts index a7f5637..a3d146b 100644 --- a/factory/packages/backend/src/actors/history/index.ts +++ b/foundry/packages/backend/src/actors/history/index.ts @@ -2,7 +2,7 @@ import { and, desc, eq } from "drizzle-orm"; import { actor, queue } from "rivetkit"; import { Loop, workflow } from "rivetkit/workflow"; -import type { HistoryEvent } from "@openhandoff/shared"; +import type { HistoryEvent } from "@sandbox-agent/foundry-shared"; import { selfHistory } from "../handles.js"; import { historyDb } from "./db/db.js"; import { events } from "./db/schema.js"; @@ -14,14 +14,14 @@ export interface HistoryInput { export interface AppendHistoryCommand { kind: string; - handoffId?: string; + taskId?: string; branchName?: string; payload: Record; } export interface ListHistoryParams { branch?: string; - handoffId?: string; + taskId?: string; limit?: number; } @@ -32,7 +32,7 @@ async function appendHistoryRow(loopCtx: any, body: AppendHistoryCommand): Promi await loopCtx.db .insert(events) .values({ - handoffId: body.handoffId ?? null, + taskId: body.taskId ?? null, branchName: body.branchName ?? null, kind: body.kind, payloadJson: JSON.stringify(body.payload), @@ -77,8 +77,8 @@ export const history = actor({ async list(c, params?: ListHistoryParams): Promise { const whereParts = []; - if (params?.handoffId) { - whereParts.push(eq(events.handoffId, params.handoffId)); + if (params?.taskId) { + whereParts.push(eq(events.taskId, params.taskId)); } if (params?.branch) { whereParts.push(eq(events.branchName, params.branch)); @@ -87,7 +87,7 @@ export const history = actor({ const base = c.db .select({ id: events.id, - handoffId: events.handoffId, + taskId: events.taskId, branchName: events.branchName, kind: events.kind, payloadJson: events.payloadJson, diff --git a/factory/packages/backend/src/actors/index.ts b/foundry/packages/backend/src/actors/index.ts similarity index 84% rename from factory/packages/backend/src/actors/index.ts rename to foundry/packages/backend/src/actors/index.ts index c551bad..0fdf8aa 100644 --- a/factory/packages/backend/src/actors/index.ts +++ b/foundry/packages/backend/src/actors/index.ts @@ -1,6 +1,6 @@ import { setup } from "rivetkit"; -import { handoffStatusSync } from "./handoff-status-sync/index.js"; -import { handoff } from "./handoff/index.js"; +import { taskStatusSync } from "./task-status-sync/index.js"; +import { task } from "./task/index.js"; import { history } from "./history/index.js"; import { projectBranchSync } from "./project-branch-sync/index.js"; import { projectPrSync } from "./project-pr-sync/index.js"; @@ -8,7 +8,7 @@ import { project } from "./project/index.js"; import { sandboxInstance } from "./sandbox-instance/index.js"; import { workspace } from "./workspace/index.js"; -function resolveManagerPort(): number { +export function resolveManagerPort(): number { const raw = process.env.HF_RIVET_MANAGER_PORT ?? process.env.RIVETKIT_MANAGER_PORT; if (!raw) { return 7750; @@ -30,12 +30,12 @@ export const registry = setup({ use: { workspace, project, - handoff, + task, sandboxInstance, history, projectPrSync, projectBranchSync, - handoffStatusSync, + taskStatusSync, }, managerPort: resolveManagerPort(), managerHost: resolveManagerHost(), @@ -43,8 +43,8 @@ export const registry = setup({ export * from "./context.js"; export * from "./events.js"; -export * from "./handoff-status-sync/index.js"; -export * from "./handoff/index.js"; +export * from "./task-status-sync/index.js"; +export * from "./task/index.js"; export * from "./history/index.js"; export * from "./keys.js"; export * from "./project-branch-sync/index.js"; diff --git a/factory/packages/backend/src/actors/keys.ts b/foundry/packages/backend/src/actors/keys.ts similarity index 68% rename from factory/packages/backend/src/actors/keys.ts rename to foundry/packages/backend/src/actors/keys.ts index ee89704..f6b210e 100644 --- a/factory/packages/backend/src/actors/keys.ts +++ b/foundry/packages/backend/src/actors/keys.ts @@ -8,8 +8,8 @@ export function projectKey(workspaceId: string, repoId: string): ActorKey { return ["ws", workspaceId, "project", repoId]; } -export function handoffKey(workspaceId: string, repoId: string, handoffId: string): ActorKey { - return ["ws", workspaceId, "project", repoId, "handoff", handoffId]; +export function taskKey(workspaceId: string, repoId: string, taskId: string): ActorKey { + return ["ws", workspaceId, "project", repoId, "task", taskId]; } export function sandboxInstanceKey(workspaceId: string, providerId: string, sandboxId: string): ActorKey { @@ -28,7 +28,7 @@ export function projectBranchSyncKey(workspaceId: string, repoId: string): Actor return ["ws", workspaceId, "project", repoId, "branch-sync"]; } -export function handoffStatusSyncKey(workspaceId: string, repoId: string, handoffId: string, sandboxId: string, sessionId: string): ActorKey { - // Include sandbox + session so multiple sandboxes/sessions can be tracked per handoff. - return ["ws", workspaceId, "project", repoId, "handoff", handoffId, "status-sync", sandboxId, sessionId]; +export function taskStatusSyncKey(workspaceId: string, repoId: string, taskId: string, sandboxId: string, sessionId: string): ActorKey { + // Include sandbox + session so multiple sandboxes/sessions can be tracked per task. + return ["ws", workspaceId, "project", repoId, "task", taskId, "status-sync", sandboxId, sessionId]; } diff --git a/factory/packages/backend/src/actors/logging.ts b/foundry/packages/backend/src/actors/logging.ts similarity index 92% rename from factory/packages/backend/src/actors/logging.ts rename to foundry/packages/backend/src/actors/logging.ts index f8ce224..da90350 100644 --- a/factory/packages/backend/src/actors/logging.ts +++ b/foundry/packages/backend/src/actors/logging.ts @@ -23,5 +23,5 @@ export function logActorWarning(scope: string, message: string, context?: Record ...(context ?? {}), }; // eslint-disable-next-line no-console - console.warn("[openhandoff][actor:warn]", payload); + console.warn("[foundry][actor:warn]", payload); } diff --git a/factory/packages/backend/src/actors/polling.ts b/foundry/packages/backend/src/actors/polling.ts similarity index 100% rename from factory/packages/backend/src/actors/polling.ts rename to foundry/packages/backend/src/actors/polling.ts diff --git a/factory/packages/backend/src/actors/project-branch-sync/index.ts b/foundry/packages/backend/src/actors/project-branch-sync/index.ts similarity index 100% rename from factory/packages/backend/src/actors/project-branch-sync/index.ts rename to foundry/packages/backend/src/actors/project-branch-sync/index.ts diff --git a/factory/packages/backend/src/actors/project-pr-sync/index.ts b/foundry/packages/backend/src/actors/project-pr-sync/index.ts similarity index 100% rename from factory/packages/backend/src/actors/project-pr-sync/index.ts rename to foundry/packages/backend/src/actors/project-pr-sync/index.ts diff --git a/factory/packages/backend/src/actors/project/actions.ts b/foundry/packages/backend/src/actors/project/actions.ts similarity index 76% rename from factory/packages/backend/src/actors/project/actions.ts rename to foundry/packages/backend/src/actors/project/actions.ts index 440ed06..23d41a8 100644 --- a/factory/packages/backend/src/actors/project/actions.ts +++ b/foundry/packages/backend/src/actors/project/actions.ts @@ -2,14 +2,14 @@ import { randomUUID } from "node:crypto"; import { and, desc, eq, isNotNull, ne } from "drizzle-orm"; import { Loop } from "rivetkit/workflow"; -import type { AgentType, HandoffRecord, HandoffSummary, ProviderId, RepoOverview, RepoStackAction, RepoStackActionResult } from "@openhandoff/shared"; +import type { AgentType, TaskRecord, TaskSummary, ProviderId, RepoOverview, RepoStackAction, RepoStackActionResult } from "@sandbox-agent/foundry-shared"; import { getActorRuntimeContext } from "../context.js"; -import { getHandoff, getOrCreateHandoff, getOrCreateHistory, getOrCreateProjectBranchSync, getOrCreateProjectPrSync, selfProject } from "../handles.js"; +import { getTask, getOrCreateTask, getOrCreateHistory, getOrCreateProjectBranchSync, getOrCreateProjectPrSync, selfProject } from "../handles.js"; import { isActorNotFoundError, logActorWarning, resolveErrorMessage } from "../logging.js"; -import { openhandoffRepoClonePath } from "../../services/openhandoff-paths.js"; +import { foundryRepoClonePath } from "../../services/foundry-paths.js"; import { expectQueueResponse } from "../../services/queue.js"; import { withRepoGitLock } from "../../services/repo-git-lock.js"; -import { branches, handoffIndex, prCache, repoMeta } from "./db/schema.js"; +import { branches, taskIndex, prCache, repoMeta } from "./db/schema.js"; import { deriveFallbackTitle } from "../../services/create-flow.js"; import { normalizeBaseBranchName } from "../../integrations/git-spice/index.js"; import { sortBranchesForOverview } from "./stack-model.js"; @@ -22,7 +22,7 @@ interface EnsureProjectResult { localPath: string; } -interface CreateHandoffCommand { +interface CreateTaskCommand { task: string; providerId: ProviderId; agentType: AgentType | null; @@ -32,22 +32,22 @@ interface CreateHandoffCommand { onBranch: string | null; } -interface HydrateHandoffIndexCommand {} +interface HydrateTaskIndexCommand {} interface ListReservedBranchesCommand {} -interface RegisterHandoffBranchCommand { - handoffId: string; +interface RegisterTaskBranchCommand { + taskId: string; branchName: string; requireExistingRemote?: boolean; } -interface ListHandoffSummariesCommand { +interface ListTaskSummariesCommand { includeArchived?: boolean; } -interface GetHandoffEnrichedCommand { - handoffId: string; +interface GetTaskEnrichedCommand { + taskId: string; } interface GetPullRequestForBranchCommand { @@ -93,9 +93,9 @@ interface RunRepoStackActionCommand { const PROJECT_QUEUE_NAMES = [ "project.command.ensure", - "project.command.hydrateHandoffIndex", - "project.command.createHandoff", - "project.command.registerHandoffBranch", + "project.command.hydrateTaskIndex", + "project.command.createTask", + "project.command.registerTaskBranch", "project.command.runRepoStackAction", "project.command.applyPrSyncResult", "project.command.applyBranchSyncResult", @@ -111,7 +111,7 @@ export function projectWorkflowQueueName(name: ProjectQueueName): ProjectQueueNa async function ensureLocalClone(c: any, remoteUrl: string): Promise { const { config, driver } = getActorRuntimeContext(); - const localPath = openhandoffRepoClonePath(config, c.state.workspaceId, c.state.repoId); + const localPath = foundryRepoClonePath(config, c.state.workspaceId, c.state.repoId); await driver.git.ensureCloned(remoteUrl, localPath); c.state.localPath = localPath; return localPath; @@ -131,59 +131,59 @@ async function ensureProjectSyncActors(c: any, localPath: string): Promise c.state.syncActorsStarted = true; } -async function deleteStaleHandoffIndexRow(c: any, handoffId: string): Promise { +async function deleteStaleTaskIndexRow(c: any, taskId: string): Promise { try { - await c.db.delete(handoffIndex).where(eq(handoffIndex.handoffId, handoffId)).run(); + await c.db.delete(taskIndex).where(eq(taskIndex.taskId, taskId)).run(); } catch { // Best-effort cleanup only; preserve the original caller flow. } } -function isStaleHandoffReferenceError(error: unknown): boolean { +function isStaleTaskReferenceError(error: unknown): boolean { const message = resolveErrorMessage(error); - return isActorNotFoundError(error) || message.startsWith("Handoff not found:"); + return isActorNotFoundError(error) || message.startsWith("Task not found:"); } -async function ensureHandoffIndexHydrated(c: any): Promise { - if (c.state.handoffIndexHydrated) { +async function ensureTaskIndexHydrated(c: any): Promise { + if (c.state.taskIndexHydrated) { return; } - const existing = await c.db.select({ handoffId: handoffIndex.handoffId }).from(handoffIndex).limit(1).get(); + const existing = await c.db.select({ taskId: taskIndex.taskId }).from(taskIndex).limit(1).get(); if (existing) { - c.state.handoffIndexHydrated = true; + c.state.taskIndexHydrated = true; return; } - // Migration path for old project actors that only tracked handoffs in history. + // Migration path for old project actors that only tracked tasks in history. try { const history = await getOrCreateHistory(c, c.state.workspaceId, c.state.repoId); const rows = await history.list({ limit: 5_000 }); const seen = new Set(); - let skippedMissingHandoffActors = 0; + let skippedMissingTaskActors = 0; for (const row of rows) { - if (!row.handoffId || seen.has(row.handoffId)) { + if (!row.taskId || seen.has(row.taskId)) { continue; } - seen.add(row.handoffId); + seen.add(row.taskId); try { - const h = getHandoff(c, c.state.workspaceId, c.state.repoId, row.handoffId); + const h = getTask(c, c.state.workspaceId, c.state.repoId, row.taskId); await h.get(); } catch (error) { - if (isStaleHandoffReferenceError(error)) { - skippedMissingHandoffActors += 1; + if (isStaleTaskReferenceError(error)) { + skippedMissingTaskActors += 1; continue; } throw error; } await c.db - .insert(handoffIndex) + .insert(taskIndex) .values({ - handoffId: row.handoffId, + taskId: row.taskId, branchName: row.branchName, createdAt: row.createdAt, updatedAt: row.createdAt, @@ -192,22 +192,22 @@ async function ensureHandoffIndexHydrated(c: any): Promise { .run(); } - if (skippedMissingHandoffActors > 0) { - logActorWarning("project", "skipped missing handoffs while hydrating index", { + if (skippedMissingTaskActors > 0) { + logActorWarning("project", "skipped missing tasks while hydrating index", { workspaceId: c.state.workspaceId, repoId: c.state.repoId, - skippedMissingHandoffActors, + skippedMissingTaskActors, }); } } catch (error) { - logActorWarning("project", "handoff index hydration from history failed", { + logActorWarning("project", "task index hydration from history failed", { workspaceId: c.state.workspaceId, repoId: c.state.repoId, error: resolveErrorMessage(error), }); } - c.state.handoffIndexHydrated = true; + c.state.taskIndexHydrated = true; } async function ensureProjectReady(c: any): Promise { @@ -241,11 +241,11 @@ async function ensureProjectReadyForRead(c: any): Promise { return c.state.localPath; } -async function ensureHandoffIndexHydratedForRead(c: any): Promise { - if (c.state.handoffIndexHydrated) { +async function ensureTaskIndexHydratedForRead(c: any): Promise { + if (c.state.taskIndexHydrated) { return; } - await projectActions.hydrateHandoffIndex(c, {}); + await projectActions.hydrateTaskIndex(c, {}); } async function forceProjectSync(c: any, localPath: string): Promise { @@ -256,7 +256,7 @@ async function forceProjectSync(c: any, localPath: string): Promise { await branchSync.force(); } -async function enrichHandoffRecord(c: any, record: HandoffRecord): Promise { +async function enrichTaskRecord(c: any, record: TaskRecord): Promise { const branchName = record.branchName; const br = branchName != null @@ -325,16 +325,16 @@ async function ensureProjectMutation(c: any, cmd: EnsureProjectCommand): Promise return { localPath }; } -async function hydrateHandoffIndexMutation(c: any, _cmd?: HydrateHandoffIndexCommand): Promise { - await ensureHandoffIndexHydrated(c); +async function hydrateTaskIndexMutation(c: any, _cmd?: HydrateTaskIndexCommand): Promise { + await ensureTaskIndexHydrated(c); } -async function createHandoffMutation(c: any, cmd: CreateHandoffCommand): Promise { +async function createTaskMutation(c: any, cmd: CreateTaskCommand): Promise { const localPath = await ensureProjectReady(c); const onBranch = cmd.onBranch?.trim() || null; const initialBranchName = onBranch; const initialTitle = onBranch ? deriveFallbackTitle(cmd.task, cmd.explicitTitle ?? undefined) : null; - const handoffId = randomUUID(); + const taskId = randomUUID(); if (onBranch) { await forceProjectSync(c, localPath); @@ -344,19 +344,19 @@ async function createHandoffMutation(c: any, cmd: CreateHandoffCommand): Promise throw new Error(`Branch not found in repo snapshot: ${onBranch}`); } - await registerHandoffBranchMutation(c, { - handoffId, + await registerTaskBranchMutation(c, { + taskId, branchName: onBranch, requireExistingRemote: true, }); } - let handoff: Awaited>; + let task: Awaited>; try { - handoff = await getOrCreateHandoff(c, c.state.workspaceId, c.state.repoId, handoffId, { + task = await getOrCreateTask(c, c.state.workspaceId, c.state.repoId, taskId, { workspaceId: c.state.workspaceId, repoId: c.state.repoId, - handoffId, + taskId, repoRemote: c.state.remoteUrl, repoLocalPath: localPath, branchName: initialBranchName, @@ -371,8 +371,8 @@ async function createHandoffMutation(c: any, cmd: CreateHandoffCommand): Promise } catch (error) { if (onBranch) { await c.db - .delete(handoffIndex) - .where(eq(handoffIndex.handoffId, handoffId)) + .delete(taskIndex) + .where(eq(taskIndex.taskId, taskId)) .run() .catch(() => {}); } @@ -382,9 +382,9 @@ async function createHandoffMutation(c: any, cmd: CreateHandoffCommand): Promise if (!onBranch) { const now = Date.now(); await c.db - .insert(handoffIndex) + .insert(taskIndex) .values({ - handoffId, + taskId, branchName: initialBranchName, createdAt: now, updatedAt: now, @@ -393,12 +393,12 @@ async function createHandoffMutation(c: any, cmd: CreateHandoffCommand): Promise .run(); } - const created = await handoff.initialize({ providerId: cmd.providerId }); + const created = await task.initialize({ providerId: cmd.providerId }); const history = await getOrCreateHistory(c, c.state.workspaceId, c.state.repoId); await history.append({ - kind: "handoff.created", - handoffId, + kind: "task.created", + taskId, payload: { repoId: c.state.repoId, providerId: cmd.providerId, @@ -408,7 +408,7 @@ async function createHandoffMutation(c: any, cmd: CreateHandoffCommand): Promise return created; } -async function registerHandoffBranchMutation(c: any, cmd: RegisterHandoffBranchCommand): Promise<{ branchName: string; headSha: string }> { +async function registerTaskBranchMutation(c: any, cmd: RegisterTaskBranchCommand): Promise<{ branchName: string; headSha: string }> { const localPath = await ensureProjectReady(c); const branchName = cmd.branchName.trim(); @@ -417,27 +417,27 @@ async function registerHandoffBranchMutation(c: any, cmd: RegisterHandoffBranchC throw new Error("branchName is required"); } - await ensureHandoffIndexHydrated(c); + await ensureTaskIndexHydrated(c); const existingOwner = await c.db - .select({ handoffId: handoffIndex.handoffId }) - .from(handoffIndex) - .where(and(eq(handoffIndex.branchName, branchName), ne(handoffIndex.handoffId, cmd.handoffId))) + .select({ taskId: taskIndex.taskId }) + .from(taskIndex) + .where(and(eq(taskIndex.branchName, branchName), ne(taskIndex.taskId, cmd.taskId))) .get(); if (existingOwner) { let ownerMissing = false; try { - const h = getHandoff(c, c.state.workspaceId, c.state.repoId, existingOwner.handoffId); + const h = getTask(c, c.state.workspaceId, c.state.repoId, existingOwner.taskId); await h.get(); } catch (error) { - if (isStaleHandoffReferenceError(error)) { + if (isStaleTaskReferenceError(error)) { ownerMissing = true; - await deleteStaleHandoffIndexRow(c, existingOwner.handoffId); - logActorWarning("project", "pruned stale handoff index row during branch registration", { + await deleteStaleTaskIndexRow(c, existingOwner.taskId); + logActorWarning("project", "pruned stale task index row during branch registration", { workspaceId: c.state.workspaceId, repoId: c.state.repoId, - handoffId: existingOwner.handoffId, + taskId: existingOwner.taskId, branchName, }); } else { @@ -445,7 +445,7 @@ async function registerHandoffBranchMutation(c: any, cmd: RegisterHandoffBranchC } } if (!ownerMissing) { - throw new Error(`branch is already assigned to a different handoff: ${branchName}`); + throw new Error(`branch is already assigned to a different task: ${branchName}`); } } @@ -525,15 +525,15 @@ async function registerHandoffBranchMutation(c: any, cmd: RegisterHandoffBranchC .run(); await c.db - .insert(handoffIndex) + .insert(taskIndex) .values({ - handoffId: cmd.handoffId, + taskId: cmd.taskId, branchName, createdAt: now, updatedAt: now, }) .onConflictDoUpdate({ - target: handoffIndex.handoffId, + target: taskIndex.taskId, set: { branchName, updatedAt: now, @@ -546,7 +546,7 @@ async function registerHandoffBranchMutation(c: any, cmd: RegisterHandoffBranchC async function runRepoStackActionMutation(c: any, cmd: RunRepoStackActionCommand): Promise { const localPath = await ensureProjectReady(c); - await ensureHandoffIndexHydrated(c); + await ensureTaskIndexHydrated(c); const { driver } = getActorRuntimeContext(); const at = Date.now(); @@ -682,30 +682,30 @@ async function applyPrSyncResultMutation(c: any, body: PrSyncResult): Promise { return Loop.continue(undefined); } - if (msg.name === "project.command.hydrateHandoffIndex") { - await loopCtx.step("project-hydrate-handoff-index", async () => hydrateHandoffIndexMutation(loopCtx, msg.body as HydrateHandoffIndexCommand)); + if (msg.name === "project.command.hydrateTaskIndex") { + await loopCtx.step("project-hydrate-task-index", async () => hydrateTaskIndexMutation(loopCtx, msg.body as HydrateTaskIndexCommand)); await msg.complete({ ok: true }); return Loop.continue(undefined); } - if (msg.name === "project.command.createHandoff") { + if (msg.name === "project.command.createTask") { const result = await loopCtx.step({ - name: "project-create-handoff", + name: "project-create-task", timeout: 12 * 60_000, - run: async () => createHandoffMutation(loopCtx, msg.body as CreateHandoffCommand), + run: async () => createTaskMutation(loopCtx, msg.body as CreateTaskCommand), }); await msg.complete(result); return Loop.continue(undefined); } - if (msg.name === "project.command.registerHandoffBranch") { + if (msg.name === "project.command.registerTaskBranch") { const result = await loopCtx.step({ - name: "project-register-handoff-branch", + name: "project-register-task-branch", timeout: 5 * 60_000, - run: async () => registerHandoffBranchMutation(loopCtx, msg.body as RegisterHandoffBranchCommand), + run: async () => registerTaskBranchMutation(loopCtx, msg.body as RegisterTaskBranchCommand), }); await msg.complete(result); return Loop.continue(undefined); @@ -857,10 +857,10 @@ export const projectActions = { ); }, - async createHandoff(c: any, cmd: CreateHandoffCommand): Promise { + async createTask(c: any, cmd: CreateTaskCommand): Promise { const self = selfProject(c); - return expectQueueResponse( - await self.send(projectWorkflowQueueName("project.command.createHandoff"), cmd, { + return expectQueueResponse( + await self.send(projectWorkflowQueueName("project.command.createTask"), cmd, { wait: true, timeout: 12 * 60_000, }), @@ -868,42 +868,42 @@ export const projectActions = { }, async listReservedBranches(c: any, _cmd?: ListReservedBranchesCommand): Promise { - await ensureHandoffIndexHydratedForRead(c); + await ensureTaskIndexHydratedForRead(c); - const rows = await c.db.select({ branchName: handoffIndex.branchName }).from(handoffIndex).where(isNotNull(handoffIndex.branchName)).all(); + const rows = await c.db.select({ branchName: taskIndex.branchName }).from(taskIndex).where(isNotNull(taskIndex.branchName)).all(); return rows.map((row) => row.branchName).filter((name): name is string => typeof name === "string" && name.trim().length > 0); }, - async registerHandoffBranch(c: any, cmd: RegisterHandoffBranchCommand): Promise<{ branchName: string; headSha: string }> { + async registerTaskBranch(c: any, cmd: RegisterTaskBranchCommand): Promise<{ branchName: string; headSha: string }> { const self = selfProject(c); return expectQueueResponse<{ branchName: string; headSha: string }>( - await self.send(projectWorkflowQueueName("project.command.registerHandoffBranch"), cmd, { + await self.send(projectWorkflowQueueName("project.command.registerTaskBranch"), cmd, { wait: true, timeout: 5 * 60_000, }), ); }, - async hydrateHandoffIndex(c: any, cmd?: HydrateHandoffIndexCommand): Promise { + async hydrateTaskIndex(c: any, cmd?: HydrateTaskIndexCommand): Promise { const self = selfProject(c); - await self.send(projectWorkflowQueueName("project.command.hydrateHandoffIndex"), cmd ?? {}, { + await self.send(projectWorkflowQueueName("project.command.hydrateTaskIndex"), cmd ?? {}, { wait: true, timeout: 60_000, }); }, - async listHandoffSummaries(c: any, cmd?: ListHandoffSummariesCommand): Promise { + async listTaskSummaries(c: any, cmd?: ListTaskSummariesCommand): Promise { const body = cmd ?? {}; - const records: HandoffSummary[] = []; + const records: TaskSummary[] = []; - await ensureHandoffIndexHydratedForRead(c); + await ensureTaskIndexHydratedForRead(c); - const handoffRows = await c.db.select({ handoffId: handoffIndex.handoffId }).from(handoffIndex).orderBy(desc(handoffIndex.updatedAt)).all(); + const taskRows = await c.db.select({ taskId: taskIndex.taskId }).from(taskIndex).orderBy(desc(taskIndex.updatedAt)).all(); - for (const row of handoffRows) { + for (const row of taskRows) { try { - const h = getHandoff(c, c.state.workspaceId, c.state.repoId, row.handoffId); + const h = getTask(c, c.state.workspaceId, c.state.repoId, row.taskId); const record = await h.get(); if (!body.includeArchived && record.status === "archived") { @@ -913,26 +913,26 @@ export const projectActions = { records.push({ workspaceId: record.workspaceId, repoId: record.repoId, - handoffId: record.handoffId, + taskId: record.taskId, branchName: record.branchName, title: record.title, status: record.status, updatedAt: record.updatedAt, }); } catch (error) { - if (isStaleHandoffReferenceError(error)) { - await deleteStaleHandoffIndexRow(c, row.handoffId); - logActorWarning("project", "pruned stale handoff index row during summary listing", { + if (isStaleTaskReferenceError(error)) { + await deleteStaleTaskIndexRow(c, row.taskId); + logActorWarning("project", "pruned stale task index row during summary listing", { workspaceId: c.state.workspaceId, repoId: c.state.repoId, - handoffId: row.handoffId, + taskId: row.taskId, }); continue; } - logActorWarning("project", "failed loading handoff summary row", { + logActorWarning("project", "failed loading task summary row", { workspaceId: c.state.workspaceId, repoId: c.state.repoId, - handoffId: row.handoffId, + taskId: row.taskId, error: resolveErrorMessage(error), }); } @@ -942,22 +942,22 @@ export const projectActions = { return records; }, - async getHandoffEnriched(c: any, cmd: GetHandoffEnrichedCommand): Promise { - await ensureHandoffIndexHydratedForRead(c); + async getTaskEnriched(c: any, cmd: GetTaskEnrichedCommand): Promise { + await ensureTaskIndexHydratedForRead(c); - const row = await c.db.select({ handoffId: handoffIndex.handoffId }).from(handoffIndex).where(eq(handoffIndex.handoffId, cmd.handoffId)).get(); + const row = await c.db.select({ taskId: taskIndex.taskId }).from(taskIndex).where(eq(taskIndex.taskId, cmd.taskId)).get(); if (!row) { - throw new Error(`Unknown handoff in repo ${c.state.repoId}: ${cmd.handoffId}`); + throw new Error(`Unknown task in repo ${c.state.repoId}: ${cmd.taskId}`); } try { - const h = getHandoff(c, c.state.workspaceId, c.state.repoId, cmd.handoffId); + const h = getTask(c, c.state.workspaceId, c.state.repoId, cmd.taskId); const record = await h.get(); - return await enrichHandoffRecord(c, record); + return await enrichTaskRecord(c, record); } catch (error) { - if (isStaleHandoffReferenceError(error)) { - await deleteStaleHandoffIndexRow(c, cmd.handoffId); - throw new Error(`Unknown handoff in repo ${c.state.repoId}: ${cmd.handoffId}`); + if (isStaleTaskReferenceError(error)) { + await deleteStaleTaskIndexRow(c, cmd.taskId); + throw new Error(`Unknown task in repo ${c.state.repoId}: ${cmd.taskId}`); } throw error; } @@ -965,7 +965,7 @@ export const projectActions = { async getRepoOverview(c: any, _cmd?: RepoOverviewCommand): Promise { const localPath = await ensureProjectReadyForRead(c); - await ensureHandoffIndexHydratedForRead(c); + await ensureTaskIndexHydratedForRead(c); await forceProjectSync(c, localPath); const { driver } = getActorRuntimeContext(); @@ -989,45 +989,45 @@ export const projectActions = { .from(branches) .all(); - const handoffRows = await c.db + const taskRows = await c.db .select({ - handoffId: handoffIndex.handoffId, - branchName: handoffIndex.branchName, - updatedAt: handoffIndex.updatedAt, + taskId: taskIndex.taskId, + branchName: taskIndex.branchName, + updatedAt: taskIndex.updatedAt, }) - .from(handoffIndex) + .from(taskIndex) .all(); - const handoffMetaByBranch = new Map(); + const taskMetaByBranch = new Map(); - for (const row of handoffRows) { + for (const row of taskRows) { if (!row.branchName) { continue; } try { - const h = getHandoff(c, c.state.workspaceId, c.state.repoId, row.handoffId); + const h = getTask(c, c.state.workspaceId, c.state.repoId, row.taskId); const record = await h.get(); - handoffMetaByBranch.set(row.branchName, { - handoffId: row.handoffId, + taskMetaByBranch.set(row.branchName, { + taskId: row.taskId, title: record.title ?? null, status: record.status, updatedAt: record.updatedAt, }); } catch (error) { - if (isStaleHandoffReferenceError(error)) { - await deleteStaleHandoffIndexRow(c, row.handoffId); - logActorWarning("project", "pruned stale handoff index row during repo overview", { + if (isStaleTaskReferenceError(error)) { + await deleteStaleTaskIndexRow(c, row.taskId); + logActorWarning("project", "pruned stale task index row during repo overview", { workspaceId: c.state.workspaceId, repoId: c.state.repoId, - handoffId: row.handoffId, + taskId: row.taskId, branchName: row.branchName, }); continue; } - logActorWarning("project", "failed loading handoff while building repo overview", { + logActorWarning("project", "failed loading task while building repo overview", { workspaceId: c.state.workspaceId, repoId: c.state.repoId, - handoffId: row.handoffId, + taskId: row.taskId, branchName: row.branchName, error: resolveErrorMessage(error), }); @@ -1060,7 +1060,7 @@ export const projectActions = { const branchRows = combinedRows.map((ordering) => { const row = detailByBranch.get(ordering.branchName)!; - const handoffMeta = handoffMetaByBranch.get(row.branchName); + const taskMeta = taskMetaByBranch.get(row.branchName); const pr = prByBranch.get(row.branchName); return { branchName: row.branchName, @@ -1070,9 +1070,9 @@ export const projectActions = { diffStat: row.diffStat ?? null, hasUnpushed: Boolean(row.hasUnpushed), conflictsWithMain: Boolean(row.conflictsWithMain), - handoffId: handoffMeta?.handoffId ?? null, - handoffTitle: handoffMeta?.title ?? null, - handoffStatus: handoffMeta?.status ?? null, + taskId: taskMeta?.taskId ?? null, + taskTitle: taskMeta?.title ?? null, + taskStatus: taskMeta?.status ?? null, prNumber: pr?.prNumber ?? null, prState: pr?.prState ?? null, prUrl: pr?.prUrl ?? null, @@ -1081,7 +1081,7 @@ export const projectActions = { reviewer: pr?.reviewer ?? null, firstSeenAt: row.firstSeenAt ?? null, lastSeenAt: row.lastSeenAt ?? null, - updatedAt: Math.max(row.updatedAt, handoffMeta?.updatedAt ?? 0), + updatedAt: Math.max(row.updatedAt, taskMeta?.updatedAt ?? 0), }; }); diff --git a/foundry/packages/backend/src/actors/project/db/db.ts b/foundry/packages/backend/src/actors/project/db/db.ts new file mode 100644 index 0000000..49b5b72 --- /dev/null +++ b/foundry/packages/backend/src/actors/project/db/db.ts @@ -0,0 +1,5 @@ +import { db } from "rivetkit/db/drizzle"; +import * as schema from "./schema.js"; +import migrations from "./migrations.js"; + +export const projectDb = db({ schema, migrations }); diff --git a/factory/packages/backend/src/actors/project/db/drizzle.config.ts b/foundry/packages/backend/src/actors/project/db/drizzle.config.ts similarity index 100% rename from factory/packages/backend/src/actors/project/db/drizzle.config.ts rename to foundry/packages/backend/src/actors/project/db/drizzle.config.ts diff --git a/factory/packages/backend/src/actors/project/db/drizzle/0000_stormy_the_hunter.sql b/foundry/packages/backend/src/actors/project/db/drizzle/0000_stormy_the_hunter.sql similarity index 100% rename from factory/packages/backend/src/actors/project/db/drizzle/0000_stormy_the_hunter.sql rename to foundry/packages/backend/src/actors/project/db/drizzle/0000_stormy_the_hunter.sql diff --git a/factory/packages/backend/src/actors/project/db/drizzle/0001_wild_carlie_cooper.sql b/foundry/packages/backend/src/actors/project/db/drizzle/0001_wild_carlie_cooper.sql similarity index 100% rename from factory/packages/backend/src/actors/project/db/drizzle/0001_wild_carlie_cooper.sql rename to foundry/packages/backend/src/actors/project/db/drizzle/0001_wild_carlie_cooper.sql diff --git a/factory/packages/backend/src/actors/project/db/drizzle/0002_far_war_machine.sql b/foundry/packages/backend/src/actors/project/db/drizzle/0002_far_war_machine.sql similarity index 54% rename from factory/packages/backend/src/actors/project/db/drizzle/0002_far_war_machine.sql rename to foundry/packages/backend/src/actors/project/db/drizzle/0002_far_war_machine.sql index 1ecd2ba..e5497a7 100644 --- a/factory/packages/backend/src/actors/project/db/drizzle/0002_far_war_machine.sql +++ b/foundry/packages/backend/src/actors/project/db/drizzle/0002_far_war_machine.sql @@ -1,5 +1,5 @@ -CREATE TABLE `handoff_index` ( - `handoff_id` text PRIMARY KEY NOT NULL, +CREATE TABLE `task_index` ( + `task_id` text PRIMARY KEY NOT NULL, `branch_name` text, `created_at` integer NOT NULL, `updated_at` integer NOT NULL diff --git a/factory/packages/backend/src/actors/project/db/drizzle/0003_busy_legacy.sql b/foundry/packages/backend/src/actors/project/db/drizzle/0003_busy_legacy.sql similarity index 100% rename from factory/packages/backend/src/actors/project/db/drizzle/0003_busy_legacy.sql rename to foundry/packages/backend/src/actors/project/db/drizzle/0003_busy_legacy.sql diff --git a/factory/packages/backend/src/actors/project/db/drizzle/meta/0000_snapshot.json b/foundry/packages/backend/src/actors/project/db/drizzle/meta/0000_snapshot.json similarity index 100% rename from factory/packages/backend/src/actors/project/db/drizzle/meta/0000_snapshot.json rename to foundry/packages/backend/src/actors/project/db/drizzle/meta/0000_snapshot.json diff --git a/factory/packages/backend/src/actors/project/db/drizzle/meta/0001_snapshot.json b/foundry/packages/backend/src/actors/project/db/drizzle/meta/0001_snapshot.json similarity index 100% rename from factory/packages/backend/src/actors/project/db/drizzle/meta/0001_snapshot.json rename to foundry/packages/backend/src/actors/project/db/drizzle/meta/0001_snapshot.json diff --git a/factory/packages/backend/src/actors/project/db/drizzle/meta/0002_snapshot.json b/foundry/packages/backend/src/actors/project/db/drizzle/meta/0002_snapshot.json similarity index 98% rename from factory/packages/backend/src/actors/project/db/drizzle/meta/0002_snapshot.json rename to foundry/packages/backend/src/actors/project/db/drizzle/meta/0002_snapshot.json index c71b0fb..fd73086 100644 --- a/factory/packages/backend/src/actors/project/db/drizzle/meta/0002_snapshot.json +++ b/foundry/packages/backend/src/actors/project/db/drizzle/meta/0002_snapshot.json @@ -77,11 +77,11 @@ "uniqueConstraints": {}, "checkConstraints": {} }, - "handoff_index": { - "name": "handoff_index", + "task_index": { + "name": "task_index", "columns": { - "handoff_id": { - "name": "handoff_id", + "task_id": { + "name": "task_id", "type": "text", "primaryKey": true, "notNull": true, diff --git a/factory/packages/backend/src/actors/project/db/drizzle/meta/_journal.json b/foundry/packages/backend/src/actors/project/db/drizzle/meta/_journal.json similarity index 100% rename from factory/packages/backend/src/actors/project/db/drizzle/meta/_journal.json rename to foundry/packages/backend/src/actors/project/db/drizzle/meta/_journal.json diff --git a/factory/packages/backend/src/actors/project/db/migrations.ts b/foundry/packages/backend/src/actors/project/db/migrations.ts similarity index 95% rename from factory/packages/backend/src/actors/project/db/migrations.ts rename to foundry/packages/backend/src/actors/project/db/migrations.ts index dd7a304..795fd67 100644 --- a/factory/packages/backend/src/actors/project/db/migrations.ts +++ b/foundry/packages/backend/src/actors/project/db/migrations.ts @@ -69,8 +69,8 @@ CREATE TABLE \`pr_cache\` ( ); --> statement-breakpoint ALTER TABLE \`branches\` DROP COLUMN \`worktree_path\`;`, - m0002: `CREATE TABLE \`handoff_index\` ( - \`handoff_id\` text PRIMARY KEY NOT NULL, + m0002: `CREATE TABLE \`task_index\` ( + \`task_id\` text PRIMARY KEY NOT NULL, \`branch_name\` text, \`created_at\` integer NOT NULL, \`updated_at\` integer NOT NULL diff --git a/factory/packages/backend/src/actors/project/db/schema.ts b/foundry/packages/backend/src/actors/project/db/schema.ts similarity index 92% rename from factory/packages/backend/src/actors/project/db/schema.ts rename to foundry/packages/backend/src/actors/project/db/schema.ts index 8ad8db8..2f7bfda 100644 --- a/factory/packages/backend/src/actors/project/db/schema.ts +++ b/foundry/packages/backend/src/actors/project/db/schema.ts @@ -36,8 +36,8 @@ export const prCache = sqliteTable("pr_cache", { updatedAt: integer("updated_at").notNull(), }); -export const handoffIndex = sqliteTable("handoff_index", { - handoffId: text("handoff_id").notNull().primaryKey(), +export const taskIndex = sqliteTable("task_index", { + taskId: text("task_id").notNull().primaryKey(), branchName: text("branch_name"), createdAt: integer("created_at").notNull(), updatedAt: integer("updated_at").notNull(), diff --git a/factory/packages/backend/src/actors/project/index.ts b/foundry/packages/backend/src/actors/project/index.ts similarity index 95% rename from factory/packages/backend/src/actors/project/index.ts rename to foundry/packages/backend/src/actors/project/index.ts index fe406ef..7197d3b 100644 --- a/factory/packages/backend/src/actors/project/index.ts +++ b/foundry/packages/backend/src/actors/project/index.ts @@ -21,7 +21,7 @@ export const project = actor({ remoteUrl: input.remoteUrl, localPath: null as string | null, syncActorsStarted: false, - handoffIndexHydrated: false, + taskIndexHydrated: false, }), actions: projectActions, run: workflow(runProjectWorkflow), diff --git a/factory/packages/backend/src/actors/project/stack-model.ts b/foundry/packages/backend/src/actors/project/stack-model.ts similarity index 100% rename from factory/packages/backend/src/actors/project/stack-model.ts rename to foundry/packages/backend/src/actors/project/stack-model.ts diff --git a/foundry/packages/backend/src/actors/sandbox-instance/db/db.ts b/foundry/packages/backend/src/actors/sandbox-instance/db/db.ts new file mode 100644 index 0000000..0251c43 --- /dev/null +++ b/foundry/packages/backend/src/actors/sandbox-instance/db/db.ts @@ -0,0 +1,5 @@ +import { db } from "rivetkit/db/drizzle"; +import * as schema from "./schema.js"; +import migrations from "./migrations.js"; + +export const sandboxInstanceDb = db({ schema, migrations }); diff --git a/factory/packages/backend/src/actors/sandbox-instance/db/drizzle.config.ts b/foundry/packages/backend/src/actors/sandbox-instance/db/drizzle.config.ts similarity index 100% rename from factory/packages/backend/src/actors/sandbox-instance/db/drizzle.config.ts rename to foundry/packages/backend/src/actors/sandbox-instance/db/drizzle.config.ts diff --git a/factory/packages/backend/src/actors/sandbox-instance/db/drizzle/0000_broad_tyrannus.sql b/foundry/packages/backend/src/actors/sandbox-instance/db/drizzle/0000_broad_tyrannus.sql similarity index 100% rename from factory/packages/backend/src/actors/sandbox-instance/db/drizzle/0000_broad_tyrannus.sql rename to foundry/packages/backend/src/actors/sandbox-instance/db/drizzle/0000_broad_tyrannus.sql diff --git a/factory/packages/backend/src/actors/sandbox-instance/db/drizzle/0001_sandbox_sessions.sql b/foundry/packages/backend/src/actors/sandbox-instance/db/drizzle/0001_sandbox_sessions.sql similarity index 100% rename from factory/packages/backend/src/actors/sandbox-instance/db/drizzle/0001_sandbox_sessions.sql rename to foundry/packages/backend/src/actors/sandbox-instance/db/drizzle/0001_sandbox_sessions.sql diff --git a/factory/packages/backend/src/actors/sandbox-instance/db/drizzle/meta/0000_snapshot.json b/foundry/packages/backend/src/actors/sandbox-instance/db/drizzle/meta/0000_snapshot.json similarity index 100% rename from factory/packages/backend/src/actors/sandbox-instance/db/drizzle/meta/0000_snapshot.json rename to foundry/packages/backend/src/actors/sandbox-instance/db/drizzle/meta/0000_snapshot.json diff --git a/factory/packages/backend/src/actors/sandbox-instance/db/drizzle/meta/_journal.json b/foundry/packages/backend/src/actors/sandbox-instance/db/drizzle/meta/_journal.json similarity index 100% rename from factory/packages/backend/src/actors/sandbox-instance/db/drizzle/meta/_journal.json rename to foundry/packages/backend/src/actors/sandbox-instance/db/drizzle/meta/_journal.json diff --git a/factory/packages/backend/src/actors/sandbox-instance/db/migrations.ts b/foundry/packages/backend/src/actors/sandbox-instance/db/migrations.ts similarity index 100% rename from factory/packages/backend/src/actors/sandbox-instance/db/migrations.ts rename to foundry/packages/backend/src/actors/sandbox-instance/db/migrations.ts diff --git a/factory/packages/backend/src/actors/sandbox-instance/db/schema.ts b/foundry/packages/backend/src/actors/sandbox-instance/db/schema.ts similarity index 94% rename from factory/packages/backend/src/actors/sandbox-instance/db/schema.ts rename to foundry/packages/backend/src/actors/sandbox-instance/db/schema.ts index fc26b26..b5dbf50 100644 --- a/factory/packages/backend/src/actors/sandbox-instance/db/schema.ts +++ b/foundry/packages/backend/src/actors/sandbox-instance/db/schema.ts @@ -1,4 +1,4 @@ -import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { integer, sqliteTable, text } from "rivetkit/db/drizzle"; // SQLite is per sandbox-instance actor instance. export const sandboxInstance = sqliteTable("sandbox_instance", { diff --git a/factory/packages/backend/src/actors/sandbox-instance/index.ts b/foundry/packages/backend/src/actors/sandbox-instance/index.ts similarity index 97% rename from factory/packages/backend/src/actors/sandbox-instance/index.ts rename to foundry/packages/backend/src/actors/sandbox-instance/index.ts index 2470aff..35e0e7b 100644 --- a/factory/packages/backend/src/actors/sandbox-instance/index.ts +++ b/foundry/packages/backend/src/actors/sandbox-instance/index.ts @@ -2,7 +2,7 @@ import { setTimeout as delay } from "node:timers/promises"; import { eq } from "drizzle-orm"; import { actor, queue } from "rivetkit"; import { Loop, workflow } from "rivetkit/workflow"; -import type { ProviderId } from "@openhandoff/shared"; +import type { ProviderId } from "@sandbox-agent/foundry-shared"; import type { ProcessCreateRequest, ProcessInfo, @@ -482,28 +482,19 @@ export const sandboxInstance = actor({ return await client.listProcesses(); }, - async getProcessLogs( - c: any, - request: { processId: string; query?: ProcessLogFollowQuery } - ): Promise { + async getProcessLogs(c: any, request: { processId: string; query?: ProcessLogFollowQuery }): Promise { const client = await getSandboxAgentClient(c); return await client.getProcessLogs(request.processId, request.query); }, - async stopProcess( - c: any, - request: { processId: string; query?: ProcessSignalQuery } - ): Promise { + async stopProcess(c: any, request: { processId: string; query?: ProcessSignalQuery }): Promise { const client = await getSandboxAgentClient(c); const stopped = await client.stopProcess(request.processId, request.query); broadcastProcessesUpdated(c); return stopped; }, - async killProcess( - c: any, - request: { processId: string; query?: ProcessSignalQuery } - ): Promise { + async killProcess(c: any, request: { processId: string; query?: ProcessSignalQuery }): Promise { const client = await getSandboxAgentClient(c); const killed = await client.killProcess(request.processId, request.query); broadcastProcessesUpdated(c); diff --git a/factory/packages/backend/src/actors/sandbox-instance/persist.ts b/foundry/packages/backend/src/actors/sandbox-instance/persist.ts similarity index 100% rename from factory/packages/backend/src/actors/sandbox-instance/persist.ts rename to foundry/packages/backend/src/actors/sandbox-instance/persist.ts diff --git a/factory/packages/backend/src/actors/handoff-status-sync/index.ts b/foundry/packages/backend/src/actors/task-status-sync/index.ts similarity index 66% rename from factory/packages/backend/src/actors/handoff-status-sync/index.ts rename to foundry/packages/backend/src/actors/task-status-sync/index.ts index dba3be9..3f85656 100644 --- a/factory/packages/backend/src/actors/handoff-status-sync/index.ts +++ b/foundry/packages/backend/src/actors/task-status-sync/index.ts @@ -1,14 +1,14 @@ import { actor, queue } from "rivetkit"; import { workflow } from "rivetkit/workflow"; -import type { ProviderId } from "@openhandoff/shared"; -import { getHandoff, getSandboxInstance, selfHandoffStatusSync } from "../handles.js"; +import type { ProviderId } from "@sandbox-agent/foundry-shared"; +import { getTask, getSandboxInstance, selfTaskStatusSync } from "../handles.js"; import { logActorWarning, resolveErrorMessage, resolveErrorStack } from "../logging.js"; import { type PollingControlState, runWorkflowPollingLoop } from "../polling.js"; -export interface HandoffStatusSyncInput { +export interface TaskStatusSyncInput { workspaceId: string; repoId: string; - handoffId: string; + taskId: string; providerId: ProviderId; sandboxId: string; sessionId: string; @@ -19,27 +19,27 @@ interface SetIntervalCommand { intervalMs: number; } -interface HandoffStatusSyncState extends PollingControlState { +interface TaskStatusSyncState extends PollingControlState { workspaceId: string; repoId: string; - handoffId: string; + taskId: string; providerId: ProviderId; sandboxId: string; sessionId: string; } const CONTROL = { - start: "handoff.status_sync.control.start", - stop: "handoff.status_sync.control.stop", - setInterval: "handoff.status_sync.control.set_interval", - force: "handoff.status_sync.control.force", + start: "task.status_sync.control.start", + stop: "task.status_sync.control.stop", + setInterval: "task.status_sync.control.set_interval", + force: "task.status_sync.control.force", } as const; -async function pollSessionStatus(c: { state: HandoffStatusSyncState }): Promise { +async function pollSessionStatus(c: { state: TaskStatusSyncState }): Promise { const sandboxInstance = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, c.state.sandboxId); const status = await sandboxInstance.sessionStatus({ sessionId: c.state.sessionId }); - const parent = getHandoff(c, c.state.workspaceId, c.state.repoId, c.state.handoffId); + const parent = getTask(c, c.state.workspaceId, c.state.repoId, c.state.taskId); await parent.syncWorkbenchSessionStatus({ sessionId: c.state.sessionId, status: status.status, @@ -47,7 +47,7 @@ async function pollSessionStatus(c: { state: HandoffStatusSyncState }): Promise< }); } -export const handoffStatusSync = actor({ +export const taskStatusSync = actor({ queues: { [CONTROL.start]: queue(), [CONTROL.stop]: queue(), @@ -58,10 +58,10 @@ export const handoffStatusSync = actor({ // Polling actors rely on timer-based wakeups; sleeping would pause the timer and stop polling. noSleep: true, }, - createState: (_c, input: HandoffStatusSyncInput): HandoffStatusSyncState => ({ + createState: (_c, input: TaskStatusSyncInput): TaskStatusSyncState => ({ workspaceId: input.workspaceId, repoId: input.repoId, - handoffId: input.handoffId, + taskId: input.taskId, providerId: input.providerId, sandboxId: input.sandboxId, sessionId: input.sessionId, @@ -70,34 +70,34 @@ export const handoffStatusSync = actor({ }), actions: { async start(c): Promise { - const self = selfHandoffStatusSync(c); + const self = selfTaskStatusSync(c); await self.send(CONTROL.start, {}, { wait: true, timeout: 15_000 }); }, async stop(c): Promise { - const self = selfHandoffStatusSync(c); + const self = selfTaskStatusSync(c); await self.send(CONTROL.stop, {}, { wait: true, timeout: 15_000 }); }, async setIntervalMs(c, payload: SetIntervalCommand): Promise { - const self = selfHandoffStatusSync(c); + const self = selfTaskStatusSync(c); await self.send(CONTROL.setInterval, payload, { wait: true, timeout: 15_000 }); }, async force(c): Promise { - const self = selfHandoffStatusSync(c); + const self = selfTaskStatusSync(c); await self.send(CONTROL.force, {}, { wait: true, timeout: 5 * 60_000 }); }, }, run: workflow(async (ctx) => { - await runWorkflowPollingLoop(ctx, { - loopName: "handoff-status-sync-loop", + await runWorkflowPollingLoop(ctx, { + loopName: "task-status-sync-loop", control: CONTROL, onPoll: async (loopCtx) => { try { await pollSessionStatus(loopCtx); } catch (error) { - logActorWarning("handoff-status-sync", "poll failed", { + logActorWarning("task-status-sync", "poll failed", { error: resolveErrorMessage(error), stack: resolveErrorStack(error), }); diff --git a/foundry/packages/backend/src/actors/task/db/db.ts b/foundry/packages/backend/src/actors/task/db/db.ts new file mode 100644 index 0000000..128f856 --- /dev/null +++ b/foundry/packages/backend/src/actors/task/db/db.ts @@ -0,0 +1,5 @@ +import { db } from "rivetkit/db/drizzle"; +import * as schema from "./schema.js"; +import migrations from "./migrations.js"; + +export const taskDb = db({ schema, migrations }); diff --git a/foundry/packages/backend/src/actors/task/db/drizzle.config.ts b/foundry/packages/backend/src/actors/task/db/drizzle.config.ts new file mode 100644 index 0000000..d022c36 --- /dev/null +++ b/foundry/packages/backend/src/actors/task/db/drizzle.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from "rivetkit/db/drizzle"; + +export default defineConfig({ + out: "./src/actors/task/db/drizzle", + schema: "./src/actors/task/db/schema.ts", +}); diff --git a/factory/packages/backend/src/actors/handoff/db/drizzle/0000_condemned_maria_hill.sql b/foundry/packages/backend/src/actors/task/db/drizzle/0000_condemned_maria_hill.sql similarity index 91% rename from factory/packages/backend/src/actors/handoff/db/drizzle/0000_condemned_maria_hill.sql rename to foundry/packages/backend/src/actors/task/db/drizzle/0000_condemned_maria_hill.sql index f73b681..1ad4a85 100644 --- a/factory/packages/backend/src/actors/handoff/db/drizzle/0000_condemned_maria_hill.sql +++ b/foundry/packages/backend/src/actors/task/db/drizzle/0000_condemned_maria_hill.sql @@ -1,4 +1,4 @@ -CREATE TABLE `handoff` ( +CREATE TABLE `task` ( `id` integer PRIMARY KEY NOT NULL, `branch_name` text NOT NULL, `title` text NOT NULL, @@ -14,7 +14,7 @@ CREATE TABLE `handoff` ( `updated_at` integer NOT NULL ); --> statement-breakpoint -CREATE TABLE `handoff_runtime` ( +CREATE TABLE `task_runtime` ( `id` integer PRIMARY KEY NOT NULL, `sandbox_id` text, `session_id` text, diff --git a/foundry/packages/backend/src/actors/task/db/drizzle/0001_rapid_eddie_brock.sql b/foundry/packages/backend/src/actors/task/db/drizzle/0001_rapid_eddie_brock.sql new file mode 100644 index 0000000..99548a6 --- /dev/null +++ b/foundry/packages/backend/src/actors/task/db/drizzle/0001_rapid_eddie_brock.sql @@ -0,0 +1,3 @@ +ALTER TABLE `task` DROP COLUMN `auto_committed`;--> statement-breakpoint +ALTER TABLE `task` DROP COLUMN `pushed`;--> statement-breakpoint +ALTER TABLE `task` DROP COLUMN `needs_push`; \ No newline at end of file diff --git a/factory/packages/backend/src/actors/handoff/db/drizzle/0002_lazy_moira_mactaggert.sql b/foundry/packages/backend/src/actors/task/db/drizzle/0002_lazy_moira_mactaggert.sql similarity index 50% rename from factory/packages/backend/src/actors/handoff/db/drizzle/0002_lazy_moira_mactaggert.sql rename to foundry/packages/backend/src/actors/task/db/drizzle/0002_lazy_moira_mactaggert.sql index fdc79be..ec6a771 100644 --- a/factory/packages/backend/src/actors/handoff/db/drizzle/0002_lazy_moira_mactaggert.sql +++ b/foundry/packages/backend/src/actors/task/db/drizzle/0002_lazy_moira_mactaggert.sql @@ -1,7 +1,7 @@ -ALTER TABLE `handoff_runtime` RENAME COLUMN "sandbox_id" TO "active_sandbox_id";--> statement-breakpoint -ALTER TABLE `handoff_runtime` RENAME COLUMN "session_id" TO "active_session_id";--> statement-breakpoint -ALTER TABLE `handoff_runtime` RENAME COLUMN "switch_target" TO "active_switch_target";--> statement-breakpoint -CREATE TABLE `handoff_sandboxes` ( +ALTER TABLE `task_runtime` RENAME COLUMN "sandbox_id" TO "active_sandbox_id";--> statement-breakpoint +ALTER TABLE `task_runtime` RENAME COLUMN "session_id" TO "active_session_id";--> statement-breakpoint +ALTER TABLE `task_runtime` RENAME COLUMN "switch_target" TO "active_switch_target";--> statement-breakpoint +CREATE TABLE `task_sandboxes` ( `sandbox_id` text PRIMARY KEY NOT NULL, `provider_id` text NOT NULL, `switch_target` text NOT NULL, @@ -11,9 +11,9 @@ CREATE TABLE `handoff_sandboxes` ( `updated_at` integer NOT NULL ); --> statement-breakpoint -ALTER TABLE `handoff_runtime` ADD `active_cwd` text; +ALTER TABLE `task_runtime` ADD `active_cwd` text; --> statement-breakpoint -INSERT INTO `handoff_sandboxes` ( +INSERT INTO `task_sandboxes` ( `sandbox_id`, `provider_id`, `switch_target`, @@ -24,13 +24,13 @@ INSERT INTO `handoff_sandboxes` ( ) SELECT r.`active_sandbox_id`, - (SELECT h.`provider_id` FROM `handoff` h WHERE h.`id` = 1), + (SELECT h.`provider_id` FROM `task` h WHERE h.`id` = 1), r.`active_switch_target`, r.`active_cwd`, r.`status_message`, - COALESCE((SELECT h.`created_at` FROM `handoff` h WHERE h.`id` = 1), r.`updated_at`), + COALESCE((SELECT h.`created_at` FROM `task` h WHERE h.`id` = 1), r.`updated_at`), r.`updated_at` -FROM `handoff_runtime` r +FROM `task_runtime` r WHERE r.`id` = 1 AND r.`active_sandbox_id` IS NOT NULL diff --git a/factory/packages/backend/src/actors/handoff/db/drizzle/0003_plucky_bran.sql b/foundry/packages/backend/src/actors/task/db/drizzle/0003_plucky_bran.sql similarity index 77% rename from factory/packages/backend/src/actors/handoff/db/drizzle/0003_plucky_bran.sql rename to foundry/packages/backend/src/actors/task/db/drizzle/0003_plucky_bran.sql index f8c87f4..6d4f112 100644 --- a/factory/packages/backend/src/actors/handoff/db/drizzle/0003_plucky_bran.sql +++ b/foundry/packages/backend/src/actors/task/db/drizzle/0003_plucky_bran.sql @@ -1,9 +1,9 @@ --- Allow handoffs to exist before their branch/title are determined. +-- Allow tasks to exist before their branch/title are determined. -- Drizzle doesn't support altering column nullability in SQLite directly, so rebuild the table. PRAGMA foreign_keys=off; -CREATE TABLE `handoff__new` ( +CREATE TABLE `task__new` ( `id` integer PRIMARY KEY NOT NULL, `branch_name` text, `title` text, @@ -16,7 +16,7 @@ CREATE TABLE `handoff__new` ( `updated_at` integer NOT NULL ); -INSERT INTO `handoff__new` ( +INSERT INTO `task__new` ( `id`, `branch_name`, `title`, @@ -39,10 +39,10 @@ SELECT `pr_submitted`, `created_at`, `updated_at` -FROM `handoff`; +FROM `task`; -DROP TABLE `handoff`; -ALTER TABLE `handoff__new` RENAME TO `handoff`; +DROP TABLE `task`; +ALTER TABLE `task__new` RENAME TO `task`; PRAGMA foreign_keys=on; diff --git a/factory/packages/backend/src/actors/handoff/db/drizzle/0004_focused_shuri.sql b/foundry/packages/backend/src/actors/task/db/drizzle/0004_focused_shuri.sql similarity index 85% rename from factory/packages/backend/src/actors/handoff/db/drizzle/0004_focused_shuri.sql rename to foundry/packages/backend/src/actors/task/db/drizzle/0004_focused_shuri.sql index aa39c9b..b85a9c8 100644 --- a/factory/packages/backend/src/actors/handoff/db/drizzle/0004_focused_shuri.sql +++ b/foundry/packages/backend/src/actors/task/db/drizzle/0004_focused_shuri.sql @@ -5,10 +5,10 @@ PRAGMA foreign_keys=off; --> statement-breakpoint -DROP TABLE IF EXISTS `handoff__new`; +DROP TABLE IF EXISTS `task__new`; --> statement-breakpoint -CREATE TABLE `handoff__new` ( +CREATE TABLE `task__new` ( `id` integer PRIMARY KEY NOT NULL, `branch_name` text, `title` text, @@ -22,7 +22,7 @@ CREATE TABLE `handoff__new` ( ); --> statement-breakpoint -INSERT INTO `handoff__new` ( +INSERT INTO `task__new` ( `id`, `branch_name`, `title`, @@ -45,13 +45,13 @@ SELECT `pr_submitted`, `created_at`, `updated_at` -FROM `handoff`; +FROM `task`; --> statement-breakpoint -DROP TABLE `handoff`; +DROP TABLE `task`; --> statement-breakpoint -ALTER TABLE `handoff__new` RENAME TO `handoff`; +ALTER TABLE `task__new` RENAME TO `task`; --> statement-breakpoint PRAGMA foreign_keys=on; diff --git a/foundry/packages/backend/src/actors/task/db/drizzle/0005_sandbox_actor_id.sql b/foundry/packages/backend/src/actors/task/db/drizzle/0005_sandbox_actor_id.sql new file mode 100644 index 0000000..22838f7 --- /dev/null +++ b/foundry/packages/backend/src/actors/task/db/drizzle/0005_sandbox_actor_id.sql @@ -0,0 +1 @@ +ALTER TABLE `task_sandboxes` ADD `sandbox_actor_id` text; diff --git a/factory/packages/backend/src/actors/handoff/db/drizzle/0006_workbench_sessions.sql b/foundry/packages/backend/src/actors/task/db/drizzle/0006_workbench_sessions.sql similarity index 90% rename from factory/packages/backend/src/actors/handoff/db/drizzle/0006_workbench_sessions.sql rename to foundry/packages/backend/src/actors/task/db/drizzle/0006_workbench_sessions.sql index 1afc5b0..8a74565 100644 --- a/factory/packages/backend/src/actors/handoff/db/drizzle/0006_workbench_sessions.sql +++ b/foundry/packages/backend/src/actors/task/db/drizzle/0006_workbench_sessions.sql @@ -1,4 +1,4 @@ -CREATE TABLE `handoff_workbench_sessions` ( +CREATE TABLE `task_workbench_sessions` ( `session_id` text PRIMARY KEY NOT NULL, `session_name` text NOT NULL, `model` text NOT NULL, diff --git a/factory/packages/backend/src/actors/handoff/db/drizzle/meta/0000_snapshot.json b/foundry/packages/backend/src/actors/task/db/drizzle/meta/0000_snapshot.json similarity index 97% rename from factory/packages/backend/src/actors/handoff/db/drizzle/meta/0000_snapshot.json rename to foundry/packages/backend/src/actors/task/db/drizzle/meta/0000_snapshot.json index 850dda8..f46152e 100644 --- a/factory/packages/backend/src/actors/handoff/db/drizzle/meta/0000_snapshot.json +++ b/foundry/packages/backend/src/actors/task/db/drizzle/meta/0000_snapshot.json @@ -4,8 +4,8 @@ "id": "9b004d3b-0722-4bb5-a410-d47635db7df3", "prevId": "00000000-0000-0000-0000-000000000000", "tables": { - "handoff": { - "name": "handoff", + "task": { + "name": "task", "columns": { "id": { "name": "id", @@ -110,8 +110,8 @@ "uniqueConstraints": {}, "checkConstraints": {} }, - "handoff_runtime": { - "name": "handoff_runtime", + "task_runtime": { + "name": "task_runtime", "columns": { "id": { "name": "id", diff --git a/factory/packages/backend/src/actors/handoff/db/drizzle/meta/0001_snapshot.json b/foundry/packages/backend/src/actors/task/db/drizzle/meta/0001_snapshot.json similarity index 97% rename from factory/packages/backend/src/actors/handoff/db/drizzle/meta/0001_snapshot.json rename to foundry/packages/backend/src/actors/task/db/drizzle/meta/0001_snapshot.json index afd7688..7b68c17 100644 --- a/factory/packages/backend/src/actors/handoff/db/drizzle/meta/0001_snapshot.json +++ b/foundry/packages/backend/src/actors/task/db/drizzle/meta/0001_snapshot.json @@ -4,8 +4,8 @@ "id": "0fca0f14-69df-4fca-bc52-29e902247909", "prevId": "9b004d3b-0722-4bb5-a410-d47635db7df3", "tables": { - "handoff": { - "name": "handoff", + "task": { + "name": "task", "columns": { "id": { "name": "id", @@ -86,8 +86,8 @@ "uniqueConstraints": {}, "checkConstraints": {} }, - "handoff_runtime": { - "name": "handoff_runtime", + "task_runtime": { + "name": "task_runtime", "columns": { "id": { "name": "id", diff --git a/factory/packages/backend/src/actors/handoff/db/drizzle/meta/0002_snapshot.json b/foundry/packages/backend/src/actors/task/db/drizzle/meta/0002_snapshot.json similarity index 92% rename from factory/packages/backend/src/actors/handoff/db/drizzle/meta/0002_snapshot.json rename to foundry/packages/backend/src/actors/task/db/drizzle/meta/0002_snapshot.json index d1f29f4..011ed7c 100644 --- a/factory/packages/backend/src/actors/handoff/db/drizzle/meta/0002_snapshot.json +++ b/foundry/packages/backend/src/actors/task/db/drizzle/meta/0002_snapshot.json @@ -4,8 +4,8 @@ "id": "72cef919-e545-48be-a7c0-7ac74cfcf9e6", "prevId": "0fca0f14-69df-4fca-bc52-29e902247909", "tables": { - "handoff": { - "name": "handoff", + "task": { + "name": "task", "columns": { "id": { "name": "id", @@ -86,8 +86,8 @@ "uniqueConstraints": {}, "checkConstraints": {} }, - "handoff_runtime": { - "name": "handoff_runtime", + "task_runtime": { + "name": "task_runtime", "columns": { "id": { "name": "id", @@ -145,8 +145,8 @@ "uniqueConstraints": {}, "checkConstraints": {} }, - "handoff_sandboxes": { - "name": "handoff_sandboxes", + "task_sandboxes": { + "name": "task_sandboxes", "columns": { "sandbox_id": { "name": "sandbox_id", @@ -211,9 +211,9 @@ "schemas": {}, "tables": {}, "columns": { - "\"handoff_runtime\".\"sandbox_id\"": "\"handoff_runtime\".\"active_sandbox_id\"", - "\"handoff_runtime\".\"session_id\"": "\"handoff_runtime\".\"active_session_id\"", - "\"handoff_runtime\".\"switch_target\"": "\"handoff_runtime\".\"active_switch_target\"" + "\"task_runtime\".\"sandbox_id\"": "\"task_runtime\".\"active_sandbox_id\"", + "\"task_runtime\".\"session_id\"": "\"task_runtime\".\"active_session_id\"", + "\"task_runtime\".\"switch_target\"": "\"task_runtime\".\"active_switch_target\"" } }, "internal": { diff --git a/factory/packages/backend/src/actors/handoff/db/drizzle/meta/_journal.json b/foundry/packages/backend/src/actors/task/db/drizzle/meta/_journal.json similarity index 100% rename from factory/packages/backend/src/actors/handoff/db/drizzle/meta/_journal.json rename to foundry/packages/backend/src/actors/task/db/drizzle/meta/_journal.json diff --git a/factory/packages/backend/src/actors/handoff/db/migrations.ts b/foundry/packages/backend/src/actors/task/db/migrations.ts similarity index 75% rename from factory/packages/backend/src/actors/handoff/db/migrations.ts rename to foundry/packages/backend/src/actors/task/db/migrations.ts index 6d45ed9..ee593bf 100644 --- a/factory/packages/backend/src/actors/handoff/db/migrations.ts +++ b/foundry/packages/backend/src/actors/task/db/migrations.ts @@ -52,7 +52,7 @@ const journal = { export default { journal, migrations: { - m0000: `CREATE TABLE \`handoff\` ( + m0000: `CREATE TABLE \`task\` ( \`id\` integer PRIMARY KEY NOT NULL, \`branch_name\` text NOT NULL, \`title\` text NOT NULL, @@ -68,7 +68,7 @@ export default { \`updated_at\` integer NOT NULL ); --> statement-breakpoint -CREATE TABLE \`handoff_runtime\` ( +CREATE TABLE \`task_runtime\` ( \`id\` integer PRIMARY KEY NOT NULL, \`sandbox_id\` text, \`session_id\` text, @@ -77,13 +77,13 @@ CREATE TABLE \`handoff_runtime\` ( \`updated_at\` integer NOT NULL ); `, - m0001: `ALTER TABLE \`handoff\` DROP COLUMN \`auto_committed\`;--> statement-breakpoint -ALTER TABLE \`handoff\` DROP COLUMN \`pushed\`;--> statement-breakpoint -ALTER TABLE \`handoff\` DROP COLUMN \`needs_push\`;`, - m0002: `ALTER TABLE \`handoff_runtime\` RENAME COLUMN "sandbox_id" TO "active_sandbox_id";--> statement-breakpoint -ALTER TABLE \`handoff_runtime\` RENAME COLUMN "session_id" TO "active_session_id";--> statement-breakpoint -ALTER TABLE \`handoff_runtime\` RENAME COLUMN "switch_target" TO "active_switch_target";--> statement-breakpoint -CREATE TABLE \`handoff_sandboxes\` ( + m0001: `ALTER TABLE \`task\` DROP COLUMN \`auto_committed\`;--> statement-breakpoint +ALTER TABLE \`task\` DROP COLUMN \`pushed\`;--> statement-breakpoint +ALTER TABLE \`task\` DROP COLUMN \`needs_push\`;`, + m0002: `ALTER TABLE \`task_runtime\` RENAME COLUMN "sandbox_id" TO "active_sandbox_id";--> statement-breakpoint +ALTER TABLE \`task_runtime\` RENAME COLUMN "session_id" TO "active_session_id";--> statement-breakpoint +ALTER TABLE \`task_runtime\` RENAME COLUMN "switch_target" TO "active_switch_target";--> statement-breakpoint +CREATE TABLE \`task_sandboxes\` ( \`sandbox_id\` text PRIMARY KEY NOT NULL, \`provider_id\` text NOT NULL, \`switch_target\` text NOT NULL, @@ -93,9 +93,9 @@ CREATE TABLE \`handoff_sandboxes\` ( \`updated_at\` integer NOT NULL ); --> statement-breakpoint -ALTER TABLE \`handoff_runtime\` ADD \`active_cwd\` text; +ALTER TABLE \`task_runtime\` ADD \`active_cwd\` text; --> statement-breakpoint -INSERT INTO \`handoff_sandboxes\` ( +INSERT INTO \`task_sandboxes\` ( \`sandbox_id\`, \`provider_id\`, \`switch_target\`, @@ -106,25 +106,25 @@ INSERT INTO \`handoff_sandboxes\` ( ) SELECT r.\`active_sandbox_id\`, - (SELECT h.\`provider_id\` FROM \`handoff\` h WHERE h.\`id\` = 1), + (SELECT h.\`provider_id\` FROM \`task\` h WHERE h.\`id\` = 1), r.\`active_switch_target\`, r.\`active_cwd\`, r.\`status_message\`, - COALESCE((SELECT h.\`created_at\` FROM \`handoff\` h WHERE h.\`id\` = 1), r.\`updated_at\`), + COALESCE((SELECT h.\`created_at\` FROM \`task\` h WHERE h.\`id\` = 1), r.\`updated_at\`), r.\`updated_at\` -FROM \`handoff_runtime\` r +FROM \`task_runtime\` r WHERE r.\`id\` = 1 AND r.\`active_sandbox_id\` IS NOT NULL AND r.\`active_switch_target\` IS NOT NULL ON CONFLICT(\`sandbox_id\`) DO NOTHING; `, - m0003: `-- Allow handoffs to exist before their branch/title are determined. + m0003: `-- Allow tasks to exist before their branch/title are determined. -- Drizzle doesn't support altering column nullability in SQLite directly, so rebuild the table. PRAGMA foreign_keys=off; -CREATE TABLE \`handoff__new\` ( +CREATE TABLE \`task__new\` ( \`id\` integer PRIMARY KEY NOT NULL, \`branch_name\` text, \`title\` text, @@ -137,7 +137,7 @@ CREATE TABLE \`handoff__new\` ( \`updated_at\` integer NOT NULL ); -INSERT INTO \`handoff__new\` ( +INSERT INTO \`task__new\` ( \`id\`, \`branch_name\`, \`title\`, @@ -160,10 +160,10 @@ SELECT \`pr_submitted\`, \`created_at\`, \`updated_at\` -FROM \`handoff\`; +FROM \`task\`; -DROP TABLE \`handoff\`; -ALTER TABLE \`handoff__new\` RENAME TO \`handoff\`; +DROP TABLE \`task\`; +ALTER TABLE \`task__new\` RENAME TO \`task\`; PRAGMA foreign_keys=on; @@ -175,10 +175,10 @@ PRAGMA foreign_keys=on; PRAGMA foreign_keys=off; --> statement-breakpoint -DROP TABLE IF EXISTS \`handoff__new\`; +DROP TABLE IF EXISTS \`task__new\`; --> statement-breakpoint -CREATE TABLE \`handoff__new\` ( +CREATE TABLE \`task__new\` ( \`id\` integer PRIMARY KEY NOT NULL, \`branch_name\` text, \`title\` text, @@ -192,7 +192,7 @@ CREATE TABLE \`handoff__new\` ( ); --> statement-breakpoint -INSERT INTO \`handoff__new\` ( +INSERT INTO \`task__new\` ( \`id\`, \`branch_name\`, \`title\`, @@ -215,19 +215,19 @@ SELECT \`pr_submitted\`, \`created_at\`, \`updated_at\` -FROM \`handoff\`; +FROM \`task\`; --> statement-breakpoint -DROP TABLE \`handoff\`; +DROP TABLE \`task\`; --> statement-breakpoint -ALTER TABLE \`handoff__new\` RENAME TO \`handoff\`; +ALTER TABLE \`task__new\` RENAME TO \`task\`; --> statement-breakpoint PRAGMA foreign_keys=on; `, - m0005: `ALTER TABLE \`handoff_sandboxes\` ADD \`sandbox_actor_id\` text;`, - m0006: `CREATE TABLE \`handoff_workbench_sessions\` ( + m0005: `ALTER TABLE \`task_sandboxes\` ADD \`sandbox_actor_id\` text;`, + m0006: `CREATE TABLE \`task_workbench_sessions\` ( \`session_id\` text PRIMARY KEY NOT NULL, \`session_name\` text NOT NULL, \`model\` text NOT NULL, diff --git a/factory/packages/backend/src/actors/handoff/db/schema.ts b/foundry/packages/backend/src/actors/task/db/schema.ts similarity index 83% rename from factory/packages/backend/src/actors/handoff/db/schema.ts rename to foundry/packages/backend/src/actors/task/db/schema.ts index 7ce1aca..a9d8ad8 100644 --- a/factory/packages/backend/src/actors/handoff/db/schema.ts +++ b/foundry/packages/backend/src/actors/task/db/schema.ts @@ -1,7 +1,7 @@ import { integer, sqliteTable, text } from "rivetkit/db/drizzle"; -// SQLite is per handoff actor instance, so these tables only ever store one row (id=1). -export const handoff = sqliteTable("handoff", { +// SQLite is per task actor instance, so these tables only ever store one row (id=1). +export const task = sqliteTable("task", { id: integer("id").primaryKey(), branchName: text("branch_name"), title: text("title"), @@ -14,7 +14,7 @@ export const handoff = sqliteTable("handoff", { updatedAt: integer("updated_at").notNull(), }); -export const handoffRuntime = sqliteTable("handoff_runtime", { +export const taskRuntime = sqliteTable("task_runtime", { id: integer("id").primaryKey(), activeSandboxId: text("active_sandbox_id"), activeSessionId: text("active_session_id"), @@ -24,7 +24,7 @@ export const handoffRuntime = sqliteTable("handoff_runtime", { updatedAt: integer("updated_at").notNull(), }); -export const handoffSandboxes = sqliteTable("handoff_sandboxes", { +export const taskSandboxes = sqliteTable("task_sandboxes", { sandboxId: text("sandbox_id").notNull().primaryKey(), providerId: text("provider_id").notNull(), sandboxActorId: text("sandbox_actor_id"), @@ -35,7 +35,7 @@ export const handoffSandboxes = sqliteTable("handoff_sandboxes", { updatedAt: integer("updated_at").notNull(), }); -export const handoffWorkbenchSessions = sqliteTable("handoff_workbench_sessions", { +export const taskWorkbenchSessions = sqliteTable("task_workbench_sessions", { sessionId: text("session_id").notNull().primaryKey(), sessionName: text("session_name").notNull(), model: text("model").notNull(), diff --git a/foundry/packages/backend/src/actors/task/index.ts b/foundry/packages/backend/src/actors/task/index.ts new file mode 100644 index 0000000..2b2684d --- /dev/null +++ b/foundry/packages/backend/src/actors/task/index.ts @@ -0,0 +1,385 @@ +import { actor, queue } from "rivetkit"; +import { workflow } from "rivetkit/workflow"; +import type { + AgentType, + TaskRecord, + TaskWorkbenchChangeModelInput, + TaskWorkbenchRenameInput, + TaskWorkbenchRenameSessionInput, + TaskWorkbenchSetSessionUnreadInput, + TaskWorkbenchSendMessageInput, + TaskWorkbenchUpdateDraftInput, + ProviderId, +} from "@sandbox-agent/foundry-shared"; +import { expectQueueResponse } from "../../services/queue.js"; +import { selfTask } from "../handles.js"; +import { taskDb } from "./db/db.js"; +import { getCurrentRecord } from "./workflow/common.js"; +import { + changeWorkbenchModel, + closeWorkbenchSession, + createWorkbenchSession, + getWorkbenchTask, + markWorkbenchUnread, + publishWorkbenchPr, + renameWorkbenchBranch, + renameWorkbenchTask, + renameWorkbenchSession, + revertWorkbenchFile, + sendWorkbenchMessage, + syncWorkbenchSessionStatus, + setWorkbenchSessionUnread, + stopWorkbenchSession, + updateWorkbenchDraft, +} from "./workbench.js"; +import { TASK_QUEUE_NAMES, taskWorkflowQueueName, runTaskWorkflow } from "./workflow/index.js"; + +export interface TaskInput { + workspaceId: string; + repoId: string; + taskId: string; + repoRemote: string; + repoLocalPath: string; + branchName: string | null; + title: string | null; + task: string; + providerId: ProviderId; + agentType: AgentType | null; + explicitTitle: string | null; + explicitBranchName: string | null; + initialPrompt: string | null; +} + +interface InitializeCommand { + providerId?: ProviderId; +} + +interface TaskActionCommand { + reason?: string; +} + +interface TaskTabCommand { + tabId: string; +} + +interface TaskStatusSyncCommand { + sessionId: string; + status: "running" | "idle" | "error"; + at: number; +} + +interface TaskWorkbenchValueCommand { + value: string; +} + +interface TaskWorkbenchSessionTitleCommand { + sessionId: string; + title: string; +} + +interface TaskWorkbenchSessionUnreadCommand { + sessionId: string; + unread: boolean; +} + +interface TaskWorkbenchUpdateDraftCommand { + sessionId: string; + text: string; + attachments: Array; +} + +interface TaskWorkbenchChangeModelCommand { + sessionId: string; + model: string; +} + +interface TaskWorkbenchSendMessageCommand { + sessionId: string; + text: string; + attachments: Array; +} + +interface TaskWorkbenchCreateSessionCommand { + model?: string; +} + +interface TaskWorkbenchSessionCommand { + sessionId: string; +} + +export const task = actor({ + db: taskDb, + queues: Object.fromEntries(TASK_QUEUE_NAMES.map((name) => [name, queue()])), + options: { + actionTimeout: 5 * 60_000, + }, + createState: (_c, input: TaskInput) => ({ + workspaceId: input.workspaceId, + repoId: input.repoId, + taskId: input.taskId, + repoRemote: input.repoRemote, + repoLocalPath: input.repoLocalPath, + branchName: input.branchName, + title: input.title, + task: input.task, + providerId: input.providerId, + 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 { + const self = selfTask(c); + const result = await self.send(taskWorkflowQueueName("task.command.initialize"), cmd ?? {}, { + wait: true, + timeout: 60_000, + }); + return expectQueueResponse(result); + }, + + async provision(c, cmd: InitializeCommand): Promise<{ ok: true }> { + const self = selfTask(c); + await self.send(taskWorkflowQueueName("task.command.provision"), cmd ?? {}, { + wait: true, + timeout: 30 * 60_000, + }); + return { ok: true }; + }, + + async attach(c, cmd?: TaskActionCommand): Promise<{ target: string; sessionId: string | null }> { + const self = selfTask(c); + const result = await self.send(taskWorkflowQueueName("task.command.attach"), cmd ?? {}, { + wait: true, + timeout: 20_000, + }); + return expectQueueResponse<{ target: string; sessionId: string | null }>(result); + }, + + async switch(c): Promise<{ switchTarget: string }> { + const self = selfTask(c); + const result = await self.send( + taskWorkflowQueueName("task.command.switch"), + {}, + { + wait: true, + timeout: 20_000, + }, + ); + return expectQueueResponse<{ switchTarget: string }>(result); + }, + + async push(c, cmd?: TaskActionCommand): Promise { + const self = selfTask(c); + await self.send(taskWorkflowQueueName("task.command.push"), cmd ?? {}, { + wait: true, + timeout: 180_000, + }); + }, + + async sync(c, cmd?: TaskActionCommand): Promise { + const self = selfTask(c); + await self.send(taskWorkflowQueueName("task.command.sync"), cmd ?? {}, { + wait: true, + timeout: 30_000, + }); + }, + + async merge(c, cmd?: TaskActionCommand): Promise { + const self = selfTask(c); + await self.send(taskWorkflowQueueName("task.command.merge"), cmd ?? {}, { + wait: true, + timeout: 30_000, + }); + }, + + async archive(c, cmd?: TaskActionCommand): Promise { + const self = selfTask(c); + void self + .send(taskWorkflowQueueName("task.command.archive"), cmd ?? {}, { + wait: true, + timeout: 60_000, + }) + .catch((error: unknown) => { + c.log.warn({ + msg: "archive command failed", + error: error instanceof Error ? error.message : String(error), + }); + }); + }, + + async kill(c, cmd?: TaskActionCommand): Promise { + const self = selfTask(c); + await self.send(taskWorkflowQueueName("task.command.kill"), cmd ?? {}, { + wait: true, + timeout: 60_000, + }); + }, + + async get(c): Promise { + return await getCurrentRecord({ db: c.db, state: c.state }); + }, + + async getWorkbench(c) { + return await getWorkbenchTask(c); + }, + + async markWorkbenchUnread(c): Promise { + const self = selfTask(c); + await self.send( + taskWorkflowQueueName("task.command.workbench.mark_unread"), + {}, + { + wait: true, + timeout: 20_000, + }, + ); + }, + + async renameWorkbenchTask(c, input: TaskWorkbenchRenameInput): Promise { + const self = selfTask(c); + await self.send(taskWorkflowQueueName("task.command.workbench.rename_task"), { value: input.value } satisfies TaskWorkbenchValueCommand, { + 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: true, + timeout: 5 * 60_000, + }); + }, + + async createWorkbenchSession(c, input?: { model?: string }): Promise<{ tabId: string }> { + const self = selfTask(c); + const result = await self.send( + taskWorkflowQueueName("task.command.workbench.create_session"), + { ...(input?.model ? { model: input.model } : {}) } satisfies TaskWorkbenchCreateSessionCommand, + { + wait: true, + timeout: 5 * 60_000, + }, + ); + return expectQueueResponse<{ tabId: string }>(result); + }, + + async renameWorkbenchSession(c, input: TaskWorkbenchRenameSessionInput): Promise { + const self = selfTask(c); + await self.send( + taskWorkflowQueueName("task.command.workbench.rename_session"), + { sessionId: input.tabId, title: input.title } satisfies TaskWorkbenchSessionTitleCommand, + { + wait: true, + timeout: 20_000, + }, + ); + }, + + async setWorkbenchSessionUnread(c, input: TaskWorkbenchSetSessionUnreadInput): Promise { + const self = selfTask(c); + await self.send( + taskWorkflowQueueName("task.command.workbench.set_session_unread"), + { sessionId: input.tabId, unread: input.unread } satisfies TaskWorkbenchSessionUnreadCommand, + { + wait: true, + timeout: 20_000, + }, + ); + }, + + async updateWorkbenchDraft(c, input: TaskWorkbenchUpdateDraftInput): Promise { + const self = selfTask(c); + await self.send( + taskWorkflowQueueName("task.command.workbench.update_draft"), + { + sessionId: input.tabId, + text: input.text, + attachments: input.attachments, + } satisfies TaskWorkbenchUpdateDraftCommand, + { + wait: true, + timeout: 20_000, + }, + ); + }, + + async changeWorkbenchModel(c, input: TaskWorkbenchChangeModelInput): Promise { + const self = selfTask(c); + await self.send( + taskWorkflowQueueName("task.command.workbench.change_model"), + { sessionId: input.tabId, model: input.model } satisfies TaskWorkbenchChangeModelCommand, + { + wait: true, + timeout: 20_000, + }, + ); + }, + + async sendWorkbenchMessage(c, input: TaskWorkbenchSendMessageInput): Promise { + const self = selfTask(c); + await self.send( + taskWorkflowQueueName("task.command.workbench.send_message"), + { + sessionId: input.tabId, + text: input.text, + attachments: input.attachments, + } satisfies TaskWorkbenchSendMessageCommand, + { + wait: true, + timeout: 10 * 60_000, + }, + ); + }, + + async stopWorkbenchSession(c, input: TaskTabCommand): Promise { + const self = selfTask(c); + await self.send(taskWorkflowQueueName("task.command.workbench.stop_session"), { sessionId: input.tabId } satisfies TaskWorkbenchSessionCommand, { + wait: true, + timeout: 5 * 60_000, + }); + }, + + async syncWorkbenchSessionStatus(c, input: TaskStatusSyncCommand): Promise { + const self = selfTask(c); + await self.send(taskWorkflowQueueName("task.command.workbench.sync_session_status"), input, { + wait: true, + timeout: 20_000, + }); + }, + + async closeWorkbenchSession(c, input: TaskTabCommand): Promise { + const self = selfTask(c); + await self.send(taskWorkflowQueueName("task.command.workbench.close_session"), { sessionId: input.tabId } satisfies TaskWorkbenchSessionCommand, { + wait: true, + timeout: 5 * 60_000, + }); + }, + + async publishWorkbenchPr(c): Promise { + const self = selfTask(c); + await self.send( + taskWorkflowQueueName("task.command.workbench.publish_pr"), + {}, + { + wait: true, + timeout: 10 * 60_000, + }, + ); + }, + + async revertWorkbenchFile(c, input: { path: string }): Promise { + const self = selfTask(c); + await self.send(taskWorkflowQueueName("task.command.workbench.revert_file"), input, { + wait: true, + timeout: 5 * 60_000, + }); + }, + }, + run: workflow(runTaskWorkflow), +}); + +export { TASK_QUEUE_NAMES }; diff --git a/factory/packages/backend/src/actors/handoff/workbench.ts b/foundry/packages/backend/src/actors/task/workbench.ts similarity index 93% rename from factory/packages/backend/src/actors/handoff/workbench.ts rename to foundry/packages/backend/src/actors/task/workbench.ts index 8bd9f6c..1a6c2ef 100644 --- a/factory/packages/backend/src/actors/handoff/workbench.ts +++ b/foundry/packages/backend/src/actors/task/workbench.ts @@ -2,15 +2,15 @@ import { basename } from "node:path"; import { asc, eq } from "drizzle-orm"; import { getActorRuntimeContext } from "../context.js"; -import { getOrCreateHandoffStatusSync, getOrCreateProject, getOrCreateWorkspace, getSandboxInstance } from "../handles.js"; -import { handoff as handoffTable, handoffRuntime, handoffWorkbenchSessions } from "./db/schema.js"; +import { getOrCreateTaskStatusSync, getOrCreateProject, getOrCreateWorkspace, getSandboxInstance } from "../handles.js"; +import { task as taskTable, taskRuntime, taskWorkbenchSessions } from "./db/schema.js"; import { getCurrentRecord } from "./workflow/common.js"; const STATUS_SYNC_INTERVAL_MS = 1_000; async function ensureWorkbenchSessionTable(c: any): Promise { await c.db.execute(` - CREATE TABLE IF NOT EXISTS handoff_workbench_sessions ( + CREATE TABLE IF NOT EXISTS task_workbench_sessions ( session_id text PRIMARY KEY NOT NULL, session_name text NOT NULL, model text NOT NULL, @@ -85,7 +85,7 @@ export function shouldMarkSessionUnreadForStatus(meta: { thinkingSinceMs?: numbe async function listSessionMetaRows(c: any, options?: { includeClosed?: boolean }): Promise> { await ensureWorkbenchSessionTable(c); - const rows = await c.db.select().from(handoffWorkbenchSessions).orderBy(asc(handoffWorkbenchSessions.createdAt)).all(); + const rows = await c.db.select().from(taskWorkbenchSessions).orderBy(asc(taskWorkbenchSessions.createdAt)).all(); const mapped = rows.map((row: any) => ({ ...row, id: row.sessionId, @@ -111,7 +111,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(handoffWorkbenchSessions).where(eq(handoffWorkbenchSessions.sessionId, sessionId)).get(); + const row = await c.db.select().from(taskWorkbenchSessions).where(eq(taskWorkbenchSessions.sessionId, sessionId)).get(); if (!row) { return null; @@ -150,7 +150,7 @@ async function ensureSessionMeta( const unread = params.unread ?? false; await c.db - .insert(handoffWorkbenchSessions) + .insert(taskWorkbenchSessions) .values({ sessionId: params.sessionId, sessionName, @@ -173,12 +173,12 @@ async function ensureSessionMeta( async function updateSessionMeta(c: any, sessionId: string, values: Record): Promise { await ensureSessionMeta(c, { sessionId }); await c.db - .update(handoffWorkbenchSessions) + .update(taskWorkbenchSessions) .set({ ...values, updatedAt: Date.now(), }) - .where(eq(handoffWorkbenchSessions.sessionId, sessionId)) + .where(eq(taskWorkbenchSessions.sessionId, sessionId)) .run(); return await readSessionMeta(c, sessionId); } @@ -427,7 +427,7 @@ export async function ensureWorkbenchSeeded(c: any): Promise { return record; } -export async function getWorkbenchHandoff(c: any): Promise { +export async function getWorkbenchTask(c: any): Promise { const record = await ensureWorkbenchSeeded(c); const gitState = await collectWorkbenchGitState(c, record); const sessions = await listSessionMetaRows(c); @@ -462,9 +462,9 @@ export async function getWorkbenchHandoff(c: any): Promise { } return { - id: c.state.handoffId, + id: c.state.taskId, repoId: c.state.repoId, - title: record.title ?? "New Handoff", + title: record.title ?? "New Task", status: record.status === "archived" ? "archived" : record.status === "running" ? "running" : record.status === "idle" ? "idle" : "new", repoName: repoLabelFromRemote(c.state.repoRemote), updatedAtMs: record.updatedAt, @@ -477,19 +477,19 @@ export async function getWorkbenchHandoff(c: any): Promise { }; } -export async function renameWorkbenchHandoff(c: any, value: string): Promise { +export async function renameWorkbenchTask(c: any, value: string): Promise { const nextTitle = value.trim(); if (!nextTitle) { - throw new Error("handoff title is required"); + throw new Error("task title is required"); } await c.db - .update(handoffTable) + .update(taskTable) .set({ title: nextTitle, updatedAt: Date.now(), }) - .where(eq(handoffTable.id, 1)) + .where(eq(taskTable.id, 1)) .run(); c.state.title = nextTitle; await notifyWorkbenchUpdated(c); @@ -503,7 +503,7 @@ export async function renameWorkbenchBranch(c: any, value: string): Promise { const { driver } = getActorRuntimeContext(); const created = await driver.github.createPr(c.state.repoLocalPath, record.branchName, record.title ?? c.state.task); await c.db - .update(handoffTable) + .update(taskTable) .set({ prSubmitted: 1, updatedAt: Date.now(), }) - .where(eq(handoffTable.id, 1)) + .where(eq(taskTable.id, 1)) .run(); await notifyWorkbenchUpdated(c); } diff --git a/factory/packages/backend/src/actors/handoff/workflow/commands.ts b/foundry/packages/backend/src/actors/task/workflow/commands.ts similarity index 68% rename from factory/packages/backend/src/actors/handoff/workflow/commands.ts rename to foundry/packages/backend/src/actors/task/workflow/commands.ts index 7752d0d..cc72ebf 100644 --- a/factory/packages/backend/src/actors/handoff/workflow/commands.ts +++ b/foundry/packages/backend/src/actors/task/workflow/commands.ts @@ -1,10 +1,10 @@ // @ts-nocheck import { eq } from "drizzle-orm"; import { getActorRuntimeContext } from "../../context.js"; -import { getOrCreateHandoffStatusSync } from "../../handles.js"; +import { getOrCreateTaskStatusSync } from "../../handles.js"; import { logActorWarning, resolveErrorMessage } from "../../logging.js"; -import { handoff as handoffTable, handoffRuntime } from "../db/schema.js"; -import { HANDOFF_ROW_ID, appendHistory, getCurrentRecord, setHandoffState } from "./common.js"; +import { task as taskTable, taskRuntime } from "../db/schema.js"; +import { TASK_ROW_ID, appendHistory, getCurrentRecord, setTaskState } from "./common.js"; import { pushActiveBranchActivity } from "./push.js"; async function withTimeout(promise: Promise, timeoutMs: number, label: string): Promise { @@ -33,7 +33,7 @@ export async function handleAttachActivity(loopCtx: any, msg: any): Promise { const db = loopCtx.db; - const runtime = await db.select({ switchTarget: handoffRuntime.activeSwitchTarget }).from(handoffRuntime).where(eq(handoffRuntime.id, HANDOFF_ROW_ID)).get(); + const runtime = await db.select({ switchTarget: taskRuntime.activeSwitchTarget }).from(taskRuntime).where(eq(taskRuntime.id, TASK_ROW_ID)).get(); await msg.complete({ switchTarget: runtime?.switchTarget ?? "" }); } @@ -54,48 +54,48 @@ export async function handleSwitchActivity(loopCtx: any, msg: any): Promise { await pushActiveBranchActivity(loopCtx, { reason: msg.body?.reason ?? null, - historyKind: "handoff.push", + historyKind: "task.push", }); 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(handoffRuntime).set({ statusMessage, updatedAt: Date.now() }).where(eq(handoffRuntime.id, HANDOFF_ROW_ID)).run(); + 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 }); await msg.complete({ ok: true }); } export async function handleArchiveActivity(loopCtx: any, msg: any): Promise { - await setHandoffState(loopCtx, "archive_stop_status_sync", "stopping status sync"); + await setTaskState(loopCtx, "archive_stop_status_sync", "stopping status sync"); const record = await getCurrentRecord(loopCtx); if (record.activeSandboxId && record.activeSessionId) { try { - const sync = await getOrCreateHandoffStatusSync( + const sync = await getOrCreateTaskStatusSync( loopCtx, loopCtx.state.workspaceId, loopCtx.state.repoId, - loopCtx.state.handoffId, + loopCtx.state.taskId, record.activeSandboxId, record.activeSessionId, { workspaceId: loopCtx.state.workspaceId, repoId: loopCtx.state.repoId, - handoffId: loopCtx.state.handoffId, + taskId: loopCtx.state.taskId, providerId: record.providerId, sandboxId: record.activeSandboxId, sessionId: record.activeSessionId, intervalMs: 2_000, }, ); - await withTimeout(sync.stop(), 15_000, "handoff status sync stop"); + await withTimeout(sync.stop(), 15_000, "task status sync stop"); } catch (error) { - logActorWarning("handoff.commands", "failed to stop status sync during archive", { + logActorWarning("task.commands", "failed to stop status sync during archive", { workspaceId: loopCtx.state.workspaceId, repoId: loopCtx.state.repoId, - handoffId: loopCtx.state.handoffId, + taskId: loopCtx.state.taskId, sandboxId: record.activeSandboxId, sessionId: record.activeSessionId, error: resolveErrorMessage(error), @@ -104,13 +104,13 @@ export async function handleArchiveActivity(loopCtx: any, msg: any): Promise sb.sandboxId === record.activeSandboxId) ?? null; const provider = providers.get(activeSandbox?.providerId ?? record.providerId); const workspaceId = loopCtx.state.workspaceId; const repoId = loopCtx.state.repoId; - const handoffId = loopCtx.state.handoffId; + const taskId = loopCtx.state.taskId; const sandboxId = record.activeSandboxId; // Do not block archive finalization on provider stop. Some provider stop calls can @@ -123,10 +123,10 @@ export async function handleArchiveActivity(loopCtx: any, msg: any): Promise { - logActorWarning("handoff.commands", "failed to release sandbox during archive", { + logActorWarning("task.commands", "failed to release sandbox during archive", { workspaceId, repoId, - handoffId, + taskId, sandboxId, error: resolveErrorMessage(error), }); @@ -134,21 +134,17 @@ export async function handleArchiveActivity(loopCtx: any, msg: any): Promise { - await setHandoffState(loopCtx, "kill_destroy_sandbox", "destroying sandbox"); + await setTaskState(loopCtx, "kill_destroy_sandbox", "destroying sandbox"); const record = await getCurrentRecord(loopCtx); if (!record.activeSandboxId) { return; @@ -164,13 +160,13 @@ export async function killDestroySandboxActivity(loopCtx: any): Promise { } export async function killWriteDbActivity(loopCtx: any, msg: any): Promise { - await setHandoffState(loopCtx, "kill_finalize", "finalizing kill"); + await setTaskState(loopCtx, "kill_finalize", "finalizing kill"); const db = loopCtx.db; - await db.update(handoffTable).set({ status: "killed", updatedAt: Date.now() }).where(eq(handoffTable.id, HANDOFF_ROW_ID)).run(); + await db.update(taskTable).set({ status: "killed", updatedAt: Date.now() }).where(eq(taskTable.id, TASK_ROW_ID)).run(); - await db.update(handoffRuntime).set({ statusMessage: "killed", updatedAt: Date.now() }).where(eq(handoffRuntime.id, HANDOFF_ROW_ID)).run(); + await db.update(taskRuntime).set({ statusMessage: "killed", updatedAt: Date.now() }).where(eq(taskRuntime.id, TASK_ROW_ID)).run(); - await appendHistory(loopCtx, "handoff.kill", { reason: msg.body?.reason ?? null }); + await appendHistory(loopCtx, "task.kill", { reason: msg.body?.reason ?? null }); await msg.complete({ ok: true }); } diff --git a/factory/packages/backend/src/actors/handoff/workflow/common.ts b/foundry/packages/backend/src/actors/task/workflow/common.ts similarity index 66% rename from factory/packages/backend/src/actors/handoff/workflow/common.ts rename to foundry/packages/backend/src/actors/task/workflow/common.ts index 60007d6..251c288 100644 --- a/factory/packages/backend/src/actors/handoff/workflow/common.ts +++ b/foundry/packages/backend/src/actors/task/workflow/common.ts @@ -1,11 +1,11 @@ // @ts-nocheck import { eq } from "drizzle-orm"; -import type { HandoffRecord, HandoffStatus } from "@openhandoff/shared"; +import type { TaskRecord, TaskStatus } from "@sandbox-agent/foundry-shared"; import { getOrCreateWorkspace } from "../../handles.js"; -import { handoff as handoffTable, handoffRuntime, handoffSandboxes } from "../db/schema.js"; +import { task as taskTable, taskRuntime, taskSandboxes } from "../db/schema.js"; import { historyKey } from "../../keys.js"; -export const HANDOFF_ROW_ID = 1; +export const TASK_ROW_ID = 1; export function collectErrorMessages(error: unknown): string[] { if (error == null) { @@ -56,16 +56,16 @@ export function buildAgentPrompt(task: string): string { return task.trim(); } -export async function setHandoffState(ctx: any, status: HandoffStatus, statusMessage?: string): Promise { +export async function setTaskState(ctx: any, status: TaskStatus, statusMessage?: string): Promise { const now = Date.now(); const db = ctx.db; - await db.update(handoffTable).set({ status, updatedAt: now }).where(eq(handoffTable.id, HANDOFF_ROW_ID)).run(); + await db.update(taskTable).set({ status, updatedAt: now }).where(eq(taskTable.id, TASK_ROW_ID)).run(); if (statusMessage != null) { await db - .insert(handoffRuntime) + .insert(taskRuntime) .values({ - id: HANDOFF_ROW_ID, + id: TASK_ROW_ID, activeSandboxId: null, activeSessionId: null, activeSwitchTarget: null, @@ -74,7 +74,7 @@ export async function setHandoffState(ctx: any, status: HandoffStatus, statusMes updatedAt: now, }) .onConflictDoUpdate({ - target: handoffRuntime.id, + target: taskRuntime.id, set: { statusMessage, updatedAt: now, @@ -87,50 +87,50 @@ export async function setHandoffState(ctx: any, status: HandoffStatus, statusMes await workspace.notifyWorkbenchUpdated({}); } -export async function getCurrentRecord(ctx: any): Promise { +export async function getCurrentRecord(ctx: any): Promise { const db = ctx.db; const row = await db .select({ - branchName: handoffTable.branchName, - title: handoffTable.title, - task: handoffTable.task, - providerId: handoffTable.providerId, - status: handoffTable.status, - statusMessage: handoffRuntime.statusMessage, - activeSandboxId: handoffRuntime.activeSandboxId, - activeSessionId: handoffRuntime.activeSessionId, - agentType: handoffTable.agentType, - prSubmitted: handoffTable.prSubmitted, - createdAt: handoffTable.createdAt, - updatedAt: handoffTable.updatedAt, + branchName: taskTable.branchName, + title: taskTable.title, + task: taskTable.task, + providerId: taskTable.providerId, + status: taskTable.status, + statusMessage: taskRuntime.statusMessage, + activeSandboxId: taskRuntime.activeSandboxId, + activeSessionId: taskRuntime.activeSessionId, + agentType: taskTable.agentType, + prSubmitted: taskTable.prSubmitted, + createdAt: taskTable.createdAt, + updatedAt: taskTable.updatedAt, }) - .from(handoffTable) - .leftJoin(handoffRuntime, eq(handoffTable.id, handoffRuntime.id)) - .where(eq(handoffTable.id, HANDOFF_ROW_ID)) + .from(taskTable) + .leftJoin(taskRuntime, eq(taskTable.id, taskRuntime.id)) + .where(eq(taskTable.id, TASK_ROW_ID)) .get(); if (!row) { - throw new Error(`Handoff not found: ${ctx.state.handoffId}`); + throw new Error(`Task not found: ${ctx.state.taskId}`); } const sandboxes = await db .select({ - sandboxId: handoffSandboxes.sandboxId, - providerId: handoffSandboxes.providerId, - sandboxActorId: handoffSandboxes.sandboxActorId, - switchTarget: handoffSandboxes.switchTarget, - cwd: handoffSandboxes.cwd, - createdAt: handoffSandboxes.createdAt, - updatedAt: handoffSandboxes.updatedAt, + sandboxId: taskSandboxes.sandboxId, + providerId: taskSandboxes.providerId, + sandboxActorId: taskSandboxes.sandboxActorId, + switchTarget: taskSandboxes.switchTarget, + cwd: taskSandboxes.cwd, + createdAt: taskSandboxes.createdAt, + updatedAt: taskSandboxes.updatedAt, }) - .from(handoffSandboxes) + .from(taskSandboxes) .all(); return { workspaceId: ctx.state.workspaceId, repoId: ctx.state.repoId, repoRemote: ctx.state.repoRemote, - handoffId: ctx.state.handoffId, + taskId: ctx.state.taskId, branchName: row.branchName, title: row.title, task: row.task, @@ -161,7 +161,7 @@ export async function getCurrentRecord(ctx: any): Promise { reviewer: null, createdAt: row.createdAt, updatedAt: row.updatedAt, - } as HandoffRecord; + } as TaskRecord; } export async function appendHistory(ctx: any, kind: string, payload: Record): Promise { @@ -171,7 +171,7 @@ export async function appendHistory(ctx: any, kind: string, payload: Record Promise }) => Promise; +type WorkflowHandler = (loopCtx: any, msg: { name: TaskQueueName; body: any; complete: (response: unknown) => Promise }) => Promise; -const commandHandlers: Record = { - "handoff.command.initialize": async (loopCtx, msg) => { +const commandHandlers: Record = { + "task.command.initialize": async (loopCtx, msg) => { const body = msg.body; await loopCtx.step("init-bootstrap-db", async () => initBootstrapDbActivity(loopCtx, body)); @@ -63,13 +63,13 @@ const commandHandlers: Record = { try { await msg.complete(currentRecord); } catch (error) { - logActorWarning("handoff.workflow", "initialize completion failed", { + logActorWarning("task.workflow", "initialize completion failed", { error: resolveErrorMessage(error), }); } }, - "handoff.command.provision": async (loopCtx, msg) => { + "task.command.provision": async (loopCtx, msg) => { const body = msg.body; await loopCtx.removed("init-failed", "step"); try { @@ -91,10 +91,7 @@ const commandHandlers: Record = { timeout: 60_000, run: async () => initStartSandboxInstanceActivity(loopCtx, body, sandbox, agent), }); - await loopCtx.step( - "init-expose-sandbox", - async () => initExposeSandboxActivity(loopCtx, body, sandbox, sandboxInstanceReady), - ); + await loopCtx.step("init-expose-sandbox", async () => initExposeSandboxActivity(loopCtx, body, sandbox, sandboxInstanceReady)); const session = await loopCtx.step({ name: "init-create-session", timeout: 180_000, @@ -111,50 +108,50 @@ const commandHandlers: Record = { } }, - "handoff.command.attach": async (loopCtx, msg) => { + "task.command.attach": async (loopCtx, msg) => { await loopCtx.step("handle-attach", async () => handleAttachActivity(loopCtx, msg)); }, - "handoff.command.switch": async (loopCtx, msg) => { + "task.command.switch": async (loopCtx, msg) => { await loopCtx.step("handle-switch", async () => handleSwitchActivity(loopCtx, msg)); }, - "handoff.command.push": async (loopCtx, msg) => { + "task.command.push": async (loopCtx, msg) => { await loopCtx.step("handle-push", async () => handlePushActivity(loopCtx, msg)); }, - "handoff.command.sync": async (loopCtx, msg) => { - await loopCtx.step("handle-sync", async () => handleSimpleCommandActivity(loopCtx, msg, "sync requested", "handoff.sync")); + "task.command.sync": async (loopCtx, msg) => { + await loopCtx.step("handle-sync", async () => handleSimpleCommandActivity(loopCtx, msg, "sync requested", "task.sync")); }, - "handoff.command.merge": async (loopCtx, msg) => { - await loopCtx.step("handle-merge", async () => handleSimpleCommandActivity(loopCtx, msg, "merge requested", "handoff.merge")); + "task.command.merge": async (loopCtx, msg) => { + await loopCtx.step("handle-merge", async () => handleSimpleCommandActivity(loopCtx, msg, "merge requested", "task.merge")); }, - "handoff.command.archive": async (loopCtx, msg) => { + "task.command.archive": async (loopCtx, msg) => { await loopCtx.step("handle-archive", async () => handleArchiveActivity(loopCtx, msg)); }, - "handoff.command.kill": async (loopCtx, msg) => { + "task.command.kill": async (loopCtx, msg) => { await loopCtx.step("kill-destroy-sandbox", async () => killDestroySandboxActivity(loopCtx)); await loopCtx.step("kill-write-db", async () => killWriteDbActivity(loopCtx, msg)); }, - "handoff.command.get": async (loopCtx, msg) => { + "task.command.get": async (loopCtx, msg) => { await loopCtx.step("handle-get", async () => handleGetActivity(loopCtx, msg)); }, - "handoff.command.workbench.mark_unread": async (loopCtx, msg) => { + "task.command.workbench.mark_unread": async (loopCtx, msg) => { await loopCtx.step("workbench-mark-unread", async () => markWorkbenchUnread(loopCtx)); await msg.complete({ ok: true }); }, - "handoff.command.workbench.rename_handoff": async (loopCtx, msg) => { - await loopCtx.step("workbench-rename-handoff", async () => renameWorkbenchHandoff(loopCtx, msg.body.value)); + "task.command.workbench.rename_task": async (loopCtx, msg) => { + await loopCtx.step("workbench-rename-task", async () => renameWorkbenchTask(loopCtx, msg.body.value)); await msg.complete({ ok: true }); }, - "handoff.command.workbench.rename_branch": async (loopCtx, msg) => { + "task.command.workbench.rename_branch": async (loopCtx, msg) => { await loopCtx.step({ name: "workbench-rename-branch", timeout: 5 * 60_000, @@ -163,7 +160,7 @@ const commandHandlers: Record = { await msg.complete({ ok: true }); }, - "handoff.command.workbench.create_session": async (loopCtx, msg) => { + "task.command.workbench.create_session": async (loopCtx, msg) => { const created = await loopCtx.step({ name: "workbench-create-session", timeout: 5 * 60_000, @@ -172,27 +169,27 @@ const commandHandlers: Record = { await msg.complete(created); }, - "handoff.command.workbench.rename_session": async (loopCtx, msg) => { + "task.command.workbench.rename_session": async (loopCtx, msg) => { await loopCtx.step("workbench-rename-session", async () => renameWorkbenchSession(loopCtx, msg.body.sessionId, msg.body.title)); await msg.complete({ ok: true }); }, - "handoff.command.workbench.set_session_unread": async (loopCtx, msg) => { + "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)); await msg.complete({ ok: true }); }, - "handoff.command.workbench.update_draft": async (loopCtx, msg) => { + "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)); await msg.complete({ ok: true }); }, - "handoff.command.workbench.change_model": async (loopCtx, msg) => { + "task.command.workbench.change_model": async (loopCtx, msg) => { await loopCtx.step("workbench-change-model", async () => changeWorkbenchModel(loopCtx, msg.body.sessionId, msg.body.model)); await msg.complete({ ok: true }); }, - "handoff.command.workbench.send_message": async (loopCtx, msg) => { + "task.command.workbench.send_message": async (loopCtx, msg) => { await loopCtx.step({ name: "workbench-send-message", timeout: 10 * 60_000, @@ -201,7 +198,7 @@ const commandHandlers: Record = { await msg.complete({ ok: true }); }, - "handoff.command.workbench.stop_session": async (loopCtx, msg) => { + "task.command.workbench.stop_session": async (loopCtx, msg) => { await loopCtx.step({ name: "workbench-stop-session", timeout: 5 * 60_000, @@ -210,12 +207,12 @@ const commandHandlers: Record = { await msg.complete({ ok: true }); }, - "handoff.command.workbench.sync_session_status": async (loopCtx, msg) => { + "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)); await msg.complete({ ok: true }); }, - "handoff.command.workbench.close_session": async (loopCtx, msg) => { + "task.command.workbench.close_session": async (loopCtx, msg) => { await loopCtx.step({ name: "workbench-close-session", timeout: 5 * 60_000, @@ -224,7 +221,7 @@ const commandHandlers: Record = { await msg.complete({ ok: true }); }, - "handoff.command.workbench.publish_pr": async (loopCtx, msg) => { + "task.command.workbench.publish_pr": async (loopCtx, msg) => { await loopCtx.step({ name: "workbench-publish-pr", timeout: 10 * 60_000, @@ -233,7 +230,7 @@ const commandHandlers: Record = { await msg.complete({ ok: true }); }, - "handoff.command.workbench.revert_file": async (loopCtx, msg) => { + "task.command.workbench.revert_file": async (loopCtx, msg) => { await loopCtx.step({ name: "workbench-revert-file", timeout: 5 * 60_000, @@ -242,7 +239,7 @@ const commandHandlers: Record = { await msg.complete({ ok: true }); }, - "handoff.status_sync.result": async (loopCtx, msg) => { + "task.status_sync.result": async (loopCtx, msg) => { const transitionedToIdle = await loopCtx.step("status-update", async () => statusUpdateActivity(loopCtx, msg.body)); if (transitionedToIdle) { @@ -255,16 +252,16 @@ const commandHandlers: Record = { }, }; -export async function runHandoffWorkflow(ctx: any): Promise { - await ctx.loop("handoff-command-loop", async (loopCtx: any) => { +export async function runTaskWorkflow(ctx: any): Promise { + await ctx.loop("task-command-loop", async (loopCtx: any) => { const msg = await loopCtx.queue.next("next-command", { - names: [...HANDOFF_QUEUE_NAMES], + names: [...TASK_QUEUE_NAMES], completable: true, }); if (!msg) { return Loop.continue(undefined); } - const handler = commandHandlers[msg.name as HandoffQueueName]; + const handler = commandHandlers[msg.name as TaskQueueName]; if (handler) { await handler(loopCtx, msg); } diff --git a/factory/packages/backend/src/actors/handoff/workflow/init.ts b/foundry/packages/backend/src/actors/task/workflow/init.ts similarity index 75% rename from factory/packages/backend/src/actors/handoff/workflow/init.ts rename to foundry/packages/backend/src/actors/task/workflow/init.ts index c8c6d0c..9e8b54f 100644 --- a/factory/packages/backend/src/actors/handoff/workflow/init.ts +++ b/foundry/packages/backend/src/actors/task/workflow/init.ts @@ -2,18 +2,11 @@ import { desc, eq } from "drizzle-orm"; import { resolveCreateFlowDecision } from "../../../services/create-flow.js"; import { getActorRuntimeContext } from "../../context.js"; -import { - getOrCreateHandoffStatusSync, - getOrCreateHistory, - getOrCreateProject, - getOrCreateSandboxInstance, - getSandboxInstance, - selfHandoff, -} from "../../handles.js"; +import { getOrCreateTaskStatusSync, getOrCreateHistory, getOrCreateProject, getOrCreateSandboxInstance, getSandboxInstance, selfTask } from "../../handles.js"; import { logActorWarning, resolveErrorMessage } from "../../logging.js"; -import { handoff as handoffTable, handoffRuntime, handoffSandboxes } from "../db/schema.js"; -import { HANDOFF_ROW_ID, appendHistory, buildAgentPrompt, collectErrorMessages, resolveErrorDetail, setHandoffState } from "./common.js"; -import { handoffWorkflowQueueName } from "./queue.js"; +import { task as taskTable, taskRuntime, taskSandboxes } from "../db/schema.js"; +import { TASK_ROW_ID, appendHistory, buildAgentPrompt, collectErrorMessages, resolveErrorDetail, setTaskState } from "./common.js"; +import { taskWorkflowQueueName } from "./queue.js"; const DEFAULT_INIT_CREATE_SANDBOX_ACTIVITY_TIMEOUT_MS = 180_000; @@ -32,10 +25,10 @@ function getInitCreateSandboxActivityTimeoutMs(): number { function debugInit(loopCtx: any, message: string, context?: Record): void { loopCtx.log.debug({ msg: message, - scope: "handoff.init", + scope: "task.init", workspaceId: loopCtx.state.workspaceId, repoId: loopCtx.state.repoId, - handoffId: loopCtx.state.handoffId, + taskId: loopCtx.state.taskId, ...(context ?? {}), }); } @@ -67,9 +60,9 @@ export async function initBootstrapDbActivity(loopCtx: any, body: any): Promise< try { await db - .insert(handoffTable) + .insert(taskTable) .values({ - id: HANDOFF_ROW_ID, + id: TASK_ROW_ID, branchName: loopCtx.state.branchName, title: loopCtx.state.title, task: loopCtx.state.task, @@ -80,7 +73,7 @@ export async function initBootstrapDbActivity(loopCtx: any, body: any): Promise< updatedAt: now, }) .onConflictDoUpdate({ - target: handoffTable.id, + target: taskTable.id, set: { branchName: loopCtx.state.branchName, title: loopCtx.state.title, @@ -94,9 +87,9 @@ export async function initBootstrapDbActivity(loopCtx: any, body: any): Promise< .run(); await db - .insert(handoffRuntime) + .insert(taskRuntime) .values({ - id: HANDOFF_ROW_ID, + id: TASK_ROW_ID, activeSandboxId: null, activeSessionId: null, activeSwitchTarget: null, @@ -105,7 +98,7 @@ export async function initBootstrapDbActivity(loopCtx: any, body: any): Promise< updatedAt: now, }) .onConflictDoUpdate({ - target: handoffRuntime.id, + target: taskRuntime.id, set: { activeSandboxId: null, activeSessionId: null, @@ -118,36 +111,36 @@ export async function initBootstrapDbActivity(loopCtx: any, body: any): Promise< .run(); } catch (error) { const detail = resolveErrorMessage(error); - throw new Error(`handoff init bootstrap db failed: ${detail}`); + throw new Error(`task init bootstrap db failed: ${detail}`); } } export async function initEnqueueProvisionActivity(loopCtx: any, body: any): Promise { - await setHandoffState(loopCtx, "init_enqueue_provision", "provision queued"); - const self = selfHandoff(loopCtx); + await setTaskState(loopCtx, "init_enqueue_provision", "provision queued"); + const self = selfTask(loopCtx); void self - .send(handoffWorkflowQueueName("handoff.command.provision"), body, { + .send(taskWorkflowQueueName("task.command.provision"), body, { wait: false, }) .catch((error: unknown) => { - logActorWarning("handoff.init", "background provision command failed", { + logActorWarning("task.init", "background provision command failed", { workspaceId: loopCtx.state.workspaceId, repoId: loopCtx.state.repoId, - handoffId: loopCtx.state.handoffId, + taskId: loopCtx.state.taskId, error: resolveErrorMessage(error), }); }); } export async function initEnsureNameActivity(loopCtx: any): Promise { - await setHandoffState(loopCtx, "init_ensure_name", "determining title and branch"); + await setTaskState(loopCtx, "init_ensure_name", "determining title and branch"); const existing = await loopCtx.db .select({ - branchName: handoffTable.branchName, - title: handoffTable.title, + branchName: taskTable.branchName, + title: taskTable.title, }) - .from(handoffTable) - .where(eq(handoffTable.id, HANDOFF_ROW_ID)) + .from(taskTable) + .where(eq(taskTable.id, TASK_ROW_ID)) .get(); if (existing?.branchName && existing?.title) { @@ -160,10 +153,10 @@ export async function initEnsureNameActivity(loopCtx: any): Promise { try { await driver.git.fetch(loopCtx.state.repoLocalPath); } catch (error) { - logActorWarning("handoff.init", "fetch before naming failed", { + logActorWarning("task.init", "fetch before naming failed", { workspaceId: loopCtx.state.workspaceId, repoId: loopCtx.state.repoId, - handoffId: loopCtx.state.handoffId, + taskId: loopCtx.state.taskId, error: resolveErrorMessage(error), }); } @@ -177,18 +170,18 @@ export async function initEnsureNameActivity(loopCtx: any): Promise { explicitTitle: loopCtx.state.explicitTitle ?? undefined, explicitBranchName: loopCtx.state.explicitBranchName ?? undefined, localBranches: remoteBranches, - handoffBranches: reservedBranches, + taskBranches: reservedBranches, }); const now = Date.now(); await loopCtx.db - .update(handoffTable) + .update(taskTable) .set({ branchName: resolved.branchName, title: resolved.title, updatedAt: now, }) - .where(eq(handoffTable.id, HANDOFF_ROW_ID)) + .where(eq(taskTable.id, TASK_ROW_ID)) .run(); loopCtx.state.branchName = resolved.branchName; @@ -197,34 +190,34 @@ export async function initEnsureNameActivity(loopCtx: any): Promise { loopCtx.state.explicitBranchName = null; await loopCtx.db - .update(handoffRuntime) + .update(taskRuntime) .set({ statusMessage: "provisioning", updatedAt: now, }) - .where(eq(handoffRuntime.id, HANDOFF_ROW_ID)) + .where(eq(taskRuntime.id, TASK_ROW_ID)) .run(); - await project.registerHandoffBranch({ - handoffId: loopCtx.state.handoffId, + await project.registerTaskBranch({ + taskId: loopCtx.state.taskId, branchName: resolved.branchName, }); - await appendHistory(loopCtx, "handoff.named", { + await appendHistory(loopCtx, "task.named", { title: resolved.title, branchName: resolved.branchName, }); } export async function initAssertNameActivity(loopCtx: any): Promise { - await setHandoffState(loopCtx, "init_assert_name", "validating naming"); + await setTaskState(loopCtx, "init_assert_name", "validating naming"); if (!loopCtx.state.branchName) { - throw new Error("handoff branchName is not initialized"); + throw new Error("task branchName is not initialized"); } } export async function initCreateSandboxActivity(loopCtx: any, body: any): Promise { - await setHandoffState(loopCtx, "init_create_sandbox", "creating sandbox"); + await setTaskState(loopCtx, "init_create_sandbox", "creating sandbox"); const { providers } = getActorRuntimeContext(); const providerId = body?.providerId ?? loopCtx.state.providerId; const provider = providers.get(providerId); @@ -238,17 +231,13 @@ export async function initCreateSandboxActivity(loopCtx: any, body: any): Promis }); if (provider.capabilities().supportsSessionReuse) { - const runtime = await loopCtx.db - .select({ activeSandboxId: handoffRuntime.activeSandboxId }) - .from(handoffRuntime) - .where(eq(handoffRuntime.id, HANDOFF_ROW_ID)) - .get(); + const runtime = await loopCtx.db.select({ activeSandboxId: taskRuntime.activeSandboxId }).from(taskRuntime).where(eq(taskRuntime.id, TASK_ROW_ID)).get(); const existing = await loopCtx.db - .select({ sandboxId: handoffSandboxes.sandboxId }) - .from(handoffSandboxes) - .where(eq(handoffSandboxes.providerId, providerId)) - .orderBy(desc(handoffSandboxes.updatedAt)) + .select({ sandboxId: taskSandboxes.sandboxId }) + .from(taskSandboxes) + .where(eq(taskSandboxes.providerId, providerId)) + .orderBy(desc(taskSandboxes.updatedAt)) .limit(1) .get(); @@ -269,10 +258,10 @@ export async function initCreateSandboxActivity(loopCtx: any, body: any): Promis }); return resumed; } catch (error) { - logActorWarning("handoff.init", "resume sandbox failed; creating a new sandbox", { + logActorWarning("task.init", "resume sandbox failed; creating a new sandbox", { workspaceId: loopCtx.state.workspaceId, repoId: loopCtx.state.repoId, - handoffId: loopCtx.state.handoffId, + taskId: loopCtx.state.taskId, sandboxId, error: resolveErrorMessage(error), }); @@ -291,7 +280,7 @@ export async function initCreateSandboxActivity(loopCtx: any, body: any): Promis repoId: loopCtx.state.repoId, repoRemote: loopCtx.state.repoRemote, branchName: loopCtx.state.branchName, - handoffId: loopCtx.state.handoffId, + taskId: loopCtx.state.taskId, debug: (message, context) => debugInit(loopCtx, message, context), }), ); @@ -311,7 +300,7 @@ export async function initCreateSandboxActivity(loopCtx: any, body: any): Promis } export async function initEnsureAgentActivity(loopCtx: any, body: any, sandbox: any): Promise { - await setHandoffState(loopCtx, "init_ensure_agent", "ensuring sandbox agent"); + await setTaskState(loopCtx, "init_ensure_agent", "ensuring sandbox agent"); const { providers } = getActorRuntimeContext(); const providerId = body?.providerId ?? loopCtx.state.providerId; const provider = providers.get(providerId); @@ -322,7 +311,7 @@ export async function initEnsureAgentActivity(loopCtx: any, body: any, sandbox: } export async function initStartSandboxInstanceActivity(loopCtx: any, body: any, sandbox: any, agent: any): Promise { - await setHandoffState(loopCtx, "init_start_sandbox_instance", "starting sandbox runtime"); + await setTaskState(loopCtx, "init_start_sandbox_instance", "starting sandbox runtime"); try { const providerId = body?.providerId ?? loopCtx.state.providerId; const sandboxInstance = await getOrCreateSandboxInstance(loopCtx, loopCtx.state.workspaceId, providerId, sandbox.sandboxId, { @@ -354,7 +343,7 @@ export async function initStartSandboxInstanceActivity(loopCtx: any, body: any, } export async function initCreateSessionActivity(loopCtx: any, body: any, sandbox: any, sandboxInstanceReady: any): Promise { - await setHandoffState(loopCtx, "init_create_session", "creating agent session"); + await setTaskState(loopCtx, "init_create_session", "creating agent session"); if (!sandboxInstanceReady.ok) { return { id: null, @@ -370,35 +359,21 @@ export async function initCreateSessionActivity(loopCtx: any, body: any, sandbox const cwd = sandbox.metadata && typeof (sandbox.metadata as any).cwd === "string" ? ((sandbox.metadata as any).cwd as string) : undefined; return await sandboxInstance.createSession({ - prompt: - typeof loopCtx.state.initialPrompt === "string" - ? loopCtx.state.initialPrompt - : buildAgentPrompt(loopCtx.state.task), + prompt: typeof loopCtx.state.initialPrompt === "string" ? loopCtx.state.initialPrompt : buildAgentPrompt(loopCtx.state.task), cwd, agent: (loopCtx.state.agentType ?? config.default_agent) as any, }); } -export async function initExposeSandboxActivity( - loopCtx: any, - body: any, - sandbox: any, - sandboxInstanceReady?: { actorId?: string | null } -): Promise { +export async function initExposeSandboxActivity(loopCtx: any, body: any, sandbox: any, sandboxInstanceReady?: { actorId?: string | null }): Promise { const providerId = body?.providerId ?? loopCtx.state.providerId; const now = Date.now(); const db = loopCtx.db; - const activeCwd = - sandbox.metadata && typeof (sandbox.metadata as any).cwd === "string" - ? ((sandbox.metadata as any).cwd as string) - : null; - const sandboxActorId = - typeof sandboxInstanceReady?.actorId === "string" && sandboxInstanceReady.actorId.length > 0 - ? sandboxInstanceReady.actorId - : null; + const activeCwd = sandbox.metadata && typeof (sandbox.metadata as any).cwd === "string" ? ((sandbox.metadata as any).cwd as string) : null; + const sandboxActorId = typeof sandboxInstanceReady?.actorId === "string" && sandboxInstanceReady.actorId.length > 0 ? sandboxInstanceReady.actorId : null; await db - .insert(handoffSandboxes) + .insert(taskSandboxes) .values({ sandboxId: sandbox.sandboxId, providerId, @@ -407,31 +382,31 @@ export async function initExposeSandboxActivity( cwd: activeCwd, statusMessage: "sandbox ready", createdAt: now, - updatedAt: now + updatedAt: now, }) .onConflictDoUpdate({ - target: handoffSandboxes.sandboxId, + target: taskSandboxes.sandboxId, set: { providerId, sandboxActorId, switchTarget: sandbox.switchTarget, cwd: activeCwd, statusMessage: "sandbox ready", - updatedAt: now - } + updatedAt: now, + }, }) .run(); await db - .update(handoffRuntime) + .update(taskRuntime) .set({ activeSandboxId: sandbox.sandboxId, activeSwitchTarget: sandbox.switchTarget, activeCwd, statusMessage: "sandbox ready", - updatedAt: now + updatedAt: now, }) - .where(eq(handoffRuntime.id, HANDOFF_ROW_ID)) + .where(eq(taskRuntime.id, TASK_ROW_ID)) .run(); } @@ -442,7 +417,7 @@ export async function initWriteDbActivity( session: any, sandboxInstanceReady?: { actorId?: string | null }, ): Promise { - await setHandoffState(loopCtx, "init_write_db", "persisting handoff runtime"); + await setTaskState(loopCtx, "init_write_db", "persisting task runtime"); const providerId = body?.providerId ?? loopCtx.state.providerId; const { config } = getActorRuntimeContext(); const now = Date.now(); @@ -456,18 +431,18 @@ export async function initWriteDbActivity( const sandboxActorId = typeof sandboxInstanceReady?.actorId === "string" && sandboxInstanceReady.actorId.length > 0 ? sandboxInstanceReady.actorId : null; await db - .update(handoffTable) + .update(taskTable) .set({ providerId, status: sessionHealthy ? "running" : "error", agentType: loopCtx.state.agentType ?? config.default_agent, updatedAt: now, }) - .where(eq(handoffTable.id, HANDOFF_ROW_ID)) + .where(eq(taskTable.id, TASK_ROW_ID)) .run(); await db - .insert(handoffSandboxes) + .insert(taskSandboxes) .values({ sandboxId: sandbox.sandboxId, providerId, @@ -479,7 +454,7 @@ export async function initWriteDbActivity( updatedAt: now, }) .onConflictDoUpdate({ - target: handoffSandboxes.sandboxId, + target: taskSandboxes.sandboxId, set: { providerId, sandboxActorId, @@ -492,9 +467,9 @@ export async function initWriteDbActivity( .run(); await db - .insert(handoffRuntime) + .insert(taskRuntime) .values({ - id: HANDOFF_ROW_ID, + id: TASK_ROW_ID, activeSandboxId: sandbox.sandboxId, activeSessionId, activeSwitchTarget: sandbox.switchTarget, @@ -503,7 +478,7 @@ export async function initWriteDbActivity( updatedAt: now, }) .onConflictDoUpdate({ - target: handoffRuntime.id, + target: taskRuntime.id, set: { activeSandboxId: sandbox.sandboxId, activeSessionId, @@ -522,25 +497,17 @@ export async function initStartStatusSyncActivity(loopCtx: any, body: any, sandb return; } - await setHandoffState(loopCtx, "init_start_status_sync", "starting session status sync"); + await setTaskState(loopCtx, "init_start_status_sync", "starting session status sync"); const providerId = body?.providerId ?? loopCtx.state.providerId; - const sync = await getOrCreateHandoffStatusSync( - loopCtx, - loopCtx.state.workspaceId, - loopCtx.state.repoId, - loopCtx.state.handoffId, - sandbox.sandboxId, + const sync = await getOrCreateTaskStatusSync(loopCtx, loopCtx.state.workspaceId, loopCtx.state.repoId, loopCtx.state.taskId, sandbox.sandboxId, sessionId, { + workspaceId: loopCtx.state.workspaceId, + repoId: loopCtx.state.repoId, + taskId: loopCtx.state.taskId, + providerId, + sandboxId: sandbox.sandboxId, sessionId, - { - workspaceId: loopCtx.state.workspaceId, - repoId: loopCtx.state.repoId, - handoffId: loopCtx.state.handoffId, - providerId, - sandboxId: sandbox.sandboxId, - sessionId, - intervalMs: 2_000, - }, - ); + intervalMs: 2_000, + }); await sync.start(); await sync.force(); @@ -551,12 +518,12 @@ export async function initCompleteActivity(loopCtx: any, body: any, sandbox: any const sessionId = session?.id ?? null; const sessionHealthy = Boolean(sessionId) && session?.status !== "error"; if (sessionHealthy) { - await setHandoffState(loopCtx, "init_complete", "handoff initialized"); + await setTaskState(loopCtx, "init_complete", "task initialized"); const history = await getOrCreateHistory(loopCtx, loopCtx.state.workspaceId, loopCtx.state.repoId); await history.append({ - kind: "handoff.initialized", - handoffId: loopCtx.state.handoffId, + kind: "task.initialized", + taskId: loopCtx.state.taskId, branchName: loopCtx.state.branchName, payload: { providerId, sandboxId: sandbox.sandboxId, sessionId }, }); @@ -566,8 +533,8 @@ export async function initCompleteActivity(loopCtx: any, body: any, sandbox: any } const detail = session?.status === "error" ? (session.error ?? "session create failed") : "session unavailable"; - await setHandoffState(loopCtx, "error", detail); - await appendHistory(loopCtx, "handoff.error", { + await setTaskState(loopCtx, "error", detail); + await appendHistory(loopCtx, "task.error", { detail, messages: [detail], }); @@ -583,9 +550,9 @@ export async function initFailedActivity(loopCtx: any, error: unknown): Promise< const providerId = loopCtx.state.providerId ?? providers.defaultProviderId(); await db - .insert(handoffTable) + .insert(taskTable) .values({ - id: HANDOFF_ROW_ID, + id: TASK_ROW_ID, branchName: loopCtx.state.branchName ?? null, title: loopCtx.state.title ?? null, task: loopCtx.state.task, @@ -596,7 +563,7 @@ export async function initFailedActivity(loopCtx: any, error: unknown): Promise< updatedAt: now, }) .onConflictDoUpdate({ - target: handoffTable.id, + target: taskTable.id, set: { branchName: loopCtx.state.branchName ?? null, title: loopCtx.state.title ?? null, @@ -610,9 +577,9 @@ export async function initFailedActivity(loopCtx: any, error: unknown): Promise< .run(); await db - .insert(handoffRuntime) + .insert(taskRuntime) .values({ - id: HANDOFF_ROW_ID, + id: TASK_ROW_ID, activeSandboxId: null, activeSessionId: null, activeSwitchTarget: null, @@ -621,7 +588,7 @@ export async function initFailedActivity(loopCtx: any, error: unknown): Promise< updatedAt: now, }) .onConflictDoUpdate({ - target: handoffRuntime.id, + target: taskRuntime.id, set: { activeSandboxId: null, activeSessionId: null, @@ -633,7 +600,7 @@ export async function initFailedActivity(loopCtx: any, error: unknown): Promise< }) .run(); - await appendHistory(loopCtx, "handoff.error", { + await appendHistory(loopCtx, "task.error", { detail, messages, }); diff --git a/factory/packages/backend/src/actors/handoff/workflow/push.ts b/foundry/packages/backend/src/actors/task/workflow/push.ts similarity index 78% rename from factory/packages/backend/src/actors/handoff/workflow/push.ts rename to foundry/packages/backend/src/actors/task/workflow/push.ts index 5c4edfe..7ee929d 100644 --- a/factory/packages/backend/src/actors/handoff/workflow/push.ts +++ b/foundry/packages/backend/src/actors/task/workflow/push.ts @@ -1,8 +1,8 @@ // @ts-nocheck import { eq } from "drizzle-orm"; import { getActorRuntimeContext } from "../../context.js"; -import { handoffRuntime, handoffSandboxes } from "../db/schema.js"; -import { HANDOFF_ROW_ID, appendHistory, getCurrentRecord } from "./common.js"; +import { taskRuntime, taskSandboxes } from "../db/schema.js"; +import { TASK_ROW_ID, appendHistory, getCurrentRecord } from "./common.js"; export interface PushActiveBranchOptions { reason?: string | null; @@ -18,7 +18,7 @@ export async function pushActiveBranchActivity(loopCtx: any, options: PushActive throw new Error("cannot push: no active sandbox"); } if (!branchName) { - throw new Error("cannot push: handoff branch is not set"); + throw new Error("cannot push: task branch is not set"); } const activeSandbox = record.sandboxes.find((sandbox: any) => sandbox.sandboxId === activeSandboxId) ?? null; @@ -33,15 +33,15 @@ export async function pushActiveBranchActivity(loopCtx: any, options: PushActive const now = Date.now(); await loopCtx.db - .update(handoffRuntime) + .update(taskRuntime) .set({ statusMessage: `pushing branch ${branchName}`, updatedAt: now }) - .where(eq(handoffRuntime.id, HANDOFF_ROW_ID)) + .where(eq(taskRuntime.id, TASK_ROW_ID)) .run(); await loopCtx.db - .update(handoffSandboxes) + .update(taskSandboxes) .set({ statusMessage: `pushing branch ${branchName}`, updatedAt: now }) - .where(eq(handoffSandboxes.sandboxId, activeSandboxId)) + .where(eq(taskSandboxes.sandboxId, activeSandboxId)) .run(); const script = [ @@ -65,18 +65,18 @@ export async function pushActiveBranchActivity(loopCtx: any, options: PushActive const updatedAt = Date.now(); await loopCtx.db - .update(handoffRuntime) + .update(taskRuntime) .set({ statusMessage: `push complete for ${branchName}`, updatedAt }) - .where(eq(handoffRuntime.id, HANDOFF_ROW_ID)) + .where(eq(taskRuntime.id, TASK_ROW_ID)) .run(); await loopCtx.db - .update(handoffSandboxes) + .update(taskSandboxes) .set({ statusMessage: `push complete for ${branchName}`, updatedAt }) - .where(eq(handoffSandboxes.sandboxId, activeSandboxId)) + .where(eq(taskSandboxes.sandboxId, activeSandboxId)) .run(); - await appendHistory(loopCtx, options.historyKind ?? "handoff.push", { + await appendHistory(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 new file mode 100644 index 0000000..399414b --- /dev/null +++ b/foundry/packages/backend/src/actors/task/workflow/queue.ts @@ -0,0 +1,31 @@ +export const TASK_QUEUE_NAMES = [ + "task.command.initialize", + "task.command.provision", + "task.command.attach", + "task.command.switch", + "task.command.push", + "task.command.sync", + "task.command.merge", + "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.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.close_session", + "task.command.workbench.publish_pr", + "task.command.workbench.revert_file", + "task.status_sync.result", +] as const; + +export function taskWorkflowQueueName(name: string): string { + return name; +} diff --git a/factory/packages/backend/src/actors/handoff/workflow/status-sync.ts b/foundry/packages/backend/src/actors/task/workflow/status-sync.ts similarity index 65% rename from factory/packages/backend/src/actors/handoff/workflow/status-sync.ts rename to foundry/packages/backend/src/actors/task/workflow/status-sync.ts index b830d00..676b481 100644 --- a/factory/packages/backend/src/actors/handoff/workflow/status-sync.ts +++ b/foundry/packages/backend/src/actors/task/workflow/status-sync.ts @@ -2,8 +2,8 @@ import { eq } from "drizzle-orm"; import { getActorRuntimeContext } from "../../context.js"; import { logActorWarning, resolveErrorMessage } from "../../logging.js"; -import { handoff as handoffTable, handoffRuntime, handoffSandboxes } from "../db/schema.js"; -import { HANDOFF_ROW_ID, appendHistory, resolveErrorDetail } from "./common.js"; +import { task as taskTable, taskRuntime, taskSandboxes } from "../db/schema.js"; +import { TASK_ROW_ID, appendHistory, resolveErrorDetail } from "./common.js"; import { pushActiveBranchActivity } from "./push.js"; function mapSessionStatus(status: "running" | "idle" | "error") { @@ -25,32 +25,32 @@ export async function statusUpdateActivity(loopCtx: any, body: any): Promise { const { driver } = getActorRuntimeContext(); const db = loopCtx.db; - const self = await db.select({ prSubmitted: handoffTable.prSubmitted }).from(handoffTable).where(eq(handoffTable.id, HANDOFF_ROW_ID)).get(); + const self = await db.select({ prSubmitted: taskTable.prSubmitted }).from(taskTable).where(eq(taskTable.id, TASK_ROW_ID)).get(); if (self && self.prSubmitted) return; try { await driver.git.fetch(loopCtx.state.repoLocalPath); } catch (error) { - logActorWarning("handoff.status-sync", "fetch before PR submit failed", { + logActorWarning("task.status-sync", "fetch before PR submit failed", { workspaceId: loopCtx.state.workspaceId, repoId: loopCtx.state.repoId, - handoffId: loopCtx.state.handoffId, + taskId: loopCtx.state.taskId, error: resolveErrorMessage(error), }); } if (!loopCtx.state.branchName || !loopCtx.state.title) { - throw new Error("cannot submit PR before handoff has a branch and title"); + throw new Error("cannot submit PR before task has a branch and title"); } try { await pushActiveBranchActivity(loopCtx, { reason: "auto_submit_idle", - historyKind: "handoff.push.auto", + historyKind: "task.push.auto", }); const pr = await driver.github.createPr(loopCtx.state.repoLocalPath, loopCtx.state.branchName, loopCtx.state.title); - await db.update(handoffTable).set({ prSubmitted: 1, updatedAt: Date.now() }).where(eq(handoffTable.id, HANDOFF_ROW_ID)).run(); + await db.update(taskTable).set({ prSubmitted: 1, updatedAt: Date.now() }).where(eq(taskTable.id, TASK_ROW_ID)).run(); - await appendHistory(loopCtx, "handoff.step", { + await appendHistory(loopCtx, "task.step", { step: "pr_submit", - handoffId: loopCtx.state.handoffId, + taskId: loopCtx.state.taskId, branchName: loopCtx.state.branchName, prUrl: pr.url, prNumber: pr.number, }); - await appendHistory(loopCtx, "handoff.pr_created", { - handoffId: loopCtx.state.handoffId, + await appendHistory(loopCtx, "task.pr_created", { + taskId: loopCtx.state.taskId, branchName: loopCtx.state.branchName, prUrl: pr.url, prNumber: pr.number, @@ -119,16 +119,16 @@ export async function idleSubmitPrActivity(loopCtx: any): Promise { } catch (error) { const detail = resolveErrorDetail(error); await db - .update(handoffRuntime) + .update(taskRuntime) .set({ statusMessage: `pr submit failed: ${detail}`, updatedAt: Date.now(), }) - .where(eq(handoffRuntime.id, HANDOFF_ROW_ID)) + .where(eq(taskRuntime.id, TASK_ROW_ID)) .run(); - await appendHistory(loopCtx, "handoff.pr_create_failed", { - handoffId: loopCtx.state.handoffId, + await appendHistory(loopCtx, "task.pr_create_failed", { + taskId: loopCtx.state.taskId, branchName: loopCtx.state.branchName, error: detail, }); diff --git a/factory/packages/backend/src/actors/workspace/actions.ts b/foundry/packages/backend/src/actors/workspace/actions.ts similarity index 61% rename from factory/packages/backend/src/actors/workspace/actions.ts rename to foundry/packages/backend/src/actors/workspace/actions.ts index 9fe69eb..22bfe5a 100644 --- a/factory/packages/backend/src/actors/workspace/actions.ts +++ b/foundry/packages/backend/src/actors/workspace/actions.ts @@ -3,23 +3,23 @@ import { desc, eq } from "drizzle-orm"; import { Loop } from "rivetkit/workflow"; import type { AddRepoInput, - CreateHandoffInput, - HandoffRecord, - HandoffSummary, - HandoffWorkbenchChangeModelInput, - HandoffWorkbenchCreateHandoffInput, - HandoffWorkbenchDiffInput, - HandoffWorkbenchRenameInput, - HandoffWorkbenchRenameSessionInput, - HandoffWorkbenchSelectInput, - HandoffWorkbenchSetSessionUnreadInput, - HandoffWorkbenchSendMessageInput, - HandoffWorkbenchSnapshot, - HandoffWorkbenchTabInput, - HandoffWorkbenchUpdateDraftInput, + CreateTaskInput, + TaskRecord, + TaskSummary, + TaskWorkbenchChangeModelInput, + TaskWorkbenchCreateTaskInput, + TaskWorkbenchDiffInput, + TaskWorkbenchRenameInput, + TaskWorkbenchRenameSessionInput, + TaskWorkbenchSelectInput, + TaskWorkbenchSetSessionUnreadInput, + TaskWorkbenchSendMessageInput, + TaskWorkbenchSnapshot, + TaskWorkbenchTabInput, + TaskWorkbenchUpdateDraftInput, HistoryEvent, HistoryQueryInput, - ListHandoffsInput, + ListTasksInput, ProviderId, RepoOverview, RepoStackActionInput, @@ -29,14 +29,15 @@ import type { StarSandboxAgentRepoResult, SwitchResult, WorkspaceUseInput, -} from "@openhandoff/shared"; +} from "@sandbox-agent/foundry-shared"; import { getActorRuntimeContext } from "../context.js"; -import { getHandoff, getOrCreateHistory, getOrCreateProject, selfWorkspace } from "../handles.js"; +import { getTask, getOrCreateHistory, getOrCreateProject, selfWorkspace } from "../handles.js"; import { logActorWarning, resolveErrorMessage } from "../logging.js"; import { normalizeRemoteUrl, repoIdFromRemote } from "../../services/repo.js"; -import { handoffLookup, repos, providerProfiles } from "./db/schema.js"; -import { agentTypeForModel } from "../handoff/workbench.js"; +import { taskLookup, repos, providerProfiles } from "./db/schema.js"; +import { agentTypeForModel } from "../task/workbench.js"; import { expectQueueResponse } from "../../services/queue.js"; +import { workspaceAppActions } from "./app-shell.js"; interface WorkspaceState { workspaceId: string; @@ -46,12 +47,12 @@ interface RefreshProviderProfilesCommand { providerId?: ProviderId; } -interface GetHandoffInput { +interface GetTaskInput { workspaceId: string; - handoffId: string; + taskId: string; } -interface HandoffProxyActionInput extends GetHandoffInput { +interface TaskProxyActionInput extends GetTaskInput { reason?: string; } @@ -60,7 +61,7 @@ interface RepoOverviewInput { repoId: string; } -const WORKSPACE_QUEUE_NAMES = ["workspace.command.addRepo", "workspace.command.createHandoff", "workspace.command.refreshProviderProfiles"] as const; +const WORKSPACE_QUEUE_NAMES = ["workspace.command.addRepo", "workspace.command.createTask", "workspace.command.refreshProviderProfiles"] as const; const SANDBOX_AGENT_REPO = "rivet-dev/sandbox-agent"; type WorkspaceQueueName = (typeof WORKSPACE_QUEUE_NAMES)[number]; @@ -77,41 +78,41 @@ function assertWorkspace(c: { state: WorkspaceState }, workspaceId: string): voi } } -async function resolveRepoId(c: any, handoffId: string): Promise { - const row = await c.db.select({ repoId: handoffLookup.repoId }).from(handoffLookup).where(eq(handoffLookup.handoffId, handoffId)).get(); +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 handoff: ${handoffId} (not in lookup)`); + throw new Error(`Unknown task: ${taskId} (not in lookup)`); } return row.repoId; } -async function upsertHandoffLookupRow(c: any, handoffId: string, repoId: string): Promise { +async function upsertTaskLookupRow(c: any, taskId: string, repoId: string): Promise { await c.db - .insert(handoffLookup) + .insert(taskLookup) .values({ - handoffId, + taskId, repoId, }) .onConflictDoUpdate({ - target: handoffLookup.handoffId, + target: taskLookup.taskId, set: { repoId }, }) .run(); } -async function collectAllHandoffSummaries(c: any): Promise { +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(); - const all: HandoffSummary[] = []; + const all: TaskSummary[] = []; for (const row of repoRows) { try { const project = await getOrCreateProject(c, c.state.workspaceId, row.repoId, row.remoteUrl); - const snapshot = await project.listHandoffSummaries({ includeArchived: true }); + const snapshot = await project.listTaskSummaries({ includeArchived: true }); all.push(...snapshot); } catch (error) { - logActorWarning("workspace", "failed collecting handoffs for repo", { + logActorWarning("workspace", "failed collecting tasks for repo", { workspaceId: c.state.workspaceId, repoId: row.repoId, error: resolveErrorMessage(error), @@ -137,43 +138,43 @@ function repoLabelFromRemote(remoteUrl: string): string { return remoteUrl; } -async function buildWorkbenchSnapshot(c: any): Promise { +async function buildWorkbenchSnapshot(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 handoffs: Array = []; + const tasks: Array = []; const projects: Array = []; for (const row of repoRows) { - const projectHandoffs: Array = []; + const projectTasks: Array = []; try { const project = await getOrCreateProject(c, c.state.workspaceId, row.repoId, row.remoteUrl); - const summaries = await project.listHandoffSummaries({ includeArchived: true }); + const summaries = await project.listTaskSummaries({ includeArchived: true }); for (const summary of summaries) { try { - await upsertHandoffLookupRow(c, summary.handoffId, row.repoId); - const handoff = getHandoff(c, c.state.workspaceId, row.repoId, summary.handoffId); - const snapshot = await handoff.getWorkbench({}); - handoffs.push(snapshot); - projectHandoffs.push(snapshot); + await upsertTaskLookupRow(c, summary.taskId, row.repoId); + const task = getTask(c, c.state.workspaceId, row.repoId, summary.taskId); + const snapshot = await task.getWorkbench({}); + tasks.push(snapshot); + projectTasks.push(snapshot); } catch (error) { - logActorWarning("workspace", "failed collecting workbench handoff", { + logActorWarning("workspace", "failed collecting workbench task", { workspaceId: c.state.workspaceId, repoId: row.repoId, - handoffId: summary.handoffId, + taskId: summary.taskId, error: resolveErrorMessage(error), }); } } - if (projectHandoffs.length > 0) { + if (projectTasks.length > 0) { projects.push({ id: row.repoId, label: repoLabelFromRemote(row.remoteUrl), - updatedAtMs: projectHandoffs[0]?.updatedAtMs ?? row.updatedAt, - handoffs: projectHandoffs.sort((left, right) => right.updatedAtMs - left.updatedAtMs), + updatedAtMs: projectTasks[0]?.updatedAtMs ?? row.updatedAt, + tasks: projectTasks.sort((left, right) => right.updatedAtMs - left.updatedAtMs), }); } } catch (error) { @@ -185,7 +186,7 @@ async function buildWorkbenchSnapshot(c: any): Promise } } - handoffs.sort((left, right) => right.updatedAtMs - left.updatedAtMs); + tasks.sort((left, right) => right.updatedAtMs - left.updatedAtMs); projects.sort((left, right) => right.updatedAtMs - left.updatedAtMs); return { workspaceId: c.state.workspaceId, @@ -194,13 +195,13 @@ async function buildWorkbenchSnapshot(c: any): Promise label: repoLabelFromRemote(row.remoteUrl), })), projects, - handoffs, + tasks, }; } -async function requireWorkbenchHandoff(c: any, handoffId: string) { - const repoId = await resolveRepoId(c, handoffId); - return getHandoff(c, c.state.workspaceId, repoId, handoffId); +async function requireWorkbenchTask(c: any, taskId: string) { + const repoId = await resolveRepoId(c, taskId); + return getTask(c, c.state.workspaceId, repoId, taskId); } async function addRepoMutation(c: any, input: AddRepoInput): Promise { @@ -244,7 +245,7 @@ async function addRepoMutation(c: any, input: AddRepoInput): Promise }; } -async function createHandoffMutation(c: any, input: CreateHandoffInput): Promise { +async function createTaskMutation(c: any, input: CreateTaskInput): Promise { assertWorkspace(c, input.workspaceId); const { providers } = getActorRuntimeContext(); @@ -276,34 +277,32 @@ async function createHandoffMutation(c: any, input: CreateHandoffInput): Promise const project = await getOrCreateProject(c, c.state.workspaceId, repoId, remoteUrl); await project.ensure({ remoteUrl }); - const created = await project.createHandoff({ + const created = await project.createTask({ task: input.task, providerId, agentType: input.agentType ?? null, explicitTitle: input.explicitTitle ?? null, explicitBranchName: input.explicitBranchName ?? null, - initialPrompt: input.initialPrompt ?? null, onBranch: input.onBranch ?? null, }); await c.db - .insert(handoffLookup) + .insert(taskLookup) .values({ - handoffId: created.handoffId, + taskId: created.taskId, repoId, }) .onConflictDoUpdate({ - target: handoffLookup.handoffId, + target: taskLookup.taskId, set: { repoId }, }) .run(); - const handoff = getHandoff(c, c.state.workspaceId, repoId, created.handoffId); - await handoff.provision({ providerId }); - const provisioned = await handoff.get(); + const task = getTask(c, c.state.workspaceId, repoId, created.taskId); + await task.provision({ providerId }); await workspaceActions.notifyWorkbenchUpdated(c); - return provisioned; + return created; } async function refreshProviderProfilesMutation(c: any, command?: RefreshProviderProfilesCommand): Promise { @@ -350,11 +349,11 @@ export async function runWorkspaceWorkflow(ctx: any): Promise { return Loop.continue(undefined); } - if (msg.name === "workspace.command.createHandoff") { + if (msg.name === "workspace.command.createTask") { const result = await loopCtx.step({ - name: "workspace-create-handoff", + name: "workspace-create-task", timeout: 12 * 60_000, - run: async () => createHandoffMutation(loopCtx, msg.body as CreateHandoffInput), + run: async () => createTaskMutation(loopCtx, msg.body as CreateTaskInput), }); await msg.complete(result); return Loop.continue(undefined); @@ -372,6 +371,7 @@ export async function runWorkspaceWorkflow(ctx: any): Promise { } export const workspaceActions = { + ...workspaceAppActions, async useWorkspace(c: any, input: WorkspaceUseInput): Promise<{ workspaceId: string }> { assertWorkspace(c, input.workspaceId); return { workspaceId: c.state.workspaceId }; @@ -410,10 +410,10 @@ export const workspaceActions = { })); }, - async createHandoff(c: any, input: CreateHandoffInput): Promise { + async createTask(c: any, input: CreateTaskInput): Promise { const self = selfWorkspace(c); - return expectQueueResponse( - await self.send(workspaceWorkflowQueueName("workspace.command.createHandoff"), input, { + return expectQueueResponse( + await self.send(workspaceWorkflowQueueName("workspace.command.createTask"), input, { wait: true, timeout: 12 * 60_000, }), @@ -430,7 +430,7 @@ export const workspaceActions = { }; }, - async getWorkbench(c: any, input: WorkspaceUseInput): Promise { + async getWorkbench(c: any, input: WorkspaceUseInput): Promise { assertWorkspace(c, input.workspaceId); return await buildWorkbenchSnapshot(c); }, @@ -439,88 +439,84 @@ export const workspaceActions = { c.broadcast("workbenchUpdated", { at: Date.now() }); }, - async createWorkbenchHandoff(c: any, input: HandoffWorkbenchCreateHandoffInput): Promise<{ handoffId: string; tabId?: string }> { - const created = await workspaceActions.createHandoff(c, { + async createWorkbenchTask(c: any, input: TaskWorkbenchCreateTaskInput): Promise<{ taskId: string }> { + const created = await workspaceActions.createTask(c, { workspaceId: c.state.workspaceId, repoId: input.repoId, task: input.task, ...(input.title ? { explicitTitle: input.title } : {}), ...(input.branch ? { explicitBranchName: input.branch } : {}), - ...(input.initialPrompt !== undefined ? { initialPrompt: input.initialPrompt } : {}), ...(input.model ? { agentType: agentTypeForModel(input.model) } : {}), }); - return { - handoffId: created.handoffId, - ...(created.activeSessionId ? { tabId: created.activeSessionId } : {}), - }; + return { taskId: created.taskId }; }, - async markWorkbenchUnread(c: any, input: HandoffWorkbenchSelectInput): Promise { - const handoff = await requireWorkbenchHandoff(c, input.handoffId); - await handoff.markWorkbenchUnread({}); + async markWorkbenchUnread(c: any, input: TaskWorkbenchSelectInput): Promise { + const task = await requireWorkbenchTask(c, input.taskId); + await task.markWorkbenchUnread({}); }, - async renameWorkbenchHandoff(c: any, input: HandoffWorkbenchRenameInput): Promise { - const handoff = await requireWorkbenchHandoff(c, input.handoffId); - await handoff.renameWorkbenchHandoff(input); + async renameWorkbenchTask(c: any, input: TaskWorkbenchRenameInput): Promise { + const task = await requireWorkbenchTask(c, input.taskId); + await task.renameWorkbenchTask(input); }, - async renameWorkbenchBranch(c: any, input: HandoffWorkbenchRenameInput): Promise { - const handoff = await requireWorkbenchHandoff(c, input.handoffId); - await handoff.renameWorkbenchBranch(input); + async renameWorkbenchBranch(c: any, input: TaskWorkbenchRenameInput): Promise { + const task = await requireWorkbenchTask(c, input.taskId); + await task.renameWorkbenchBranch(input); }, - async createWorkbenchSession(c: any, input: HandoffWorkbenchSelectInput & { model?: string }): Promise<{ tabId: string }> { - const handoff = await requireWorkbenchHandoff(c, input.handoffId); - return await handoff.createWorkbenchSession({ ...(input.model ? { model: input.model } : {}) }); + async createWorkbenchSession(c: any, input: TaskWorkbenchSelectInput & { model?: string }): Promise<{ tabId: string }> { + const task = await requireWorkbenchTask(c, input.taskId); + return await task.createWorkbenchSession({ ...(input.model ? { model: input.model } : {}) }); }, - async renameWorkbenchSession(c: any, input: HandoffWorkbenchRenameSessionInput): Promise { - const handoff = await requireWorkbenchHandoff(c, input.handoffId); - await handoff.renameWorkbenchSession(input); + async renameWorkbenchSession(c: any, input: TaskWorkbenchRenameSessionInput): Promise { + const task = await requireWorkbenchTask(c, input.taskId); + await task.renameWorkbenchSession(input); }, - async setWorkbenchSessionUnread(c: any, input: HandoffWorkbenchSetSessionUnreadInput): Promise { - const handoff = await requireWorkbenchHandoff(c, input.handoffId); - await handoff.setWorkbenchSessionUnread(input); + async setWorkbenchSessionUnread(c: any, input: TaskWorkbenchSetSessionUnreadInput): Promise { + const task = await requireWorkbenchTask(c, input.taskId); + await task.setWorkbenchSessionUnread(input); }, - async updateWorkbenchDraft(c: any, input: HandoffWorkbenchUpdateDraftInput): Promise { - const handoff = await requireWorkbenchHandoff(c, input.handoffId); - await handoff.updateWorkbenchDraft(input); + async updateWorkbenchDraft(c: any, input: TaskWorkbenchUpdateDraftInput): Promise { + const task = await requireWorkbenchTask(c, input.taskId); + await task.updateWorkbenchDraft(input); }, - async changeWorkbenchModel(c: any, input: HandoffWorkbenchChangeModelInput): Promise { - const handoff = await requireWorkbenchHandoff(c, input.handoffId); - await handoff.changeWorkbenchModel(input); + async changeWorkbenchModel(c: any, input: TaskWorkbenchChangeModelInput): Promise { + const task = await requireWorkbenchTask(c, input.taskId); + await task.changeWorkbenchModel(input); }, - async sendWorkbenchMessage(c: any, input: HandoffWorkbenchSendMessageInput): Promise { - const handoff = await requireWorkbenchHandoff(c, input.handoffId); - await handoff.sendWorkbenchMessage(input); + async sendWorkbenchMessage(c: any, input: TaskWorkbenchSendMessageInput): Promise { + const task = await requireWorkbenchTask(c, input.taskId); + await task.sendWorkbenchMessage(input); }, - async stopWorkbenchSession(c: any, input: HandoffWorkbenchTabInput): Promise { - const handoff = await requireWorkbenchHandoff(c, input.handoffId); - await handoff.stopWorkbenchSession(input); + async stopWorkbenchSession(c: any, input: TaskWorkbenchTabInput): Promise { + const task = await requireWorkbenchTask(c, input.taskId); + await task.stopWorkbenchSession(input); }, - async closeWorkbenchSession(c: any, input: HandoffWorkbenchTabInput): Promise { - const handoff = await requireWorkbenchHandoff(c, input.handoffId); - await handoff.closeWorkbenchSession(input); + async closeWorkbenchSession(c: any, input: TaskWorkbenchTabInput): Promise { + const task = await requireWorkbenchTask(c, input.taskId); + await task.closeWorkbenchSession(input); }, - async publishWorkbenchPr(c: any, input: HandoffWorkbenchSelectInput): Promise { - const handoff = await requireWorkbenchHandoff(c, input.handoffId); - await handoff.publishWorkbenchPr({}); + async publishWorkbenchPr(c: any, input: TaskWorkbenchSelectInput): Promise { + const task = await requireWorkbenchTask(c, input.taskId); + await task.publishWorkbenchPr({}); }, - async revertWorkbenchFile(c: any, input: HandoffWorkbenchDiffInput): Promise { - const handoff = await requireWorkbenchHandoff(c, input.handoffId); - await handoff.revertWorkbenchFile(input); + async revertWorkbenchFile(c: any, input: TaskWorkbenchDiffInput): Promise { + const task = await requireWorkbenchTask(c, input.taskId); + await task.revertWorkbenchFile(input); }, - async listHandoffs(c: any, input: ListHandoffsInput): Promise { + async listTasks(c: any, input: ListTasksInput): Promise { assertWorkspace(c, input.workspaceId); if (input.repoId) { @@ -530,10 +526,10 @@ export const workspaceActions = { } const project = await getOrCreateProject(c, c.state.workspaceId, input.repoId, repoRow.remoteUrl); - return await project.listHandoffSummaries({ includeArchived: true }); + return await project.listTaskSummaries({ includeArchived: true }); } - return await collectAllHandoffSummaries(c); + return await collectAllTaskSummaries(c); }, async getRepoOverview(c: any, input: RepoOverviewInput): Promise { @@ -566,15 +562,15 @@ export const workspaceActions = { }); }, - async switchHandoff(c: any, handoffId: string): Promise { - const repoId = await resolveRepoId(c, handoffId); - const h = getHandoff(c, c.state.workspaceId, repoId, handoffId); + async switchTask(c: any, taskId: string): Promise { + const repoId = await resolveRepoId(c, taskId); + const h = getTask(c, c.state.workspaceId, repoId, taskId); const record = await h.get(); const switched = await h.switch(); return { workspaceId: c.state.workspaceId, - handoffId, + taskId, providerId: record.providerId, switchTarget: switched.switchTarget, }; @@ -601,7 +597,7 @@ export const workspaceActions = { const hist = await getOrCreateHistory(c, c.state.workspaceId, row.repoId); const items = await hist.list({ branch: input.branch, - handoffId: input.handoffId, + taskId: input.taskId, limit, }); allEvents.push(...items); @@ -618,10 +614,10 @@ export const workspaceActions = { return allEvents.slice(0, limit); }, - async getHandoff(c: any, input: GetHandoffInput): Promise { + async getTask(c: any, input: GetTaskInput): Promise { assertWorkspace(c, input.workspaceId); - const repoId = await resolveRepoId(c, input.handoffId); + 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) { @@ -629,48 +625,48 @@ export const workspaceActions = { } const project = await getOrCreateProject(c, c.state.workspaceId, repoId, repoRow.remoteUrl); - return await project.getHandoffEnriched({ handoffId: input.handoffId }); + return await project.getTaskEnriched({ taskId: input.taskId }); }, - async attachHandoff(c: any, input: HandoffProxyActionInput): Promise<{ target: string; sessionId: string | null }> { + async attachTask(c: any, input: TaskProxyActionInput): Promise<{ target: string; sessionId: string | null }> { assertWorkspace(c, input.workspaceId); - const repoId = await resolveRepoId(c, input.handoffId); - const h = getHandoff(c, c.state.workspaceId, repoId, input.handoffId); + const repoId = await resolveRepoId(c, input.taskId); + const h = getTask(c, c.state.workspaceId, repoId, input.taskId); return await h.attach({ reason: input.reason }); }, - async pushHandoff(c: any, input: HandoffProxyActionInput): Promise { + async pushTask(c: any, input: TaskProxyActionInput): Promise { assertWorkspace(c, input.workspaceId); - const repoId = await resolveRepoId(c, input.handoffId); - const h = getHandoff(c, c.state.workspaceId, repoId, input.handoffId); + const repoId = await resolveRepoId(c, input.taskId); + const h = getTask(c, c.state.workspaceId, repoId, input.taskId); await h.push({ reason: input.reason }); }, - async syncHandoff(c: any, input: HandoffProxyActionInput): Promise { + async syncTask(c: any, input: TaskProxyActionInput): Promise { assertWorkspace(c, input.workspaceId); - const repoId = await resolveRepoId(c, input.handoffId); - const h = getHandoff(c, c.state.workspaceId, repoId, input.handoffId); + const repoId = await resolveRepoId(c, input.taskId); + const h = getTask(c, c.state.workspaceId, repoId, input.taskId); await h.sync({ reason: input.reason }); }, - async mergeHandoff(c: any, input: HandoffProxyActionInput): Promise { + async mergeTask(c: any, input: TaskProxyActionInput): Promise { assertWorkspace(c, input.workspaceId); - const repoId = await resolveRepoId(c, input.handoffId); - const h = getHandoff(c, c.state.workspaceId, repoId, input.handoffId); + const repoId = await resolveRepoId(c, input.taskId); + const h = getTask(c, c.state.workspaceId, repoId, input.taskId); await h.merge({ reason: input.reason }); }, - async archiveHandoff(c: any, input: HandoffProxyActionInput): Promise { + async archiveTask(c: any, input: TaskProxyActionInput): Promise { assertWorkspace(c, input.workspaceId); - const repoId = await resolveRepoId(c, input.handoffId); - const h = getHandoff(c, c.state.workspaceId, repoId, input.handoffId); + const repoId = await resolveRepoId(c, input.taskId); + const h = getTask(c, c.state.workspaceId, repoId, input.taskId); await h.archive({ reason: input.reason }); }, - async killHandoff(c: any, input: HandoffProxyActionInput): Promise { + async killTask(c: any, input: TaskProxyActionInput): Promise { assertWorkspace(c, input.workspaceId); - const repoId = await resolveRepoId(c, input.handoffId); - const h = getHandoff(c, c.state.workspaceId, repoId, input.handoffId); + const repoId = await resolveRepoId(c, input.taskId); + const h = getTask(c, c.state.workspaceId, repoId, input.taskId); await h.kill({ reason: input.reason }); }, }; diff --git a/foundry/packages/backend/src/actors/workspace/app-shell.ts b/foundry/packages/backend/src/actors/workspace/app-shell.ts new file mode 100644 index 0000000..8d99086 --- /dev/null +++ b/foundry/packages/backend/src/actors/workspace/app-shell.ts @@ -0,0 +1,1442 @@ +// @ts-nocheck +import { desc, eq } from "drizzle-orm"; +import { randomUUID } from "node:crypto"; +import type { + FoundryAppSnapshot, + FoundryBillingPlanId, + FoundryBillingState, + FoundryOrganization, + FoundryOrganizationMember, + FoundryUser, + UpdateFoundryOrganizationProfileInput, +} from "@sandbox-agent/foundry-shared"; +import { getActorRuntimeContext } from "../context.js"; +import { getOrCreateWorkspace } from "../handles.js"; +import { GitHubAppError } from "../../services/app-github.js"; +import { repoIdFromRemote, repoLabelFromRemote } from "../../services/repo.js"; +import { appSessions, invoices, organizationMembers, organizationProfile, repos, seatAssignments, stripeLookup } from "./db/schema.js"; + +export const APP_SHELL_WORKSPACE_ID = "app"; + +const PROFILE_ROW_ID = "profile"; +const OAUTH_TTL_MS = 10 * 60_000; + +function assertAppWorkspace(c: any): void { + if (c.state.workspaceId !== APP_SHELL_WORKSPACE_ID) { + throw new Error(`App shell action requires workspace ${APP_SHELL_WORKSPACE_ID}, got ${c.state.workspaceId}`); + } +} + +function assertOrganizationWorkspace(c: any): void { + if (c.state.workspaceId === APP_SHELL_WORKSPACE_ID) { + throw new Error("Organization action cannot run on the reserved app workspace"); + } +} + +function slugify(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +function personalWorkspaceId(login: string): string { + return `personal-${slugify(login)}`; +} + +function organizationWorkspaceId(kind: FoundryOrganization["kind"], login: string): string { + return kind === "personal" ? personalWorkspaceId(login) : slugify(login); +} + +function splitScopes(value: string): string[] { + return value + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); +} + +function parseEligibleOrganizationIds(value: string): string[] { + try { + const parsed = JSON.parse(value); + if (!Array.isArray(parsed)) { + return []; + } + return parsed.filter((entry): entry is string => typeof entry === "string" && entry.length > 0); + } catch { + return []; + } +} + +function encodeEligibleOrganizationIds(value: string[]): string { + return JSON.stringify([...new Set(value)]); +} + +function encodeOauthState(payload: { sessionId: string; nonce: string }): string { + return Buffer.from(JSON.stringify(payload), "utf8").toString("base64url"); +} + +function decodeOauthState(value: string): { sessionId: string; nonce: string } { + const parsed = JSON.parse(Buffer.from(value, "base64url").toString("utf8")) as Record; + if (typeof parsed.sessionId !== "string" || typeof parsed.nonce !== "string") { + throw new Error("GitHub OAuth state is malformed"); + } + return { + sessionId: parsed.sessionId, + nonce: parsed.nonce, + }; +} + +function seatsIncludedForPlan(planId: FoundryBillingPlanId): number { + switch (planId) { + case "free": + return 1; + case "team": + return 5; + } +} + +function stripeStatusToBillingStatus(stripeStatus: string, cancelAtPeriodEnd: boolean): FoundryBillingState["status"] { + if (cancelAtPeriodEnd) { + return "scheduled_cancel"; + } + if (stripeStatus === "trialing") { + return "trialing"; + } + if (stripeStatus === "past_due" || stripeStatus === "unpaid" || stripeStatus === "incomplete") { + return "past_due"; + } + return "active"; +} + +function formatUnixDate(value: number): string { + return new Date(value * 1000).toISOString().slice(0, 10); +} + +function legacyRepoImportStatusToGithubSyncStatus(value: string | null | undefined): FoundryOrganization["github"]["syncStatus"] { + switch (value) { + case "ready": + return "synced"; + case "importing": + return "syncing"; + default: + return "pending"; + } +} + +function stringFromMetadata(metadata: unknown, key: string): string | null { + if (!metadata || typeof metadata !== "object") { + return null; + } + const value = (metadata as Record)[key]; + return typeof value === "string" && value.length > 0 ? value : null; +} + +function stripeWebhookSubscription(event: any) { + const object = event.data.object as Record; + const items = (object.items as { data?: Array> } | undefined)?.data ?? []; + const price = items[0]?.price as Record | undefined; + return { + id: typeof object.id === "string" ? object.id : "", + customerId: typeof object.customer === "string" ? object.customer : "", + priceId: typeof price?.id === "string" ? price.id : null, + status: typeof object.status === "string" ? object.status : "active", + cancelAtPeriodEnd: object.cancel_at_period_end === true, + currentPeriodEnd: typeof object.current_period_end === "number" ? object.current_period_end : null, + trialEnd: typeof object.trial_end === "number" ? object.trial_end : null, + defaultPaymentMethodLabel: "Payment method on file", + }; +} + +async function getAppSessionRow(c: any, sessionId: string) { + assertAppWorkspace(c); + return await c.db.select().from(appSessions).where(eq(appSessions.id, sessionId)).get(); +} + +async function requireAppSessionRow(c: any, sessionId: string) { + const row = await getAppSessionRow(c, sessionId); + if (!row) { + throw new Error(`Unknown app session: ${sessionId}`); + } + return row; +} + +async function ensureAppSession(c: any, requestedSessionId?: string | null): Promise { + assertAppWorkspace(c); + const requested = typeof requestedSessionId === "string" && requestedSessionId.trim().length > 0 ? requestedSessionId.trim() : null; + + if (requested) { + const existing = await getAppSessionRow(c, requested); + if (existing) { + return requested; + } + } + + const sessionId = requested ?? randomUUID(); + const now = Date.now(); + await c.db + .insert(appSessions) + .values({ + id: sessionId, + currentUserId: null, + currentUserName: null, + currentUserEmail: null, + currentUserGithubLogin: null, + currentUserRoleLabel: null, + eligibleOrganizationIdsJson: "[]", + activeOrganizationId: null, + githubAccessToken: null, + githubScope: "", + starterRepoStatus: "pending", + starterRepoStarredAt: null, + starterRepoSkippedAt: null, + oauthState: null, + oauthStateExpiresAt: null, + createdAt: now, + updatedAt: now, + }) + .onConflictDoNothing() + .run(); + return sessionId; +} + +async function updateAppSession(c: any, sessionId: string, patch: Record): Promise { + assertAppWorkspace(c); + await c.db + .update(appSessions) + .set({ + ...patch, + updatedAt: Date.now(), + }) + .where(eq(appSessions.id, sessionId)) + .run(); +} + +async function getOrganizationState(workspace: any) { + return await workspace.getOrganizationShellState({}); +} + +async function buildAppSnapshot(c: any, sessionId: string): Promise { + assertAppWorkspace(c); + const session = await requireAppSessionRow(c, sessionId); + const eligibleOrganizationIds = parseEligibleOrganizationIds(session.eligibleOrganizationIdsJson); + + const organizations: FoundryOrganization[] = []; + for (const organizationId of eligibleOrganizationIds) { + try { + const workspace = await getOrCreateWorkspace(c, organizationId); + const organizationState = await getOrganizationState(workspace); + organizations.push(organizationState.snapshot); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!message.includes("Actor not found")) { + throw error; + } + } + } + + const currentUser: FoundryUser | null = session.currentUserId + ? { + id: session.currentUserId, + name: session.currentUserName ?? session.currentUserGithubLogin ?? "GitHub user", + email: session.currentUserEmail ?? "", + githubLogin: session.currentUserGithubLogin ?? "", + roleLabel: session.currentUserRoleLabel ?? "GitHub user", + eligibleOrganizationIds: organizations.map((organization) => organization.id), + } + : null; + + const activeOrganizationId = + currentUser && session.activeOrganizationId && organizations.some((organization) => organization.id === session.activeOrganizationId) + ? session.activeOrganizationId + : currentUser && organizations.length === 1 + ? (organizations[0]?.id ?? null) + : null; + + return { + auth: { + status: currentUser ? "signed_in" : "signed_out", + currentUserId: currentUser?.id ?? null, + }, + activeOrganizationId, + onboarding: { + starterRepo: { + repoFullName: "rivet-dev/sandbox-agent", + repoUrl: "https://github.com/rivet-dev/sandbox-agent", + status: session.starterRepoStatus ?? "pending", + starredAt: session.starterRepoStarredAt ?? null, + skippedAt: session.starterRepoSkippedAt ?? null, + }, + }, + users: currentUser ? [currentUser] : [], + organizations, + }; +} + +async function requireSignedInSession(c: any, sessionId: string) { + const session = await requireAppSessionRow(c, sessionId); + if (!session.currentUserId || !session.currentUserEmail || !session.currentUserGithubLogin) { + throw new Error("User must be signed in"); + } + return session; +} + +function requireEligibleOrganization(session: any, organizationId: string): void { + const eligibleOrganizationIds = parseEligibleOrganizationIds(session.eligibleOrganizationIdsJson); + if (!eligibleOrganizationIds.includes(organizationId)) { + throw new Error(`Organization ${organizationId} is not available in this app session`); + } +} + +async function upsertStripeLookupEntries(c: any, organizationId: string, customerId: string | null, subscriptionId: string | null): Promise { + assertAppWorkspace(c); + const now = Date.now(); + for (const lookupKey of [customerId ? `customer:${customerId}` : null, subscriptionId ? `subscription:${subscriptionId}` : null]) { + if (!lookupKey) { + continue; + } + await c.db + .insert(stripeLookup) + .values({ + lookupKey, + organizationId, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: stripeLookup.lookupKey, + set: { + organizationId, + updatedAt: now, + }, + }) + .run(); + } +} + +async function findOrganizationIdForStripeEvent(c: any, customerId: string | null, subscriptionId: string | null): Promise { + assertAppWorkspace(c); + const customerLookup = customerId + ? await c.db + .select({ organizationId: stripeLookup.organizationId }) + .from(stripeLookup) + .where(eq(stripeLookup.lookupKey, `customer:${customerId}`)) + .get() + : null; + if (customerLookup?.organizationId) { + return customerLookup.organizationId; + } + + const subscriptionLookup = subscriptionId + ? await c.db + .select({ organizationId: stripeLookup.organizationId }) + .from(stripeLookup) + .where(eq(stripeLookup.lookupKey, `subscription:${subscriptionId}`)) + .get() + : null; + return subscriptionLookup?.organizationId ?? null; +} + +async function safeListOrganizations(accessToken: string): Promise { + const { appShell } = getActorRuntimeContext(); + try { + return await appShell.github.listOrganizations(accessToken); + } catch (error) { + if (error instanceof GitHubAppError && error.status === 403) { + return []; + } + throw error; + } +} + +async function safeListInstallations(accessToken: string): Promise { + const { appShell } = getActorRuntimeContext(); + try { + return await appShell.github.listInstallations(accessToken); + } catch (error) { + if (error instanceof GitHubAppError && (error.status === 403 || error.status === 404)) { + return []; + } + throw error; + } +} + +async function syncGithubSessionFromToken(c: any, sessionId: string, accessToken: string): Promise<{ sessionId: string; redirectTo: string }> { + assertAppWorkspace(c); + const { appShell } = getActorRuntimeContext(); + const session = await requireAppSessionRow(c, sessionId); + const token = { accessToken, scopes: splitScopes(session.githubScope) }; + const viewer = await appShell.github.getViewer(accessToken); + const organizations = await safeListOrganizations(accessToken); + const installations = await safeListInstallations(accessToken); + const userId = `user-${slugify(viewer.login)}`; + + const linkedOrganizationIds: string[] = []; + const accounts = [ + { + githubAccountId: viewer.id, + githubLogin: viewer.login, + githubAccountType: "User", + kind: "personal" as const, + displayName: viewer.name || viewer.login, + }, + ...organizations.map((organization) => ({ + githubAccountId: organization.id, + githubLogin: organization.login, + githubAccountType: "Organization", + kind: "organization" as const, + displayName: organization.name || organization.login, + })), + ]; + + for (const account of accounts) { + const organizationId = organizationWorkspaceId(account.kind, account.githubLogin); + const installation = installations.find((candidate) => candidate.accountLogin === account.githubLogin) ?? null; + const workspace = await getOrCreateWorkspace(c, organizationId); + await workspace.syncOrganizationShellFromGithub({ + userId, + userName: viewer.name || viewer.login, + userEmail: viewer.email ?? `${viewer.login}@users.noreply.github.com`, + githubUserLogin: viewer.login, + githubAccountId: account.githubAccountId, + githubLogin: account.githubLogin, + githubAccountType: account.githubAccountType, + kind: account.kind, + displayName: account.displayName, + installationId: installation?.id ?? null, + appConfigured: appShell.github.isAppConfigured(), + }); + linkedOrganizationIds.push(organizationId); + } + + const activeOrganizationId = + session.activeOrganizationId && linkedOrganizationIds.includes(session.activeOrganizationId) + ? session.activeOrganizationId + : linkedOrganizationIds.length === 1 + ? (linkedOrganizationIds[0] ?? null) + : null; + + await updateAppSession(c, session.id, { + currentUserId: userId, + currentUserName: viewer.name || viewer.login, + currentUserEmail: viewer.email ?? `${viewer.login}@users.noreply.github.com`, + currentUserGithubLogin: viewer.login, + currentUserRoleLabel: "GitHub user", + eligibleOrganizationIdsJson: encodeEligibleOrganizationIds(linkedOrganizationIds), + activeOrganizationId, + githubAccessToken: accessToken, + githubScope: token.scopes.join(","), + oauthState: null, + oauthStateExpiresAt: null, + }); + + return { + sessionId: session.id, + redirectTo: `${appShell.appUrl}/organizations?foundrySession=${encodeURIComponent(session.id)}`, + }; +} + +async function readOrganizationProfileRow(c: any) { + assertOrganizationWorkspace(c); + return await c.db.select().from(organizationProfile).where(eq(organizationProfile.id, PROFILE_ROW_ID)).get(); +} + +async function requireOrganizationProfileRow(c: any) { + const row = await readOrganizationProfileRow(c); + if (!row) { + throw new Error(`Organization profile is not initialized for workspace ${c.state.workspaceId}`); + } + return row; +} + +async function listOrganizationMembers(c: any): Promise { + assertOrganizationWorkspace(c); + const rows = await c.db.select().from(organizationMembers).orderBy(organizationMembers.role, organizationMembers.name).all(); + return rows.map((row) => ({ + id: row.id, + name: row.name, + email: row.email, + role: row.role, + state: row.state, + })); +} + +async function listOrganizationSeatAssignments(c: any): Promise { + assertOrganizationWorkspace(c); + const rows = await c.db.select({ email: seatAssignments.email }).from(seatAssignments).orderBy(seatAssignments.email).all(); + return rows.map((row) => row.email); +} + +async function listOrganizationInvoices(c: any): Promise { + assertOrganizationWorkspace(c); + const rows = await c.db.select().from(invoices).orderBy(desc(invoices.issuedAt), desc(invoices.createdAt)).all(); + return rows.map((row) => ({ + id: row.id, + label: row.label, + issuedAt: row.issuedAt, + amountUsd: row.amountUsd, + status: row.status, + })); +} + +async function listOrganizationRepoCatalog(c: any): Promise { + assertOrganizationWorkspace(c); + const rows = await c.db.select({ remoteUrl: repos.remoteUrl }).from(repos).orderBy(desc(repos.updatedAt)).all(); + return rows.map((row) => repoLabelFromRemote(row.remoteUrl)).sort((left, right) => left.localeCompare(right)); +} + +async function buildOrganizationState(c: any) { + const row = await requireOrganizationProfileRow(c); + const repoCatalog = await listOrganizationRepoCatalog(c); + const members = await listOrganizationMembers(c); + const seatAssignmentEmails = await listOrganizationSeatAssignments(c); + const invoiceRows = await listOrganizationInvoices(c); + + return { + id: c.state.workspaceId, + workspaceId: c.state.workspaceId, + kind: row.kind, + githubLogin: row.githubLogin, + githubInstallationId: row.githubInstallationId ?? null, + stripeCustomerId: row.stripeCustomerId ?? null, + stripeSubscriptionId: row.stripeSubscriptionId ?? null, + stripePriceId: row.stripePriceId ?? null, + billingPlanId: row.billingPlanId, + snapshot: { + id: c.state.workspaceId, + workspaceId: c.state.workspaceId, + kind: row.kind, + settings: { + displayName: row.displayName, + slug: row.slug, + primaryDomain: row.primaryDomain, + seatAccrualMode: "first_prompt", + defaultModel: row.defaultModel, + autoImportRepos: row.autoImportRepos === 1, + }, + github: { + connectedAccount: row.githubConnectedAccount, + installationStatus: row.githubInstallationStatus, + syncStatus: row.githubSyncStatus ?? legacyRepoImportStatusToGithubSyncStatus(row.repoImportStatus), + importedRepoCount: repoCatalog.length, + lastSyncLabel: row.githubLastSyncLabel, + lastSyncAt: row.githubLastSyncAt ?? null, + }, + billing: { + planId: row.billingPlanId, + status: row.billingStatus, + seatsIncluded: row.billingSeatsIncluded, + trialEndsAt: row.billingTrialEndsAt, + renewalAt: row.billingRenewalAt, + stripeCustomerId: row.stripeCustomerId ?? "", + paymentMethodLabel: row.billingPaymentMethodLabel, + invoices: invoiceRows, + }, + members, + seatAssignments: seatAssignmentEmails, + repoCatalog, + }, + }; +} + +async function applySubscriptionState( + workspace: any, + subscription: { + id: string; + customerId: string; + priceId: string | null; + status: string; + cancelAtPeriodEnd: boolean; + currentPeriodEnd: number | null; + trialEnd: number | null; + defaultPaymentMethodLabel: string; + }, + fallbackPlanId: FoundryBillingPlanId, +): Promise { + await workspace.applyOrganizationStripeSubscription({ + subscription, + fallbackPlanId, + }); +} + +export const workspaceAppActions = { + async ensureAppSession(c: any, input?: { requestedSessionId?: string | null }): Promise<{ sessionId: string }> { + const sessionId = await ensureAppSession(c, input?.requestedSessionId); + return { sessionId }; + }, + + async getAppSnapshot(c: any, input: { sessionId: string }): Promise { + return await buildAppSnapshot(c, input.sessionId); + }, + + async startAppGithubAuth(c: any, input: { sessionId: string }): Promise<{ url: string }> { + assertAppWorkspace(c); + const { appShell } = getActorRuntimeContext(); + const sessionId = await ensureAppSession(c, input.sessionId); + const nonce = randomUUID(); + await updateAppSession(c, sessionId, { + oauthState: nonce, + oauthStateExpiresAt: Date.now() + OAUTH_TTL_MS, + }); + return { + url: appShell.github.buildAuthorizeUrl(encodeOauthState({ sessionId, nonce })), + }; + }, + + async completeAppGithubAuth(c: any, input: { code: string; state: string }): Promise<{ sessionId: string; redirectTo: string }> { + assertAppWorkspace(c); + const { appShell } = getActorRuntimeContext(); + const oauth = decodeOauthState(input.state); + const session = await requireAppSessionRow(c, oauth.sessionId); + if (!session.oauthState || session.oauthState !== oauth.nonce || !session.oauthStateExpiresAt || session.oauthStateExpiresAt < Date.now()) { + throw new Error("GitHub OAuth state is invalid or expired"); + } + + const token = await appShell.github.exchangeCode(input.code); + await updateAppSession(c, session.id, { + githubScope: token.scopes.join(","), + }); + return await syncGithubSessionFromToken(c, session.id, token.accessToken); + }, + + async bootstrapAppGithubSession(c: any, input: { accessToken: string; sessionId?: string | null }): Promise<{ sessionId: string; redirectTo: string }> { + assertAppWorkspace(c); + if (process.env.NODE_ENV === "production") { + throw new Error("bootstrapAppGithubSession is development-only"); + } + const sessionId = await ensureAppSession(c, input.sessionId ?? null); + return await syncGithubSessionFromToken(c, sessionId, input.accessToken); + }, + + async signOutApp(c: any, input: { sessionId: string }): Promise { + assertAppWorkspace(c); + const sessionId = await ensureAppSession(c, input.sessionId); + await updateAppSession(c, sessionId, { + currentUserId: null, + currentUserName: null, + currentUserEmail: null, + currentUserGithubLogin: null, + currentUserRoleLabel: null, + eligibleOrganizationIdsJson: "[]", + activeOrganizationId: null, + githubAccessToken: null, + githubScope: "", + starterRepoStatus: "pending", + starterRepoStarredAt: null, + starterRepoSkippedAt: null, + oauthState: null, + oauthStateExpiresAt: null, + }); + return await buildAppSnapshot(c, sessionId); + }, + + async skipAppStarterRepo(c: any, input: { sessionId: string }): Promise { + assertAppWorkspace(c); + await requireSignedInSession(c, input.sessionId); + await updateAppSession(c, input.sessionId, { + starterRepoStatus: "skipped", + starterRepoSkippedAt: Date.now(), + starterRepoStarredAt: null, + }); + return await buildAppSnapshot(c, input.sessionId); + }, + + async starAppStarterRepo(c: any, input: { sessionId: string; organizationId: string }): Promise { + assertAppWorkspace(c); + const session = await requireSignedInSession(c, input.sessionId); + requireEligibleOrganization(session, input.organizationId); + const workspace = await getOrCreateWorkspace(c, input.organizationId); + await workspace.starSandboxAgentRepo({ + workspaceId: input.organizationId, + }); + await updateAppSession(c, input.sessionId, { + starterRepoStatus: "starred", + starterRepoStarredAt: Date.now(), + starterRepoSkippedAt: null, + }); + return await buildAppSnapshot(c, input.sessionId); + }, + + async selectAppOrganization(c: any, input: { sessionId: string; organizationId: string }): Promise { + assertAppWorkspace(c); + const session = await requireSignedInSession(c, input.sessionId); + requireEligibleOrganization(session, input.organizationId); + await updateAppSession(c, input.sessionId, { + activeOrganizationId: input.organizationId, + }); + + const workspace = await getOrCreateWorkspace(c, input.organizationId); + const organization = await getOrganizationState(workspace); + if (organization.snapshot.github.syncStatus !== "synced") { + return await workspaceAppActions.triggerAppRepoImport(c, input); + } + return await buildAppSnapshot(c, input.sessionId); + }, + + async updateAppOrganizationProfile( + c: any, + input: { sessionId: string; organizationId: string } & UpdateFoundryOrganizationProfileInput, + ): Promise { + assertAppWorkspace(c); + const session = await requireSignedInSession(c, input.sessionId); + requireEligibleOrganization(session, input.organizationId); + const workspace = await getOrCreateWorkspace(c, input.organizationId); + await workspace.updateOrganizationShellProfile({ + displayName: input.displayName, + slug: input.slug, + primaryDomain: input.primaryDomain, + }); + return await buildAppSnapshot(c, input.sessionId); + }, + + async triggerAppRepoImport(c: any, input: { sessionId: string; organizationId: string }): Promise { + assertAppWorkspace(c); + const session = await requireSignedInSession(c, input.sessionId); + requireEligibleOrganization(session, input.organizationId); + + const { appShell } = getActorRuntimeContext(); + const workspace = await getOrCreateWorkspace(c, input.organizationId); + const organization = await getOrganizationState(workspace); + + await workspace.markOrganizationSyncStarted({ + label: "Importing repository catalog...", + }); + + try { + const repositories = + organization.snapshot.kind === "personal" + ? await appShell.github.listUserRepositories(session.githubAccessToken) + : organization.githubInstallationId + ? await appShell.github.listInstallationRepositories(organization.githubInstallationId) + : (() => { + throw new GitHubAppError("GitHub App installation required before importing repositories", 400); + })(); + + await workspace.applyOrganizationSyncCompleted({ + repositories, + installationStatus: organization.snapshot.kind === "personal" ? "connected" : organization.snapshot.github.installationStatus, + lastSyncLabel: repositories.length > 0 ? "Synced just now" : "No repositories available", + }); + } catch (error) { + const installationStatus = + error instanceof GitHubAppError && (error.status === 403 || error.status === 404) + ? "reconnect_required" + : organization.snapshot.github.installationStatus; + await workspace.markOrganizationSyncFailed({ + message: error instanceof Error ? error.message : "GitHub import failed", + installationStatus, + }); + } + + return await buildAppSnapshot(c, input.sessionId); + }, + + async beginAppGithubInstall(c: any, input: { sessionId: string; organizationId: string }): Promise<{ url: string }> { + assertAppWorkspace(c); + const session = await requireSignedInSession(c, input.sessionId); + requireEligibleOrganization(session, input.organizationId); + const { appShell } = getActorRuntimeContext(); + const workspace = await getOrCreateWorkspace(c, input.organizationId); + const organization = await getOrganizationState(workspace); + if (organization.snapshot.kind !== "organization") { + return { + url: `${appShell.appUrl}/workspaces/${input.organizationId}?foundrySession=${encodeURIComponent(input.sessionId)}`, + }; + } + return { + url: await appShell.github.buildInstallationUrl(organization.githubLogin, randomUUID()), + }; + }, + + async createAppCheckoutSession(c: any, input: { sessionId: string; organizationId: string; planId: FoundryBillingPlanId }): Promise<{ url: string }> { + assertAppWorkspace(c); + const session = await requireSignedInSession(c, input.sessionId); + requireEligibleOrganization(session, input.organizationId); + const { appShell } = getActorRuntimeContext(); + const workspace = await getOrCreateWorkspace(c, input.organizationId); + const organization = await getOrganizationState(workspace); + + if (input.planId === "free") { + await workspace.applyOrganizationFreePlan({ clearSubscription: false }); + return { + url: `${appShell.appUrl}/organizations/${input.organizationId}/billing?foundrySession=${encodeURIComponent(input.sessionId)}`, + }; + } + + if (!appShell.stripe.isConfigured()) { + throw new Error("Stripe is not configured"); + } + + let customerId = organization.stripeCustomerId; + if (!customerId) { + customerId = ( + await appShell.stripe.createCustomer({ + organizationId: input.organizationId, + displayName: organization.snapshot.settings.displayName, + email: session.currentUserEmail, + }) + ).id; + await workspace.applyOrganizationStripeCustomer({ customerId }); + await upsertStripeLookupEntries(c, input.organizationId, customerId, null); + } + + return { + url: await appShell.stripe + .createCheckoutSession({ + organizationId: input.organizationId, + customerId, + customerEmail: session.currentUserEmail, + planId: input.planId, + successUrl: `${appShell.appUrl}/api/rivet/app/billing/checkout/complete?organizationId=${encodeURIComponent( + input.organizationId, + )}&foundrySession=${encodeURIComponent(input.sessionId)}&session_id={CHECKOUT_SESSION_ID}`, + cancelUrl: `${appShell.appUrl}/organizations/${input.organizationId}/billing?foundrySession=${encodeURIComponent(input.sessionId)}`, + }) + .then((checkout) => checkout.url), + }; + }, + + async finalizeAppCheckoutSession(c: any, input: { sessionId: string; organizationId: string; checkoutSessionId: string }): Promise<{ redirectTo: string }> { + assertAppWorkspace(c); + const { appShell } = getActorRuntimeContext(); + const workspace = await getOrCreateWorkspace(c, input.organizationId); + const organization = await getOrganizationState(workspace); + const completion = await appShell.stripe.retrieveCheckoutCompletion(input.checkoutSessionId); + + if (completion.customerId) { + await workspace.applyOrganizationStripeCustomer({ customerId: completion.customerId }); + } + await upsertStripeLookupEntries(c, input.organizationId, completion.customerId, completion.subscriptionId); + + if (completion.subscriptionId) { + const subscription = await appShell.stripe.retrieveSubscription(completion.subscriptionId); + await applySubscriptionState(workspace, subscription, completion.planId ?? organization.billingPlanId); + } + + if (completion.paymentMethodLabel) { + await workspace.setOrganizationBillingPaymentMethod({ + label: completion.paymentMethodLabel, + }); + } + + return { + redirectTo: `${appShell.appUrl}/organizations/${input.organizationId}/billing?foundrySession=${encodeURIComponent(input.sessionId)}`, + }; + }, + + async createAppBillingPortalSession(c: any, input: { sessionId: string; organizationId: string }): Promise<{ url: string }> { + assertAppWorkspace(c); + const session = await requireSignedInSession(c, input.sessionId); + requireEligibleOrganization(session, input.organizationId); + const { appShell } = getActorRuntimeContext(); + const workspace = await getOrCreateWorkspace(c, input.organizationId); + const organization = await getOrganizationState(workspace); + if (!organization.stripeCustomerId) { + throw new Error("Stripe customer is not available for this organization"); + } + const portal = await appShell.stripe.createPortalSession({ + customerId: organization.stripeCustomerId, + returnUrl: `${appShell.appUrl}/organizations/${input.organizationId}/billing?foundrySession=${encodeURIComponent(input.sessionId)}`, + }); + return { url: portal.url }; + }, + + async cancelAppScheduledRenewal(c: any, input: { sessionId: string; organizationId: string }): Promise { + assertAppWorkspace(c); + const session = await requireSignedInSession(c, input.sessionId); + requireEligibleOrganization(session, input.organizationId); + const { appShell } = getActorRuntimeContext(); + const workspace = await getOrCreateWorkspace(c, input.organizationId); + const organization = await getOrganizationState(workspace); + + if (organization.stripeSubscriptionId && appShell.stripe.isConfigured()) { + const subscription = await appShell.stripe.updateSubscriptionCancellation(organization.stripeSubscriptionId, true); + await applySubscriptionState(workspace, subscription, organization.billingPlanId); + await upsertStripeLookupEntries(c, input.organizationId, subscription.customerId ?? organization.stripeCustomerId, subscription.id); + } else { + await workspace.setOrganizationBillingStatus({ status: "scheduled_cancel" }); + } + + return await buildAppSnapshot(c, input.sessionId); + }, + + async resumeAppSubscription(c: any, input: { sessionId: string; organizationId: string }): Promise { + assertAppWorkspace(c); + const session = await requireSignedInSession(c, input.sessionId); + requireEligibleOrganization(session, input.organizationId); + const { appShell } = getActorRuntimeContext(); + const workspace = await getOrCreateWorkspace(c, input.organizationId); + const organization = await getOrganizationState(workspace); + + if (organization.stripeSubscriptionId && appShell.stripe.isConfigured()) { + const subscription = await appShell.stripe.updateSubscriptionCancellation(organization.stripeSubscriptionId, false); + await applySubscriptionState(workspace, subscription, organization.billingPlanId); + await upsertStripeLookupEntries(c, input.organizationId, subscription.customerId ?? organization.stripeCustomerId, subscription.id); + } else { + await workspace.setOrganizationBillingStatus({ status: "active" }); + } + + return await buildAppSnapshot(c, input.sessionId); + }, + + async recordAppSeatUsage(c: any, input: { sessionId: string; workspaceId: string }): Promise { + assertAppWorkspace(c); + const session = await requireSignedInSession(c, input.sessionId); + requireEligibleOrganization(session, input.workspaceId); + const workspace = await getOrCreateWorkspace(c, input.workspaceId); + await workspace.recordOrganizationSeatUsage({ + email: session.currentUserEmail, + }); + return await buildAppSnapshot(c, input.sessionId); + }, + + async handleAppStripeWebhook(c: any, input: { payload: string; signatureHeader: string | null }): Promise<{ ok: true }> { + assertAppWorkspace(c); + const { appShell } = getActorRuntimeContext(); + const event = appShell.stripe.verifyWebhookEvent(input.payload, input.signatureHeader); + + if (event.type === "checkout.session.completed") { + const object = event.data.object as Record; + const organizationId = + stringFromMetadata(object.metadata, "organizationId") ?? + (await findOrganizationIdForStripeEvent( + c, + typeof object.customer === "string" ? object.customer : null, + typeof object.subscription === "string" ? object.subscription : null, + )); + if (organizationId) { + const workspace = await getOrCreateWorkspace(c, organizationId); + if (typeof object.customer === "string") { + await workspace.applyOrganizationStripeCustomer({ customerId: object.customer }); + } + await upsertStripeLookupEntries( + c, + organizationId, + typeof object.customer === "string" ? object.customer : null, + typeof object.subscription === "string" ? object.subscription : null, + ); + } + return { ok: true }; + } + + if (event.type === "customer.subscription.updated" || event.type === "customer.subscription.created") { + const subscription = stripeWebhookSubscription(event); + const organizationId = await findOrganizationIdForStripeEvent(c, subscription.customerId, subscription.id); + if (organizationId) { + const workspace = await getOrCreateWorkspace(c, organizationId); + const organization = await getOrganizationState(workspace); + await applySubscriptionState(workspace, subscription, appShell.stripe.planIdForPriceId(subscription.priceId ?? "") ?? organization.billingPlanId); + await upsertStripeLookupEntries(c, organizationId, subscription.customerId, subscription.id); + } + return { ok: true }; + } + + if (event.type === "customer.subscription.deleted") { + const subscription = stripeWebhookSubscription(event); + const organizationId = await findOrganizationIdForStripeEvent(c, subscription.customerId, subscription.id); + if (organizationId) { + const workspace = await getOrCreateWorkspace(c, organizationId); + await workspace.applyOrganizationFreePlan({ clearSubscription: true }); + } + return { ok: true }; + } + + if (event.type === "invoice.paid" || event.type === "invoice.payment_failed") { + const invoice = event.data.object as Record; + const organizationId = await findOrganizationIdForStripeEvent(c, typeof invoice.customer === "string" ? invoice.customer : null, null); + if (organizationId) { + const workspace = await getOrCreateWorkspace(c, organizationId); + const rawAmount = typeof invoice.amount_paid === "number" ? invoice.amount_paid : invoice.amount_due; + const amountUsd = Math.round((typeof rawAmount === "number" ? rawAmount : 0) / 100); + await workspace.upsertOrganizationInvoice({ + id: String(invoice.id), + label: typeof invoice.number === "string" ? `Invoice ${invoice.number}` : "Stripe invoice", + issuedAt: formatUnixDate(typeof invoice.created === "number" ? invoice.created : Math.floor(Date.now() / 1000)), + amountUsd: Number.isFinite(amountUsd) ? amountUsd : 0, + status: event.type === "invoice.paid" ? "paid" : "open", + }); + } + } + + return { ok: true }; + }, + + async handleAppGithubWebhook(c: any, input: { payload: string; signatureHeader: string | null; eventHeader: string | null }): Promise<{ ok: true }> { + assertAppWorkspace(c); + const { appShell } = getActorRuntimeContext(); + const { event, body } = appShell.github.verifyWebhookEvent(input.payload, input.signatureHeader, input.eventHeader); + + const accountLogin = body.installation?.account?.login; + const accountType = body.installation?.account?.type; + if (!accountLogin) { + console.log(`[github-webhook] Ignoring ${event}.${body.action ?? ""}: no installation account`); + return { ok: true }; + } + + const kind: FoundryOrganization["kind"] = accountType === "User" ? "personal" : "organization"; + const organizationId = organizationWorkspaceId(kind, accountLogin); + + if (event === "installation" && (body.action === "created" || body.action === "deleted" || body.action === "suspend" || body.action === "unsuspend")) { + console.log(`[github-webhook] ${event}.${body.action} for ${accountLogin} (org=${organizationId})`); + if (body.action === "deleted") { + const workspace = await getOrCreateWorkspace(c, organizationId); + await workspace.applyGithubInstallationRemoved({}); + } else if (body.action === "created") { + const workspace = await getOrCreateWorkspace(c, organizationId); + await workspace.applyGithubInstallationCreated({ + installationId: body.installation?.id ?? 0, + }); + } + return { ok: true }; + } + + if (event === "installation_repositories") { + console.log( + `[github-webhook] ${event}.${body.action} for ${accountLogin}: +${body.repositories_added?.length ?? 0} -${body.repositories_removed?.length ?? 0}`, + ); + const workspace = await getOrCreateWorkspace(c, organizationId); + await workspace.applyGithubRepositoryChanges({ + added: (body.repositories_added ?? []).map((r) => ({ + fullName: r.full_name, + private: r.private, + })), + removed: (body.repositories_removed ?? []).map((r) => r.full_name), + }); + return { ok: true }; + } + + if ( + event === "push" || + event === "pull_request" || + event === "pull_request_review" || + event === "pull_request_review_comment" || + event === "check_run" || + event === "check_suite" || + event === "status" || + event === "create" || + event === "delete" + ) { + const repoFullName = body.repository?.full_name; + if (repoFullName) { + console.log(`[github-webhook] ${event}.${body.action ?? ""} for ${repoFullName}`); + // TODO: Dispatch to GitHubStateActor / downstream actors + } + return { ok: true }; + } + + console.log(`[github-webhook] Unhandled event: ${event}.${body.action ?? ""}`); + return { ok: true }; + }, + + async syncOrganizationShellFromGithub( + c: any, + input: { + userId: string; + userName: string; + userEmail: string; + githubUserLogin: string; + githubAccountId: string; + githubLogin: string; + githubAccountType: string; + kind: FoundryOrganization["kind"]; + displayName: string; + installationId: number | null; + appConfigured: boolean; + }, + ): Promise<{ organizationId: string }> { + assertOrganizationWorkspace(c); + const now = Date.now(); + const existing = await readOrganizationProfileRow(c); + const slug = existing?.slug ?? slugify(input.githubLogin); + const organizationId = organizationWorkspaceId(input.kind, input.githubLogin); + if (organizationId !== c.state.workspaceId) { + throw new Error(`Workspace actor mismatch: actor=${c.state.workspaceId} github=${organizationId}`); + } + + const installationStatus = + input.kind === "personal" ? "connected" : input.installationId ? "connected" : input.appConfigured ? "install_required" : "reconnect_required"; + const syncStatus = existing?.githubSyncStatus ?? legacyRepoImportStatusToGithubSyncStatus(existing?.repoImportStatus); + const lastSyncLabel = + syncStatus === "synced" + ? existing.githubLastSyncLabel + : installationStatus === "connected" + ? "Waiting for first import" + : installationStatus === "install_required" + ? "GitHub App installation required" + : "GitHub App configuration incomplete"; + const hasStripeBillingState = Boolean(existing?.stripeCustomerId || existing?.stripeSubscriptionId || existing?.stripePriceId); + const defaultBillingPlanId = input.kind === "personal" || !hasStripeBillingState ? "free" : (existing?.billingPlanId ?? "team"); + const defaultSeatsIncluded = input.kind === "personal" || !hasStripeBillingState ? 1 : (existing?.billingSeatsIncluded ?? 5); + const defaultPaymentMethodLabel = + input.kind === "personal" + ? "No card required" + : hasStripeBillingState + ? (existing?.billingPaymentMethodLabel ?? "Payment method on file") + : "No payment method on file"; + + await c.db + .insert(organizationProfile) + .values({ + id: PROFILE_ROW_ID, + kind: input.kind, + githubAccountId: input.githubAccountId, + githubLogin: input.githubLogin, + githubAccountType: input.githubAccountType, + 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, + githubInstallationStatus: installationStatus, + githubSyncStatus: syncStatus, + githubInstallationId: input.installationId, + githubLastSyncLabel: lastSyncLabel, + githubLastSyncAt: existing?.githubLastSyncAt ?? null, + stripeCustomerId: existing?.stripeCustomerId ?? null, + stripeSubscriptionId: existing?.stripeSubscriptionId ?? null, + stripePriceId: existing?.stripePriceId ?? null, + billingPlanId: defaultBillingPlanId, + billingStatus: existing?.billingStatus ?? "active", + billingSeatsIncluded: defaultSeatsIncluded, + billingTrialEndsAt: existing?.billingTrialEndsAt ?? null, + billingRenewalAt: existing?.billingRenewalAt ?? null, + billingPaymentMethodLabel: defaultPaymentMethodLabel, + createdAt: existing?.createdAt ?? now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: organizationProfile.id, + set: { + kind: input.kind, + githubAccountId: input.githubAccountId, + githubLogin: input.githubLogin, + githubAccountType: input.githubAccountType, + displayName: input.displayName, + githubConnectedAccount: input.githubLogin, + githubInstallationStatus: installationStatus, + githubSyncStatus: syncStatus, + githubInstallationId: input.installationId, + githubLastSyncLabel: lastSyncLabel, + githubLastSyncAt: existing?.githubLastSyncAt ?? null, + billingPlanId: defaultBillingPlanId, + billingSeatsIncluded: defaultSeatsIncluded, + billingPaymentMethodLabel: defaultPaymentMethodLabel, + updatedAt: now, + }, + }) + .run(); + + await c.db + .insert(organizationMembers) + .values({ + id: input.userId, + name: input.userName, + email: input.userEmail, + role: input.kind === "personal" ? "owner" : "admin", + state: "active", + updatedAt: now, + }) + .onConflictDoUpdate({ + target: organizationMembers.id, + set: { + name: input.userName, + email: input.userEmail, + role: input.kind === "personal" ? "owner" : "admin", + state: "active", + updatedAt: now, + }, + }) + .run(); + + return { organizationId }; + }, + + async getOrganizationShellState(c: any): Promise { + assertOrganizationWorkspace(c); + return await buildOrganizationState(c); + }, + + async updateOrganizationShellProfile(c: any, input: Pick): Promise { + assertOrganizationWorkspace(c); + const existing = await requireOrganizationProfileRow(c); + await c.db + .update(organizationProfile) + .set({ + displayName: input.displayName.trim() || existing.displayName, + slug: input.slug.trim() || existing.slug, + primaryDomain: input.primaryDomain.trim() || existing.primaryDomain, + updatedAt: Date.now(), + }) + .where(eq(organizationProfile.id, PROFILE_ROW_ID)) + .run(); + }, + + async markOrganizationSyncStarted(c: any, input: { label: string }): Promise { + assertOrganizationWorkspace(c); + await c.db + .update(organizationProfile) + .set({ + githubSyncStatus: "syncing", + githubLastSyncLabel: input.label, + updatedAt: Date.now(), + }) + .where(eq(organizationProfile.id, PROFILE_ROW_ID)) + .run(); + }, + + async applyOrganizationSyncCompleted( + c: any, + input: { + repositories: Array<{ fullName: string; cloneUrl: string; private: boolean }>; + installationStatus: FoundryOrganization["github"]["installationStatus"]; + lastSyncLabel: string; + }, + ): Promise { + assertOrganizationWorkspace(c); + const now = Date.now(); + for (const repository of input.repositories) { + const remoteUrl = repository.cloneUrl; + await c.db + .insert(repos) + .values({ + repoId: repoIdFromRemote(remoteUrl), + remoteUrl, + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: repos.repoId, + set: { + remoteUrl, + updatedAt: now, + }, + }) + .run(); + } + await c.db + .update(organizationProfile) + .set({ + githubInstallationStatus: input.installationStatus, + githubSyncStatus: "synced", + githubLastSyncLabel: input.lastSyncLabel, + githubLastSyncAt: now, + updatedAt: now, + }) + .where(eq(organizationProfile.id, PROFILE_ROW_ID)) + .run(); + }, + + async markOrganizationSyncFailed(c: any, input: { message: string; installationStatus: FoundryOrganization["github"]["installationStatus"] }): Promise { + assertOrganizationWorkspace(c); + await c.db + .update(organizationProfile) + .set({ + githubInstallationStatus: input.installationStatus, + githubSyncStatus: "error", + githubLastSyncLabel: input.message, + updatedAt: Date.now(), + }) + .where(eq(organizationProfile.id, PROFILE_ROW_ID)) + .run(); + }, + + async applyOrganizationStripeCustomer(c: any, input: { customerId: string }): Promise { + assertOrganizationWorkspace(c); + await c.db + .update(organizationProfile) + .set({ + stripeCustomerId: input.customerId, + updatedAt: Date.now(), + }) + .where(eq(organizationProfile.id, PROFILE_ROW_ID)) + .run(); + }, + + async applyOrganizationStripeSubscription( + c: any, + input: { + subscription: { + id: string; + customerId: string; + priceId: string | null; + status: string; + cancelAtPeriodEnd: boolean; + currentPeriodEnd: number | null; + trialEnd: number | null; + defaultPaymentMethodLabel: string; + }; + fallbackPlanId: FoundryBillingPlanId; + }, + ): Promise { + assertOrganizationWorkspace(c); + const { appShell } = getActorRuntimeContext(); + const planId = appShell.stripe.planIdForPriceId(input.subscription.priceId ?? "") ?? input.fallbackPlanId; + await c.db + .update(organizationProfile) + .set({ + stripeCustomerId: input.subscription.customerId || null, + stripeSubscriptionId: input.subscription.id || null, + stripePriceId: input.subscription.priceId, + billingPlanId: planId, + billingStatus: stripeStatusToBillingStatus(input.subscription.status, input.subscription.cancelAtPeriodEnd), + billingSeatsIncluded: seatsIncludedForPlan(planId), + billingTrialEndsAt: input.subscription.trialEnd ? new Date(input.subscription.trialEnd * 1000).toISOString() : null, + billingRenewalAt: input.subscription.currentPeriodEnd ? new Date(input.subscription.currentPeriodEnd * 1000).toISOString() : null, + billingPaymentMethodLabel: input.subscription.defaultPaymentMethodLabel || "Payment method on file", + updatedAt: Date.now(), + }) + .where(eq(organizationProfile.id, PROFILE_ROW_ID)) + .run(); + }, + + async applyOrganizationFreePlan(c: any, input: { clearSubscription: boolean }): Promise { + assertOrganizationWorkspace(c); + const patch: Record = { + billingPlanId: "free", + billingStatus: "active", + billingSeatsIncluded: 1, + billingTrialEndsAt: null, + billingRenewalAt: null, + billingPaymentMethodLabel: "No card required", + updatedAt: Date.now(), + }; + if (input.clearSubscription) { + patch.stripeSubscriptionId = null; + patch.stripePriceId = null; + } + await c.db.update(organizationProfile).set(patch).where(eq(organizationProfile.id, PROFILE_ROW_ID)).run(); + }, + + async setOrganizationBillingPaymentMethod(c: any, input: { label: string }): Promise { + assertOrganizationWorkspace(c); + await c.db + .update(organizationProfile) + .set({ + billingPaymentMethodLabel: input.label, + updatedAt: Date.now(), + }) + .where(eq(organizationProfile.id, PROFILE_ROW_ID)) + .run(); + }, + + async setOrganizationBillingStatus(c: any, input: { status: FoundryBillingState["status"] }): Promise { + assertOrganizationWorkspace(c); + await c.db + .update(organizationProfile) + .set({ + billingStatus: input.status, + updatedAt: Date.now(), + }) + .where(eq(organizationProfile.id, PROFILE_ROW_ID)) + .run(); + }, + + async upsertOrganizationInvoice(c: any, input: { id: string; label: string; issuedAt: string; amountUsd: number; status: "paid" | "open" }): Promise { + assertOrganizationWorkspace(c); + await c.db + .insert(invoices) + .values({ + id: input.id, + label: input.label, + issuedAt: input.issuedAt, + amountUsd: input.amountUsd, + status: input.status, + createdAt: Date.now(), + }) + .onConflictDoUpdate({ + target: invoices.id, + set: { + label: input.label, + issuedAt: input.issuedAt, + amountUsd: input.amountUsd, + status: input.status, + }, + }) + .run(); + }, + + async recordOrganizationSeatUsage(c: any, input: { email: string }): Promise { + assertOrganizationWorkspace(c); + await c.db + .insert(seatAssignments) + .values({ + email: input.email, + createdAt: Date.now(), + }) + .onConflictDoNothing() + .run(); + }, + + async applyGithubInstallationCreated(c: any, input: { installationId: number }): Promise { + assertOrganizationWorkspace(c); + await c.db + .update(organizationProfile) + .set({ + githubInstallationId: input.installationId, + githubInstallationStatus: "connected", + updatedAt: Date.now(), + }) + .where(eq(organizationProfile.id, PROFILE_ROW_ID)) + .run(); + }, + + async applyGithubInstallationRemoved(c: any, _input: {}): Promise { + assertOrganizationWorkspace(c); + await c.db + .update(organizationProfile) + .set({ + githubInstallationId: null, + githubInstallationStatus: "install_required", + githubSyncStatus: "pending", + githubLastSyncLabel: "GitHub App installation removed", + updatedAt: Date.now(), + }) + .where(eq(organizationProfile.id, PROFILE_ROW_ID)) + .run(); + }, + + async applyGithubRepositoryChanges(c: any, input: { added: Array<{ fullName: string; private: boolean }>; removed: string[] }): Promise { + assertOrganizationWorkspace(c); + const now = Date.now(); + + for (const repo of input.added) { + const remoteUrl = `https://github.com/${repo.fullName}.git`; + const repoId = repoIdFromRemote(remoteUrl); + await c.db + .insert(repos) + .values({ + repoId, + remoteUrl, + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: repos.repoId, + set: { + remoteUrl, + updatedAt: now, + }, + }) + .run(); + } + + for (const fullName of input.removed) { + const remoteUrl = `https://github.com/${fullName}.git`; + const repoId = repoIdFromRemote(remoteUrl); + await c.db.delete(repos).where(eq(repos.repoId, repoId)).run(); + } + + const repoCount = (await c.db.select().from(repos).all()).length; + await c.db + .update(organizationProfile) + .set({ + githubSyncStatus: "synced", + githubLastSyncLabel: `${repoCount} repositories synced`, + githubLastSyncAt: now, + updatedAt: now, + }) + .where(eq(organizationProfile.id, PROFILE_ROW_ID)) + .run(); + }, +}; diff --git a/foundry/packages/backend/src/actors/workspace/db/db.ts b/foundry/packages/backend/src/actors/workspace/db/db.ts new file mode 100644 index 0000000..1b7c080 --- /dev/null +++ b/foundry/packages/backend/src/actors/workspace/db/db.ts @@ -0,0 +1,5 @@ +import { db } from "rivetkit/db/drizzle"; +import * as schema from "./schema.js"; +import migrations from "./migrations.js"; + +export const workspaceDb = db({ schema, migrations }); diff --git a/factory/packages/backend/src/actors/workspace/db/drizzle.config.ts b/foundry/packages/backend/src/actors/workspace/db/drizzle.config.ts similarity index 100% rename from factory/packages/backend/src/actors/workspace/db/drizzle.config.ts rename to foundry/packages/backend/src/actors/workspace/db/drizzle.config.ts diff --git a/factory/packages/backend/src/actors/workspace/db/drizzle/0000_rare_iron_man.sql b/foundry/packages/backend/src/actors/workspace/db/drizzle/0000_rare_iron_man.sql similarity index 100% rename from factory/packages/backend/src/actors/workspace/db/drizzle/0000_rare_iron_man.sql rename to foundry/packages/backend/src/actors/workspace/db/drizzle/0000_rare_iron_man.sql diff --git a/factory/packages/backend/src/actors/workspace/db/drizzle/0001_sleepy_lady_deathstrike.sql b/foundry/packages/backend/src/actors/workspace/db/drizzle/0001_sleepy_lady_deathstrike.sql similarity index 100% rename from factory/packages/backend/src/actors/workspace/db/drizzle/0001_sleepy_lady_deathstrike.sql rename to foundry/packages/backend/src/actors/workspace/db/drizzle/0001_sleepy_lady_deathstrike.sql diff --git a/foundry/packages/backend/src/actors/workspace/db/drizzle/0002_tiny_silver_surfer.sql b/foundry/packages/backend/src/actors/workspace/db/drizzle/0002_tiny_silver_surfer.sql new file mode 100644 index 0000000..bd2a378 --- /dev/null +++ b/foundry/packages/backend/src/actors/workspace/db/drizzle/0002_tiny_silver_surfer.sql @@ -0,0 +1,4 @@ +CREATE TABLE `task_lookup` ( + `task_id` text PRIMARY KEY NOT NULL, + `repo_id` text NOT NULL +); diff --git a/factory/packages/backend/src/actors/workspace/db/drizzle/meta/0000_snapshot.json b/foundry/packages/backend/src/actors/workspace/db/drizzle/meta/0000_snapshot.json similarity index 100% rename from factory/packages/backend/src/actors/workspace/db/drizzle/meta/0000_snapshot.json rename to foundry/packages/backend/src/actors/workspace/db/drizzle/meta/0000_snapshot.json diff --git a/factory/packages/backend/src/actors/workspace/db/drizzle/meta/0001_snapshot.json b/foundry/packages/backend/src/actors/workspace/db/drizzle/meta/0001_snapshot.json similarity index 100% rename from factory/packages/backend/src/actors/workspace/db/drizzle/meta/0001_snapshot.json rename to foundry/packages/backend/src/actors/workspace/db/drizzle/meta/0001_snapshot.json diff --git a/factory/packages/backend/src/actors/workspace/db/drizzle/meta/_journal.json b/foundry/packages/backend/src/actors/workspace/db/drizzle/meta/_journal.json similarity index 100% rename from factory/packages/backend/src/actors/workspace/db/drizzle/meta/_journal.json rename to foundry/packages/backend/src/actors/workspace/db/drizzle/meta/_journal.json diff --git a/foundry/packages/backend/src/actors/workspace/db/migrations.ts b/foundry/packages/backend/src/actors/workspace/db/migrations.ts new file mode 100644 index 0000000..a6596f7 --- /dev/null +++ b/foundry/packages/backend/src/actors/workspace/db/migrations.ts @@ -0,0 +1,189 @@ +// This file is generated by src/actors/_scripts/generate-actor-migrations.ts. +// Source of truth is drizzle-kit output under ./drizzle (meta/_journal.json + *.sql). +// Do not hand-edit this file. + +const journal = { + entries: [ + { + idx: 0, + when: 1770924376525, + tag: "0000_rare_iron_man", + breakpoints: true, + }, + { + idx: 1, + when: 1770947252912, + tag: "0001_sleepy_lady_deathstrike", + breakpoints: true, + }, + { + idx: 2, + when: 1772668800000, + tag: "0002_tiny_silver_surfer", + breakpoints: true, + }, + { + idx: 3, + when: 1773100800000, + tag: "0003_app_shell_organization_profile", + breakpoints: true, + }, + { + idx: 4, + when: 1773100800001, + tag: "0004_app_shell_organization_members", + breakpoints: true, + }, + { + idx: 5, + when: 1773100800002, + tag: "0005_app_shell_seat_assignments", + breakpoints: true, + }, + { + idx: 6, + when: 1773100800003, + tag: "0006_app_shell_invoices", + breakpoints: true, + }, + { + idx: 7, + when: 1773100800004, + tag: "0007_app_shell_sessions", + breakpoints: true, + }, + { + idx: 8, + when: 1773100800005, + tag: "0008_app_shell_stripe_lookup", + breakpoints: true, + }, + { + idx: 9, + when: 1773100800006, + tag: "0009_github_sync_status", + breakpoints: true, + }, + { + idx: 10, + when: 1772928000000, + tag: "0010_app_session_starter_repo", + breakpoints: true, + }, + ], +} as const; + +export default { + journal, + migrations: { + m0000: `CREATE TABLE \`provider_profiles\` ( + \`provider_id\` text PRIMARY KEY NOT NULL, + \`profile_json\` text NOT NULL, + \`updated_at\` integer NOT NULL +); +`, + m0001: `CREATE TABLE \`repos\` ( + \`repo_id\` text PRIMARY KEY NOT NULL, + \`remote_url\` text NOT NULL, + \`created_at\` integer NOT NULL, + \`updated_at\` integer NOT NULL +); +`, + m0002: `CREATE TABLE \`task_lookup\` ( + \`task_id\` text PRIMARY KEY NOT NULL, + \`repo_id\` text NOT NULL +); +`, + m0003: `CREATE TABLE \`organization_profile\` ( + \`id\` text PRIMARY KEY NOT NULL, + \`kind\` text NOT NULL, + \`github_account_id\` text NOT NULL, + \`github_login\` text NOT NULL, + \`github_account_type\` text NOT NULL, + \`display_name\` text NOT NULL, + \`slug\` text NOT NULL, + \`primary_domain\` text NOT NULL, + \`default_model\` text NOT NULL, + \`auto_import_repos\` integer NOT NULL, + \`repo_import_status\` text NOT NULL, + \`github_connected_account\` text NOT NULL, + \`github_installation_status\` text NOT NULL, + \`github_installation_id\` integer, + \`github_last_sync_label\` text NOT NULL, + \`stripe_customer_id\` text, + \`stripe_subscription_id\` text, + \`stripe_price_id\` text, + \`billing_plan_id\` text NOT NULL, + \`billing_status\` text NOT NULL, + \`billing_seats_included\` integer NOT NULL, + \`billing_trial_ends_at\` text, + \`billing_renewal_at\` text, + \`billing_payment_method_label\` text NOT NULL, + \`created_at\` integer NOT NULL, + \`updated_at\` integer NOT NULL +); +`, + m0004: `CREATE TABLE \`organization_members\` ( + \`id\` text PRIMARY KEY NOT NULL, + \`name\` text NOT NULL, + \`email\` text NOT NULL, + \`role\` text NOT NULL, + \`state\` text NOT NULL, + \`updated_at\` integer NOT NULL +); +`, + m0005: `CREATE TABLE \`seat_assignments\` ( + \`email\` text PRIMARY KEY NOT NULL, + \`created_at\` integer NOT NULL +); +`, + m0006: `CREATE TABLE \`invoices\` ( + \`id\` text PRIMARY KEY NOT NULL, + \`label\` text NOT NULL, + \`issued_at\` text NOT NULL, + \`amount_usd\` integer NOT NULL, + \`status\` text NOT NULL, + \`created_at\` integer NOT NULL +); +`, + m0007: `CREATE TABLE \`app_sessions\` ( + \`id\` text PRIMARY KEY NOT NULL, + \`current_user_id\` text, + \`current_user_name\` text, + \`current_user_email\` text, + \`current_user_github_login\` text, + \`current_user_role_label\` text, + \`eligible_organization_ids_json\` text NOT NULL, + \`active_organization_id\` text, + \`github_access_token\` text, + \`github_scope\` text NOT NULL, + \`starter_repo_status\` text NOT NULL, + \`starter_repo_starred_at\` integer, + \`starter_repo_skipped_at\` integer, + \`oauth_state\` text, + \`oauth_state_expires_at\` integer, + \`created_at\` integer NOT NULL, + \`updated_at\` integer NOT NULL +); +`, + m0008: `CREATE TABLE \`stripe_lookup\` ( + \`lookup_key\` text PRIMARY KEY NOT NULL, + \`organization_id\` text NOT NULL, + \`updated_at\` integer NOT NULL +); +`, + m0009: `ALTER TABLE \`organization_profile\` ADD COLUMN \`github_sync_status\` text NOT NULL DEFAULT 'pending'; +ALTER TABLE \`organization_profile\` ADD COLUMN \`github_last_sync_at\` integer; +UPDATE \`organization_profile\` +SET \`github_sync_status\` = CASE + WHEN \`repo_import_status\` = 'ready' THEN 'synced' + WHEN \`repo_import_status\` = 'importing' THEN 'syncing' + ELSE 'pending' +END; +`, + m0010: `ALTER TABLE \`app_sessions\` ADD COLUMN \`starter_repo_status\` text NOT NULL DEFAULT 'pending'; +ALTER TABLE \`app_sessions\` ADD COLUMN \`starter_repo_starred_at\` integer; +ALTER TABLE \`app_sessions\` ADD COLUMN \`starter_repo_skipped_at\` integer; +`, + } as const, +}; diff --git a/foundry/packages/backend/src/actors/workspace/db/schema.ts b/foundry/packages/backend/src/actors/workspace/db/schema.ts new file mode 100644 index 0000000..728103b --- /dev/null +++ b/foundry/packages/backend/src/actors/workspace/db/schema.ts @@ -0,0 +1,100 @@ +import { integer, sqliteTable, text } from "rivetkit/db/drizzle"; + +// SQLite is per workspace actor instance, so no workspaceId column needed. +export const providerProfiles = sqliteTable("provider_profiles", { + providerId: text("provider_id").notNull().primaryKey(), + profileJson: text("profile_json").notNull(), + updatedAt: integer("updated_at").notNull(), +}); + +export const repos = sqliteTable("repos", { + repoId: text("repo_id").notNull().primaryKey(), + remoteUrl: text("remote_url").notNull(), + createdAt: integer("created_at").notNull(), + updatedAt: integer("updated_at").notNull(), +}); + +export const taskLookup = sqliteTable("task_lookup", { + taskId: text("task_id").notNull().primaryKey(), + repoId: text("repo_id").notNull(), +}); + +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"), + 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 organizationMembers = sqliteTable("organization_members", { + id: text("id").notNull().primaryKey(), + name: text("name").notNull(), + email: text("email").notNull(), + role: text("role").notNull(), + state: text("state").notNull(), + updatedAt: integer("updated_at").notNull(), +}); + +export const seatAssignments = sqliteTable("seat_assignments", { + email: text("email").notNull().primaryKey(), + createdAt: integer("created_at").notNull(), +}); + +export const invoices = sqliteTable("invoices", { + id: text("id").notNull().primaryKey(), + label: text("label").notNull(), + issuedAt: text("issued_at").notNull(), + amountUsd: integer("amount_usd").notNull(), + status: text("status").notNull(), + createdAt: integer("created_at").notNull(), +}); + +export const appSessions = sqliteTable("app_sessions", { + id: text("id").notNull().primaryKey(), + currentUserId: text("current_user_id"), + currentUserName: text("current_user_name"), + currentUserEmail: text("current_user_email"), + currentUserGithubLogin: text("current_user_github_login"), + currentUserRoleLabel: text("current_user_role_label"), + eligibleOrganizationIdsJson: text("eligible_organization_ids_json").notNull(), + activeOrganizationId: text("active_organization_id"), + githubAccessToken: text("github_access_token"), + githubScope: text("github_scope").notNull(), + starterRepoStatus: text("starter_repo_status").notNull(), + starterRepoStarredAt: integer("starter_repo_starred_at"), + starterRepoSkippedAt: integer("starter_repo_skipped_at"), + oauthState: text("oauth_state"), + oauthStateExpiresAt: integer("oauth_state_expires_at"), + createdAt: integer("created_at").notNull(), + updatedAt: integer("updated_at").notNull(), +}); + +export const stripeLookup = sqliteTable("stripe_lookup", { + lookupKey: text("lookup_key").notNull().primaryKey(), + organizationId: text("organization_id").notNull(), + updatedAt: integer("updated_at").notNull(), +}); diff --git a/factory/packages/backend/src/actors/workspace/index.ts b/foundry/packages/backend/src/actors/workspace/index.ts similarity index 100% rename from factory/packages/backend/src/actors/workspace/index.ts rename to foundry/packages/backend/src/actors/workspace/index.ts diff --git a/factory/packages/backend/src/config/backend.ts b/foundry/packages/backend/src/config/backend.ts similarity index 80% rename from factory/packages/backend/src/config/backend.ts rename to foundry/packages/backend/src/config/backend.ts index 66ac3f1..82d9bbe 100644 --- a/factory/packages/backend/src/config/backend.ts +++ b/foundry/packages/backend/src/config/backend.ts @@ -2,9 +2,9 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname } from "node:path"; import { homedir } from "node:os"; import * as toml from "@iarna/toml"; -import { ConfigSchema, type AppConfig } from "@openhandoff/shared"; +import { ConfigSchema, type AppConfig } from "@sandbox-agent/foundry-shared"; -export const CONFIG_PATH = `${homedir()}/.config/openhandoff/config.toml`; +export const CONFIG_PATH = `${homedir()}/.config/foundry/config.toml`; export function loadConfig(path = CONFIG_PATH): AppConfig { if (!existsSync(path)) { diff --git a/factory/packages/backend/src/config/workspace.ts b/foundry/packages/backend/src/config/workspace.ts similarity index 85% rename from factory/packages/backend/src/config/workspace.ts rename to foundry/packages/backend/src/config/workspace.ts index a7b4010..2225200 100644 --- a/factory/packages/backend/src/config/workspace.ts +++ b/foundry/packages/backend/src/config/workspace.ts @@ -1,4 +1,4 @@ -import type { AppConfig } from "@openhandoff/shared"; +import type { AppConfig } from "@sandbox-agent/foundry-shared"; export function defaultWorkspace(config: AppConfig): string { const ws = config.workspace.default.trim(); diff --git a/factory/packages/backend/src/driver.ts b/foundry/packages/backend/src/driver.ts similarity index 95% rename from factory/packages/backend/src/driver.ts rename to foundry/packages/backend/src/driver.ts index fc1fc65..def33cf 100644 --- a/factory/packages/backend/src/driver.ts +++ b/foundry/packages/backend/src/driver.ts @@ -1,10 +1,6 @@ import type { BranchSnapshot } from "./integrations/git/index.js"; import type { PullRequestSnapshot } from "./integrations/github/index.js"; -import type { - SandboxSession, - SandboxAgentClientOptions, - SandboxSessionCreateRequest -} from "./integrations/sandbox-agent/client.js"; +import type { SandboxSession, SandboxAgentClientOptions, SandboxSessionCreateRequest } from "./integrations/sandbox-agent/client.js"; import type { ListEventsRequest, ListPage, @@ -17,12 +13,7 @@ import type { SessionEvent, SessionRecord, } from "sandbox-agent"; -import type { - DaytonaClientOptions, - DaytonaCreateSandboxOptions, - DaytonaPreviewEndpoint, - DaytonaSandbox, -} from "./integrations/daytona/client.js"; +import type { DaytonaClientOptions, DaytonaCreateSandboxOptions, DaytonaPreviewEndpoint, DaytonaSandbox } from "./integrations/daytona/client.js"; import { validateRemote, ensureCloned, diff --git a/foundry/packages/backend/src/index.ts b/foundry/packages/backend/src/index.ts new file mode 100644 index 0000000..66643bc --- /dev/null +++ b/foundry/packages/backend/src/index.ts @@ -0,0 +1,351 @@ +import { Hono } from "hono"; +import { cors } from "hono/cors"; +import { initActorRuntimeContext } from "./actors/context.js"; +import { registry, resolveManagerPort } from "./actors/index.js"; +import { workspaceKey } from "./actors/keys.js"; +import { loadConfig } from "./config/backend.js"; +import { createBackends, createNotificationService } from "./notifications/index.js"; +import { createDefaultDriver } from "./driver.js"; +import { createProviderRegistry } from "./providers/index.js"; +import { createClient } from "rivetkit/client"; +import type { FoundryBillingPlanId } from "@sandbox-agent/foundry-shared"; +import { createDefaultAppShellServices } from "./services/app-shell-runtime.js"; +import { APP_SHELL_WORKSPACE_ID } from "./actors/workspace/app-shell.js"; + +export interface BackendStartOptions { + host?: string; + port?: number; +} + +export async function startBackend(options: BackendStartOptions = {}): Promise { + // sandbox-agent agent plugins vary on which env var they read for OpenAI/Codex auth. + // Normalize to keep local dev + docker-compose simple. + if (!process.env.CODEX_API_KEY && process.env.OPENAI_API_KEY) { + process.env.CODEX_API_KEY = process.env.OPENAI_API_KEY; + } + + const config = loadConfig(); + config.backend.host = options.host ?? config.backend.host; + config.backend.port = options.port ?? config.backend.port; + + // Allow docker-compose/dev environments to supply provider config via env vars + // instead of writing into the container's config.toml. + const envFirst = (...keys: string[]): string | undefined => { + for (const key of keys) { + const raw = process.env[key]; + if (raw && raw.trim().length > 0) return raw.trim(); + } + return undefined; + }; + + config.providers.daytona.endpoint = envFirst("HF_DAYTONA_ENDPOINT", "DAYTONA_ENDPOINT") ?? config.providers.daytona.endpoint; + config.providers.daytona.apiKey = envFirst("HF_DAYTONA_API_KEY", "DAYTONA_API_KEY") ?? config.providers.daytona.apiKey; + + const driver = createDefaultDriver(); + const providers = createProviderRegistry(config, driver); + const backends = await createBackends(config.notify); + const notifications = createNotificationService(backends); + initActorRuntimeContext(config, providers, notifications, driver, createDefaultAppShellServices()); + + registry.startRunner(); + const inner = registry.serve(); + const actorClient = createClient({ + endpoint: `http://127.0.0.1:${resolveManagerPort()}`, + disableMetadataLookup: true, + }) as any; + + // Wrap in a Hono app mounted at /api/rivet to serve on the backend port. + // Uses Bun.serve — cannot use @hono/node-server because it conflicts with + // RivetKit's internal Bun.serve manager server (Bun bug: mixing Node HTTP + // server and Bun.serve in the same process breaks Bun.serve's fetch handler). + const app = new Hono(); + const allowHeaders = [ + "Content-Type", + "Authorization", + "x-rivet-token", + "x-rivet-encoding", + "x-rivet-query", + "x-rivet-conn-params", + "x-rivet-actor", + "x-rivet-target", + "x-rivet-namespace", + "x-rivet-endpoint", + "x-rivet-total-slots", + "x-rivet-runner-name", + "x-rivet-namespace-name", + "x-foundry-session", + ]; + const exposeHeaders = ["Content-Type", "x-foundry-session", "x-rivet-ray-id"]; + app.use( + "/api/rivet/*", + cors({ + origin: (origin) => origin ?? "*", + credentials: true, + allowHeaders, + allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], + exposeHeaders, + }), + ); + app.use( + "/api/rivet", + cors({ + origin: (origin) => origin ?? "*", + credentials: true, + allowHeaders, + allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], + exposeHeaders, + }), + ); + const forward = async (c: any) => { + try { + // RivetKit serverless handler is configured with basePath `/api/rivet` by default. + return await inner.fetch(c.req.raw); + } catch (err) { + if (err instanceof URIError) { + return c.text("Bad Request: Malformed URI", 400); + } + throw err; + } + }; + + const appWorkspace = async () => + await actorClient.workspace.getOrCreate(workspaceKey(APP_SHELL_WORKSPACE_ID), { + createWithInput: APP_SHELL_WORKSPACE_ID, + }); + + const resolveSessionId = async (c: any): Promise => { + const requested = c.req.header("x-foundry-session"); + const { sessionId } = await (await appWorkspace()).ensureAppSession({ + requestedSessionId: requested ?? null, + }); + c.header("x-foundry-session", sessionId); + return sessionId; + }; + + app.get("/api/rivet/app/snapshot", async (c) => { + const sessionId = await resolveSessionId(c); + return c.json(await (await appWorkspace()).getAppSnapshot({ sessionId })); + }); + + app.get("/api/rivet/app/auth/github/start", async (c) => { + const sessionId = await resolveSessionId(c); + const result = await (await appWorkspace()).startAppGithubAuth({ sessionId }); + return Response.redirect(result.url, 302); + }); + + app.get("/api/rivet/app/auth/github/callback", async (c) => { + const code = c.req.query("code"); + const state = c.req.query("state"); + if (!code || !state) { + return c.text("Missing GitHub OAuth callback parameters", 400); + } + const result = await (await appWorkspace()).completeAppGithubAuth({ code, state }); + c.header("x-foundry-session", result.sessionId); + return Response.redirect(result.redirectTo, 302); + }); + + app.post("/api/rivet/app/sign-out", async (c) => { + const sessionId = await resolveSessionId(c); + return c.json(await (await appWorkspace()).signOutApp({ sessionId })); + }); + + app.post("/api/rivet/app/onboarding/starter-repo/skip", async (c) => { + const sessionId = await resolveSessionId(c); + return c.json(await (await appWorkspace()).skipAppStarterRepo({ sessionId })); + }); + + app.post("/api/rivet/app/organizations/:organizationId/starter-repo/star", async (c) => { + const sessionId = await resolveSessionId(c); + return c.json( + await (await appWorkspace()).starAppStarterRepo({ + sessionId, + organizationId: c.req.param("organizationId"), + }), + ); + }); + + app.post("/api/rivet/app/organizations/:organizationId/select", async (c) => { + const sessionId = await resolveSessionId(c); + return c.json( + await (await appWorkspace()).selectAppOrganization({ + sessionId, + organizationId: c.req.param("organizationId"), + }), + ); + }); + + app.patch("/api/rivet/app/organizations/:organizationId/profile", async (c) => { + const sessionId = await resolveSessionId(c); + const body = await c.req.json(); + return c.json( + await (await appWorkspace()).updateAppOrganizationProfile({ + sessionId, + organizationId: c.req.param("organizationId"), + displayName: typeof body?.displayName === "string" ? body.displayName : "", + slug: typeof body?.slug === "string" ? body.slug : "", + primaryDomain: typeof body?.primaryDomain === "string" ? body.primaryDomain : "", + }), + ); + }); + + app.post("/api/rivet/app/organizations/:organizationId/import", async (c) => { + const sessionId = await resolveSessionId(c); + return c.json( + await (await appWorkspace()).triggerAppRepoImport({ + sessionId, + organizationId: c.req.param("organizationId"), + }), + ); + }); + + app.post("/api/rivet/app/organizations/:organizationId/reconnect", async (c) => { + const sessionId = await resolveSessionId(c); + return c.json( + await (await appWorkspace()).beginAppGithubInstall({ + sessionId, + organizationId: c.req.param("organizationId"), + }), + ); + }); + + app.post("/api/rivet/app/organizations/:organizationId/billing/checkout", async (c) => { + const sessionId = await resolveSessionId(c); + const body = await c.req.json().catch(() => ({})); + const planId = body?.planId === "free" || body?.planId === "team" ? (body.planId as FoundryBillingPlanId) : "team"; + return c.json( + await (await appWorkspace()).createAppCheckoutSession({ + sessionId, + organizationId: c.req.param("organizationId"), + planId, + }), + ); + }); + + app.get("/api/rivet/app/billing/checkout/complete", async (c) => { + const organizationId = c.req.query("organizationId"); + const sessionId = c.req.query("foundrySession"); + const checkoutSessionId = c.req.query("session_id"); + if (!organizationId || !sessionId || !checkoutSessionId) { + return c.text("Missing Stripe checkout completion parameters", 400); + } + const result = await (await appWorkspace()).finalizeAppCheckoutSession({ + organizationId, + sessionId, + checkoutSessionId, + }); + return Response.redirect(result.redirectTo, 302); + }); + + app.post("/api/rivet/app/organizations/:organizationId/billing/portal", async (c) => { + const sessionId = await resolveSessionId(c); + return c.json( + await (await appWorkspace()).createAppBillingPortalSession({ + sessionId, + organizationId: c.req.param("organizationId"), + }), + ); + }); + + app.post("/api/rivet/app/organizations/:organizationId/billing/cancel", async (c) => { + const sessionId = await resolveSessionId(c); + return c.json( + await (await appWorkspace()).cancelAppScheduledRenewal({ + sessionId, + organizationId: c.req.param("organizationId"), + }), + ); + }); + + app.post("/api/rivet/app/organizations/:organizationId/billing/resume", async (c) => { + const sessionId = await resolveSessionId(c); + return c.json( + await (await appWorkspace()).resumeAppSubscription({ + sessionId, + organizationId: c.req.param("organizationId"), + }), + ); + }); + + app.post("/api/rivet/app/workspaces/:workspaceId/seat-usage", async (c) => { + const sessionId = await resolveSessionId(c); + return c.json( + await (await appWorkspace()).recordAppSeatUsage({ + sessionId, + workspaceId: c.req.param("workspaceId"), + }), + ); + }); + + const handleStripeWebhook = async (c: any) => { + const payload = await c.req.text(); + await (await appWorkspace()).handleAppStripeWebhook({ + payload, + signatureHeader: c.req.header("stripe-signature") ?? null, + }); + return c.json({ ok: true }); + }; + + app.post("/api/rivet/app/webhooks/stripe", handleStripeWebhook); + app.post("/api/rivet/app/stripe/webhook", handleStripeWebhook); + + app.all("/api/rivet", forward); + app.all("/api/rivet/*", forward); + + const server = Bun.serve({ + fetch: app.fetch, + hostname: config.backend.host, + port: config.backend.port, + }); + + process.on("SIGINT", async () => { + server.stop(); + process.exit(0); + }); + + process.on("SIGTERM", async () => { + server.stop(); + process.exit(0); + }); + + // Keep process alive. + await new Promise(() => undefined); +} + +function parseArg(flag: string): string | undefined { + const idx = process.argv.indexOf(flag); + if (idx < 0) return undefined; + return process.argv[idx + 1]; +} + +function parseEnvPort(value: string | undefined): number | undefined { + if (!value) { + return undefined; + } + const port = Number(value); + if (!Number.isInteger(port) || port <= 0 || port > 65535) { + return undefined; + } + return port; +} + +async function main(): Promise { + const cmd = process.argv[2] ?? "start"; + if (cmd !== "start") { + throw new Error(`Unsupported backend command: ${cmd}`); + } + + const host = parseArg("--host") ?? process.env.HOST ?? process.env.HF_BACKEND_HOST; + const port = parseArg("--port") ?? process.env.PORT ?? process.env.HF_BACKEND_PORT; + await startBackend({ + host, + port: parseEnvPort(port), + }); +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main().catch((err: unknown) => { + const message = err instanceof Error ? (err.stack ?? err.message) : String(err); + console.error(message); + process.exit(1); + }); +} diff --git a/factory/packages/backend/src/integrations/daytona/client.ts b/foundry/packages/backend/src/integrations/daytona/client.ts similarity index 100% rename from factory/packages/backend/src/integrations/daytona/client.ts rename to foundry/packages/backend/src/integrations/daytona/client.ts diff --git a/factory/packages/backend/src/integrations/git-spice/index.ts b/foundry/packages/backend/src/integrations/git-spice/index.ts similarity index 100% rename from factory/packages/backend/src/integrations/git-spice/index.ts rename to foundry/packages/backend/src/integrations/git-spice/index.ts diff --git a/factory/packages/backend/src/integrations/git/index.ts b/foundry/packages/backend/src/integrations/git/index.ts similarity index 99% rename from factory/packages/backend/src/integrations/git/index.ts rename to foundry/packages/backend/src/integrations/git/index.ts index cb56315..38e7b0b 100644 --- a/factory/packages/backend/src/integrations/git/index.ts +++ b/foundry/packages/backend/src/integrations/git/index.ts @@ -23,7 +23,7 @@ function ensureAskpassScript(): string { return cachedAskpassPath; } - const dir = mkdtempSync(resolve(tmpdir(), "openhandoff-git-askpass-")); + const dir = mkdtempSync(resolve(tmpdir(), "foundry-git-askpass-")); const path = resolve(dir, "askpass.sh"); // Git invokes $GIT_ASKPASS with the prompt string as argv[1]. Provide both username and password. diff --git a/factory/packages/backend/src/integrations/github/index.ts b/foundry/packages/backend/src/integrations/github/index.ts similarity index 100% rename from factory/packages/backend/src/integrations/github/index.ts rename to foundry/packages/backend/src/integrations/github/index.ts diff --git a/factory/packages/backend/src/integrations/graphite/index.ts b/foundry/packages/backend/src/integrations/graphite/index.ts similarity index 100% rename from factory/packages/backend/src/integrations/graphite/index.ts rename to foundry/packages/backend/src/integrations/graphite/index.ts diff --git a/factory/packages/backend/src/integrations/sandbox-agent/client.ts b/foundry/packages/backend/src/integrations/sandbox-agent/client.ts similarity index 97% rename from factory/packages/backend/src/integrations/sandbox-agent/client.ts rename to foundry/packages/backend/src/integrations/sandbox-agent/client.ts index 9ee44d7..f936db3 100644 --- a/factory/packages/backend/src/integrations/sandbox-agent/client.ts +++ b/foundry/packages/backend/src/integrations/sandbox-agent/client.ts @@ -1,4 +1,4 @@ -import type { AgentType } from "@openhandoff/shared"; +import type { AgentType } from "@sandbox-agent/foundry-shared"; import type { ListEventsRequest, ListPage, @@ -10,7 +10,7 @@ import type { ProcessSignalQuery, SessionEvent, SessionPersistDriver, - SessionRecord + SessionRecord, } from "sandbox-agent"; import { SandboxAgent } from "sandbox-agent"; @@ -142,13 +142,13 @@ export class SandboxAgentClient { const modeId = modeIdForAgent(normalized.agent ?? this.agent); // Codex defaults to a restrictive "read-only" preset in some environments. - // For OpenHandoff automation we need to allow edits + command execution + network + // Foundry automation needs edits, command execution, and network access. // access (git push / PR creation). Use full-access where supported. // // If the agent doesn't support session modes, ignore. // // Do this in the background: ACP mode updates can occasionally time out (504), - // and waiting here can stall session creation long enough to trip handoff init + // and waiting here can stall session creation long enough to trip task init // step timeouts even though the session itself was created. if (modeId) { void session.rawSend("session/set_mode", { modeId }).catch(() => { @@ -221,10 +221,7 @@ export class SandboxAgentClient { return await sdk.listProcesses(); } - async getProcessLogs( - processId: string, - query: ProcessLogFollowQuery = {} - ): Promise { + async getProcessLogs(processId: string, query: ProcessLogFollowQuery = {}): Promise { const sdk = await this.sdk(); return await sdk.getProcessLogs(processId, query); } diff --git a/factory/packages/backend/src/notifications/backends.ts b/foundry/packages/backend/src/notifications/backends.ts similarity index 96% rename from factory/packages/backend/src/notifications/backends.ts rename to foundry/packages/backend/src/notifications/backends.ts index 9ca6609..9429298 100644 --- a/factory/packages/backend/src/notifications/backends.ts +++ b/foundry/packages/backend/src/notifications/backends.ts @@ -109,12 +109,12 @@ export async function createBackends(configOrder: string[]): Promise; changesRequested(branchName: string, prNumber: number, reviewer: string): Promise; prMerged(branchName: string, prNumber: number): Promise; - handoffCreated(branchName: string): Promise; + taskCreated(branchName: string): Promise; } export function createNotificationService(backends: NotifyBackend[]): NotificationService { @@ -56,8 +56,8 @@ export function createNotificationService(backends: NotifyBackend[]): Notificati await notify("PR Merged", `PR #${prNumber} on ${branchName} merged`, "normal"); }, - async handoffCreated(branchName: string): Promise { - await notify("Handoff Created", `New handoff on ${branchName}`, "low"); + async taskCreated(branchName: string): Promise { + await notify("Task Created", `New task on ${branchName}`, "low"); }, }; } diff --git a/factory/packages/backend/src/notifications/state-tracker.ts b/foundry/packages/backend/src/notifications/state-tracker.ts similarity index 100% rename from factory/packages/backend/src/notifications/state-tracker.ts rename to foundry/packages/backend/src/notifications/state-tracker.ts diff --git a/factory/packages/backend/src/providers/daytona/index.ts b/foundry/packages/backend/src/providers/daytona/index.ts similarity index 93% rename from factory/packages/backend/src/providers/daytona/index.ts rename to foundry/packages/backend/src/providers/daytona/index.ts index 422b3ea..c15170d 100644 --- a/factory/packages/backend/src/providers/daytona/index.ts +++ b/foundry/packages/backend/src/providers/daytona/index.ts @@ -186,7 +186,7 @@ export class DaytonaProvider implements SandboxProvider { emitDebug("daytona.createSandbox.start", { workspaceId: req.workspaceId, repoId: req.repoId, - handoffId: req.handoffId, + taskId: req.taskId, branchName: req.branchName, }); @@ -196,11 +196,11 @@ export class DaytonaProvider implements SandboxProvider { image: this.buildSnapshotImage(), envVars: this.buildEnvVars(), labels: { - "openhandoff.workspace": req.workspaceId, - "openhandoff.handoff": req.handoffId, - "openhandoff.repo_id": req.repoId, - "openhandoff.repo_remote": req.repoRemote, - "openhandoff.branch": req.branchName, + "foundry.workspace": req.workspaceId, + "foundry.task": req.taskId, + "foundry.repo_id": req.repoId, + "foundry.repo_remote": req.repoRemote, + "foundry.branch": req.branchName, }, autoStopInterval: this.config.autoStopInterval, }), @@ -211,9 +211,9 @@ export class DaytonaProvider implements SandboxProvider { state: sandbox.state ?? null, }); - const repoDir = `/home/daytona/openhandoff/${req.workspaceId}/${req.repoId}/${req.handoffId}/repo`; + const repoDir = `/home/daytona/foundry/${req.workspaceId}/${req.repoId}/${req.taskId}/repo`; - // Prepare a working directory for the agent. This must succeed for the handoff to work. + // Prepare a working directory for the agent. This must succeed for the task to work. const installStartedAt = Date.now(); await this.runCheckedCommand( sandbox.id, @@ -247,10 +247,10 @@ export class DaytonaProvider implements SandboxProvider { `git clone "${req.repoRemote}" "${repoDir}"`, `cd "${repoDir}"`, `git fetch origin --prune`, - // The handoff branch may not exist remotely yet (agent push creates it). Base off current branch (default branch). + // The task branch may not exist remotely yet (agent push creates it). Base off current branch (default branch). `if git show-ref --verify --quiet "refs/remotes/origin/${req.branchName}"; then git checkout -B "${req.branchName}" "origin/${req.branchName}"; else git checkout -B "${req.branchName}" "$(git branch --show-current 2>/dev/null || echo main)"; fi`, - `git config user.email "openhandoff@local" >/dev/null 2>&1 || true`, - `git config user.name "OpenHandoff" >/dev/null 2>&1 || true`, + `git config user.email "foundry@local" >/dev/null 2>&1 || true`, + `git config user.name "Foundry" >/dev/null 2>&1 || true`, ].join("; "), )}`, ].join(" "), @@ -283,10 +283,10 @@ export class DaytonaProvider implements SandboxProvider { // Reconstruct cwd from sandbox labels written at create time. const info = await this.withTimeout("resume get sandbox", () => client.getSandbox(req.sandboxId)); const labels = info.labels ?? {}; - const workspaceId = labels["openhandoff.workspace"] ?? req.workspaceId; - const repoId = labels["openhandoff.repo_id"] ?? ""; - const handoffId = labels["openhandoff.handoff"] ?? ""; - const cwd = repoId && handoffId ? `/home/daytona/openhandoff/${workspaceId}/${repoId}/${handoffId}/repo` : null; + const workspaceId = labels["foundry.workspace"] ?? req.workspaceId; + const repoId = labels["foundry.repo_id"] ?? ""; + const taskId = labels["foundry.task"] ?? ""; + const cwd = repoId && taskId ? `/home/daytona/foundry/${workspaceId}/${repoId}/${taskId}/repo` : null; return { sandboxId: req.sandboxId, diff --git a/factory/packages/backend/src/providers/index.ts b/foundry/packages/backend/src/providers/index.ts similarity index 94% rename from factory/packages/backend/src/providers/index.ts rename to foundry/packages/backend/src/providers/index.ts index 133a126..1f3af94 100644 --- a/factory/packages/backend/src/providers/index.ts +++ b/foundry/packages/backend/src/providers/index.ts @@ -1,5 +1,5 @@ -import type { ProviderId } from "@openhandoff/shared"; -import type { AppConfig } from "@openhandoff/shared"; +import type { ProviderId } from "@sandbox-agent/foundry-shared"; +import type { AppConfig } from "@sandbox-agent/foundry-shared"; import type { BackendDriver } from "../driver.js"; import { DaytonaProvider } from "./daytona/index.js"; import { LocalProvider } from "./local/index.js"; diff --git a/factory/packages/backend/src/providers/local/index.ts b/foundry/packages/backend/src/providers/local/index.ts similarity index 98% rename from factory/packages/backend/src/providers/local/index.ts rename to foundry/packages/backend/src/providers/local/index.ts index 5da5e85..9945216 100644 --- a/factory/packages/backend/src/providers/local/index.ts +++ b/foundry/packages/backend/src/providers/local/index.ts @@ -68,7 +68,7 @@ export class LocalProvider implements SandboxProvider { ) {} private rootDir(): string { - return expandHome(this.config.rootDir?.trim() || "~/.local/share/openhandoff/local-sandboxes"); + return expandHome(this.config.rootDir?.trim() || "~/.local/share/foundry/local-sandboxes"); } private sandboxRoot(workspaceId: string, sandboxId: string): string { @@ -146,7 +146,7 @@ export class LocalProvider implements SandboxProvider { } async createSandbox(req: CreateSandboxRequest): Promise { - const sandboxId = req.handoffId || `local-${randomUUID()}`; + const sandboxId = req.taskId || `local-${randomUUID()}`; const repoDir = this.repoDir(req.workspaceId, sandboxId); mkdirSync(dirname(repoDir), { recursive: true }); await this.git.ensureCloned(req.repoRemote, repoDir); diff --git a/factory/packages/backend/src/providers/provider-api/index.ts b/foundry/packages/backend/src/providers/provider-api/index.ts similarity index 96% rename from factory/packages/backend/src/providers/provider-api/index.ts rename to foundry/packages/backend/src/providers/provider-api/index.ts index 5735ec4..c772b46 100644 --- a/factory/packages/backend/src/providers/provider-api/index.ts +++ b/foundry/packages/backend/src/providers/provider-api/index.ts @@ -1,4 +1,4 @@ -import type { ProviderId } from "@openhandoff/shared"; +import type { ProviderId } from "@sandbox-agent/foundry-shared"; export interface ProviderCapabilities { remote: boolean; @@ -10,7 +10,7 @@ export interface CreateSandboxRequest { repoId: string; repoRemote: string; branchName: string; - handoffId: string; + taskId: string; debug?: (message: string, context?: Record) => void; options?: Record; } diff --git a/foundry/packages/backend/src/services/app-github.ts b/foundry/packages/backend/src/services/app-github.ts new file mode 100644 index 0000000..c18254e --- /dev/null +++ b/foundry/packages/backend/src/services/app-github.ts @@ -0,0 +1,489 @@ +import { createHmac, createPrivateKey, createSign, timingSafeEqual } from "node:crypto"; + +export class GitHubAppError extends Error { + readonly status: number; + + constructor(message: string, status = 500) { + super(message); + this.name = "GitHubAppError"; + this.status = status; + } +} + +export interface GitHubOAuthSession { + accessToken: string; + scopes: string[]; +} + +export interface GitHubViewerIdentity { + id: string; + login: string; + name: string; + email: string | null; +} + +export interface GitHubOrgIdentity { + id: string; + login: string; + name: string | null; +} + +export interface GitHubInstallationRecord { + id: number; + accountLogin: string; +} + +export interface GitHubRepositoryRecord { + fullName: string; + cloneUrl: string; + private: boolean; +} + +interface GitHubTokenResponse { + access_token?: string; + scope?: string; + error?: string; + error_description?: string; +} + +interface GitHubPageResponse { + items: T[]; + nextUrl: string | null; +} + +export interface GitHubWebhookEvent { + action?: string; + installation?: { id: number; account?: { login?: string; type?: string; id?: number } | null }; + repositories_added?: Array<{ id: number; full_name: string; private: boolean }>; + repositories_removed?: Array<{ id: number; full_name: string }>; + repository?: { id: number; full_name: string; clone_url?: string; private?: boolean; owner?: { login?: string } }; + pull_request?: { number: number; title?: string; state?: string; head?: { ref?: string }; base?: { ref?: string } }; + sender?: { login?: string; id?: number }; + [key: string]: unknown; +} + +export interface GitHubAppClientOptions { + apiBaseUrl?: string; + authBaseUrl?: string; + clientId?: string; + clientSecret?: string; + redirectUri?: string; + appId?: string; + appPrivateKey?: string; + webhookSecret?: string; +} + +export class GitHubAppClient { + private readonly apiBaseUrl: string; + private readonly authBaseUrl: string; + private readonly clientId?: string; + private readonly clientSecret?: string; + private readonly redirectUri?: string; + private readonly appId?: string; + private readonly appPrivateKey?: string; + private readonly webhookSecret?: string; + + constructor(options: GitHubAppClientOptions = {}) { + this.apiBaseUrl = (options.apiBaseUrl ?? "https://api.github.com").replace(/\/$/, ""); + this.authBaseUrl = (options.authBaseUrl ?? "https://github.com").replace(/\/$/, ""); + this.clientId = options.clientId ?? process.env.GITHUB_CLIENT_ID; + this.clientSecret = options.clientSecret ?? process.env.GITHUB_CLIENT_SECRET; + this.redirectUri = options.redirectUri ?? process.env.GITHUB_REDIRECT_URI; + this.appId = options.appId ?? process.env.GITHUB_APP_ID; + this.appPrivateKey = options.appPrivateKey ?? process.env.GITHUB_APP_PRIVATE_KEY; + this.webhookSecret = options.webhookSecret ?? process.env.GITHUB_WEBHOOK_SECRET; + } + + isOauthConfigured(): boolean { + return Boolean(this.clientId && this.clientSecret && this.redirectUri); + } + + isAppConfigured(): boolean { + return Boolean(this.appId && this.appPrivateKey); + } + + isWebhookConfigured(): boolean { + return Boolean(this.webhookSecret); + } + + verifyWebhookEvent(payload: string, signatureHeader: string | null, eventHeader: string | null): { event: string; body: GitHubWebhookEvent } { + if (!this.webhookSecret) { + throw new GitHubAppError("GitHub webhook secret is not configured", 500); + } + if (!signatureHeader) { + throw new GitHubAppError("Missing GitHub signature header", 400); + } + if (!eventHeader) { + throw new GitHubAppError("Missing GitHub event header", 400); + } + + const expectedSignature = signatureHeader.startsWith("sha256=") ? signatureHeader.slice(7) : null; + if (!expectedSignature) { + throw new GitHubAppError("Malformed GitHub signature header", 400); + } + + const computed = createHmac("sha256", this.webhookSecret).update(payload).digest("hex"); + const computedBuffer = Buffer.from(computed, "utf8"); + const expectedBuffer = Buffer.from(expectedSignature, "utf8"); + if (computedBuffer.length !== expectedBuffer.length || !timingSafeEqual(computedBuffer, expectedBuffer)) { + throw new GitHubAppError("GitHub webhook signature verification failed", 400); + } + + return { + event: eventHeader, + body: JSON.parse(payload) as GitHubWebhookEvent, + }; + } + + buildAuthorizeUrl(state: string): string { + if (!this.clientId || !this.redirectUri) { + throw new GitHubAppError("GitHub OAuth is not configured", 500); + } + + const url = new URL(`${this.authBaseUrl}/login/oauth/authorize`); + url.searchParams.set("client_id", this.clientId); + url.searchParams.set("redirect_uri", this.redirectUri); + url.searchParams.set("scope", "read:user user:email read:org"); + url.searchParams.set("state", state); + return url.toString(); + } + + async exchangeCode(code: string): Promise { + if (!this.clientId || !this.clientSecret || !this.redirectUri) { + throw new GitHubAppError("GitHub OAuth is not configured", 500); + } + + const response = await fetch(`${this.authBaseUrl}/login/oauth/access_token`, { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + client_id: this.clientId, + client_secret: this.clientSecret, + code, + redirect_uri: this.redirectUri, + }), + }); + + const responseText = await response.text(); + let payload: GitHubTokenResponse; + try { + payload = JSON.parse(responseText) as GitHubTokenResponse; + } catch { + // GitHub may return URL-encoded responses despite Accept: application/json + const params = new URLSearchParams(responseText); + if (params.has("access_token")) { + payload = { + access_token: params.get("access_token")!, + scope: params.get("scope") ?? "", + }; + } else { + throw new GitHubAppError( + params.get("error_description") ?? params.get("error") ?? `GitHub token exchange failed: ${responseText.slice(0, 200)}`, + response.status || 502, + ); + } + } + if (!response.ok || !payload.access_token) { + throw new GitHubAppError(payload.error_description ?? payload.error ?? `GitHub token exchange failed with ${response.status}`, response.status); + } + + return { + accessToken: payload.access_token, + scopes: + payload.scope + ?.split(",") + .map((value) => value.trim()) + .filter((value) => value.length > 0) ?? [], + }; + } + + async getViewer(accessToken: string): Promise { + const user = await this.requestJson<{ + id: number; + login: string; + name?: string | null; + email?: string | null; + }>("/user", accessToken); + + let email = user.email ?? null; + if (!email) { + try { + const emails = await this.requestJson>("/user/emails", accessToken); + const primary = emails.find((candidate) => candidate.primary && candidate.verified) ?? emails[0] ?? null; + email = primary?.email ?? null; + } catch (error) { + if (!(error instanceof GitHubAppError) || error.status !== 404) { + throw error; + } + } + } + + return { + id: String(user.id), + login: user.login, + name: user.name?.trim() || user.login, + email, + }; + } + + async listOrganizations(accessToken: string): Promise { + const organizations = await this.paginate<{ id: number; login: string; description?: string | null }>("/user/orgs?per_page=100", accessToken); + return organizations.map((organization) => ({ + id: String(organization.id), + login: organization.login, + name: organization.description?.trim() || organization.login, + })); + } + + async listInstallations(accessToken: string): Promise { + if (!this.isAppConfigured()) { + return []; + } + try { + const payload = await this.requestJson<{ + installations?: Array<{ id: number; account?: { login?: string } | null }>; + }>("/user/installations", accessToken); + + return (payload.installations ?? []) + .map((installation) => ({ + id: installation.id, + accountLogin: installation.account?.login?.trim() ?? "", + })) + .filter((installation) => installation.accountLogin.length > 0); + } catch (error) { + if (!(error instanceof GitHubAppError) || (error.status !== 401 && error.status !== 403)) { + throw error; + } + } + + const installations = await this.paginateApp<{ id: number; account?: { login?: string } | null }>("/app/installations?per_page=100"); + return installations + .map((installation) => ({ + id: installation.id, + accountLogin: installation.account?.login?.trim() ?? "", + })) + .filter((installation) => installation.accountLogin.length > 0); + } + + async listUserRepositories(accessToken: string): Promise { + const repositories = await this.paginate<{ + full_name: string; + clone_url: string; + private: boolean; + }>("/user/repos?per_page=100&affiliation=owner&sort=updated", accessToken); + + return repositories.map((repository) => ({ + fullName: repository.full_name, + cloneUrl: repository.clone_url, + private: repository.private, + })); + } + + async listInstallationRepositories(installationId: number): Promise { + const accessToken = await this.createInstallationAccessToken(installationId); + const repositories = await this.paginate<{ + full_name: string; + clone_url: string; + private: boolean; + }>("/installation/repositories?per_page=100", accessToken); + + return repositories.map((repository) => ({ + fullName: repository.full_name, + cloneUrl: repository.clone_url, + private: repository.private, + })); + } + + async buildInstallationUrl(organizationLogin: string, state: string): Promise { + if (!this.isAppConfigured()) { + throw new GitHubAppError("GitHub App is not configured", 500); + } + const app = await this.requestAppJson<{ slug?: string }>("/app"); + if (!app.slug) { + throw new GitHubAppError("GitHub App slug is unavailable", 500); + } + const url = new URL(`${this.authBaseUrl}/apps/${app.slug}/installations/new`); + url.searchParams.set("state", state); + void organizationLogin; + return url.toString(); + } + + private async createInstallationAccessToken(installationId: number): Promise { + if (!this.appId || !this.appPrivateKey) { + throw new GitHubAppError("GitHub App is not configured", 500); + } + + const response = await fetch(`${this.apiBaseUrl}/app/installations/${installationId}/access_tokens`, { + method: "POST", + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${this.createAppJwt()}`, + "X-GitHub-Api-Version": "2022-11-28", + }, + }); + + const payload = (await response.json()) as { token?: string; message?: string }; + if (!response.ok || !payload.token) { + throw new GitHubAppError(payload.message ?? "Unable to mint GitHub installation token", response.status); + } + return payload.token; + } + + private createAppJwt(): string { + if (!this.appId || !this.appPrivateKey) { + throw new GitHubAppError("GitHub App is not configured", 500); + } + + const header = base64UrlEncode(JSON.stringify({ alg: "RS256", typ: "JWT" })); + const now = Math.floor(Date.now() / 1000); + const payload = base64UrlEncode( + JSON.stringify({ + iat: now - 60, + exp: now + 540, + iss: this.appId, + }), + ); + const signer = createSign("RSA-SHA256"); + signer.update(`${header}.${payload}`); + signer.end(); + const key = createPrivateKey(this.appPrivateKey); + const signature = signer.sign(key); + return `${header}.${payload}.${base64UrlEncode(signature)}`; + } + + private async requestAppJson(path: string): Promise { + const response = await fetch(`${this.apiBaseUrl}${path}`, { + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${this.createAppJwt()}`, + "X-GitHub-Api-Version": "2022-11-28", + }, + }); + + const payload = (await response.json()) as T | { message?: string }; + if (!response.ok) { + throw new GitHubAppError( + typeof payload === "object" && payload && "message" in payload ? (payload.message ?? "GitHub request failed") : "GitHub request failed", + response.status, + ); + } + return payload as T; + } + + private async paginateApp(path: string): Promise { + let nextUrl = `${this.apiBaseUrl}${path.startsWith("/") ? path : `/${path}`}`; + const items: T[] = []; + + while (nextUrl) { + const page = await this.requestAppPage(nextUrl); + items.push(...page.items); + nextUrl = page.nextUrl ?? ""; + } + + return items; + } + + private async requestJson(path: string, accessToken: string): Promise { + const response = await fetch(`${this.apiBaseUrl}${path}`, { + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${accessToken}`, + "X-GitHub-Api-Version": "2022-11-28", + }, + }); + + const payload = (await response.json()) as T | { message?: string }; + if (!response.ok) { + throw new GitHubAppError( + typeof payload === "object" && payload && "message" in payload ? (payload.message ?? "GitHub request failed") : "GitHub request failed", + response.status, + ); + } + return payload as T; + } + + private async paginate(path: string, accessToken: string): Promise { + let nextUrl = `${this.apiBaseUrl}${path.startsWith("/") ? path : `/${path}`}`; + const items: T[] = []; + + while (nextUrl) { + const page = await this.requestPage(nextUrl, accessToken); + items.push(...page.items); + nextUrl = page.nextUrl ?? ""; + } + + return items; + } + + private async requestPage(url: string, accessToken: string): Promise> { + const response = await fetch(url, { + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${accessToken}`, + "X-GitHub-Api-Version": "2022-11-28", + }, + }); + + const payload = (await response.json()) as T[] | { repositories?: T[]; message?: string }; + if (!response.ok) { + throw new GitHubAppError( + typeof payload === "object" && payload && "message" in payload ? (payload.message ?? "GitHub request failed") : "GitHub request failed", + response.status, + ); + } + + const items = Array.isArray(payload) ? payload : (payload.repositories ?? []); + return { + items, + nextUrl: parseNextLink(response.headers.get("link")), + }; + } + + private async requestAppPage(url: string): Promise> { + const response = await fetch(url, { + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${this.createAppJwt()}`, + "X-GitHub-Api-Version": "2022-11-28", + }, + }); + + const payload = (await response.json()) as T[] | { installations?: T[]; message?: string }; + if (!response.ok) { + throw new GitHubAppError( + typeof payload === "object" && payload && "message" in payload ? (payload.message ?? "GitHub request failed") : "GitHub request failed", + response.status, + ); + } + + const items = Array.isArray(payload) ? payload : (payload.installations ?? []); + return { + items, + nextUrl: parseNextLink(response.headers.get("link")), + }; + } +} + +function parseNextLink(linkHeader: string | null): string | null { + if (!linkHeader) { + return null; + } + + for (const part of linkHeader.split(",")) { + const [urlPart, relPart] = part.split(";").map((value) => value.trim()); + if (!urlPart || !relPart || !relPart.includes('rel="next"')) { + continue; + } + return urlPart.replace(/^<|>$/g, ""); + } + + return null; +} + +function base64UrlEncode(value: string | Buffer): string { + const source = typeof value === "string" ? Buffer.from(value, "utf8") : value; + return source.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); +} diff --git a/foundry/packages/backend/src/services/app-shell-runtime.ts b/foundry/packages/backend/src/services/app-shell-runtime.ts new file mode 100644 index 0000000..896ecee --- /dev/null +++ b/foundry/packages/backend/src/services/app-shell-runtime.ts @@ -0,0 +1,81 @@ +import { + GitHubAppClient, + type GitHubInstallationRecord, + type GitHubOAuthSession, + type GitHubOrgIdentity, + type GitHubRepositoryRecord, + type GitHubViewerIdentity, + type GitHubWebhookEvent, +} from "./app-github.js"; +import { + StripeAppClient, + type StripeCheckoutCompletion, + type StripeCheckoutSession, + type StripePortalSession, + type StripeSubscriptionSnapshot, + type StripeWebhookEvent, +} from "./app-stripe.js"; +import type { FoundryBillingPlanId } from "@sandbox-agent/foundry-shared"; + +export type AppShellGithubClient = Pick< + GitHubAppClient, + | "isAppConfigured" + | "isWebhookConfigured" + | "buildAuthorizeUrl" + | "exchangeCode" + | "getViewer" + | "listOrganizations" + | "listInstallations" + | "listUserRepositories" + | "listInstallationRepositories" + | "buildInstallationUrl" + | "verifyWebhookEvent" +>; + +export type AppShellStripeClient = Pick< + StripeAppClient, + | "isConfigured" + | "createCustomer" + | "createCheckoutSession" + | "retrieveCheckoutCompletion" + | "retrieveSubscription" + | "createPortalSession" + | "updateSubscriptionCancellation" + | "verifyWebhookEvent" + | "planIdForPriceId" +>; + +export interface AppShellServices { + appUrl: string; + github: AppShellGithubClient; + stripe: AppShellStripeClient; +} + +export interface CreateAppShellServicesOptions { + appUrl?: string; + github?: AppShellGithubClient; + stripe?: AppShellStripeClient; +} + +export function createDefaultAppShellServices(options: CreateAppShellServicesOptions = {}): AppShellServices { + return { + appUrl: (options.appUrl ?? process.env.APP_URL ?? "http://localhost:4173").replace(/\/$/, ""), + github: options.github ?? new GitHubAppClient(), + stripe: options.stripe ?? new StripeAppClient(), + }; +} + +export type { + GitHubInstallationRecord, + GitHubOAuthSession, + GitHubOrgIdentity, + GitHubRepositoryRecord, + GitHubViewerIdentity, + GitHubWebhookEvent, + StripeCheckoutCompletion, + StripeCheckoutSession, + StripePortalSession, + StripeSubscriptionSnapshot, + StripeWebhookEvent, + FoundryBillingPlanId, +}; diff --git a/foundry/packages/backend/src/services/app-stripe.ts b/foundry/packages/backend/src/services/app-stripe.ts new file mode 100644 index 0000000..9cb249e --- /dev/null +++ b/foundry/packages/backend/src/services/app-stripe.ts @@ -0,0 +1,284 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; +import type { FoundryBillingPlanId } from "@sandbox-agent/foundry-shared"; + +export class StripeAppError extends Error { + readonly status: number; + + constructor(message: string, status = 500) { + super(message); + this.name = "StripeAppError"; + this.status = status; + } +} + +export interface StripeCheckoutSession { + id: string; + url: string; +} + +export interface StripePortalSession { + url: string; +} + +export interface StripeSubscriptionSnapshot { + id: string; + customerId: string; + priceId: string | null; + status: string; + cancelAtPeriodEnd: boolean; + currentPeriodEnd: number | null; + trialEnd: number | null; + defaultPaymentMethodLabel: string; +} + +export interface StripeCheckoutCompletion { + customerId: string | null; + subscriptionId: string | null; + planId: FoundryBillingPlanId | null; + paymentMethodLabel: string; +} + +export interface StripeWebhookEvent { + id: string; + type: string; + data: { + object: T; + }; +} + +export interface StripeAppClientOptions { + apiBaseUrl?: string; + secretKey?: string; + webhookSecret?: string; + teamPriceId?: string; +} + +export class StripeAppClient { + private readonly apiBaseUrl: string; + private readonly secretKey?: string; + private readonly webhookSecret?: string; + private readonly teamPriceId?: string; + + constructor(options: StripeAppClientOptions = {}) { + this.apiBaseUrl = (options.apiBaseUrl ?? "https://api.stripe.com").replace(/\/$/, ""); + this.secretKey = options.secretKey ?? process.env.STRIPE_SECRET_KEY; + this.webhookSecret = options.webhookSecret ?? process.env.STRIPE_WEBHOOK_SECRET; + this.teamPriceId = options.teamPriceId ?? process.env.STRIPE_PRICE_TEAM; + } + + isConfigured(): boolean { + return Boolean(this.secretKey); + } + + createCheckoutSession(input: { + organizationId: string; + customerId: string; + customerEmail: string | null; + planId: Exclude; + successUrl: string; + cancelUrl: string; + }): Promise { + const priceId = this.priceIdForPlan(input.planId); + return this.formRequest("/v1/checkout/sessions", { + mode: "subscription", + success_url: input.successUrl, + cancel_url: input.cancelUrl, + customer: input.customerId, + "line_items[0][price]": priceId, + "line_items[0][quantity]": "1", + "metadata[organizationId]": input.organizationId, + "metadata[planId]": input.planId, + "subscription_data[metadata][organizationId]": input.organizationId, + "subscription_data[metadata][planId]": input.planId, + }); + } + + createPortalSession(input: { customerId: string; returnUrl: string }): Promise { + return this.formRequest("/v1/billing_portal/sessions", { + customer: input.customerId, + return_url: input.returnUrl, + }); + } + + createCustomer(input: { organizationId: string; displayName: string; email: string | null }): Promise<{ id: string }> { + return this.formRequest<{ id: string }>("/v1/customers", { + name: input.displayName, + ...(input.email ? { email: input.email } : {}), + "metadata[organizationId]": input.organizationId, + }); + } + + async updateSubscriptionCancellation(subscriptionId: string, cancelAtPeriodEnd: boolean): Promise { + const payload = await this.formRequest>(`/v1/subscriptions/${subscriptionId}`, { + cancel_at_period_end: cancelAtPeriodEnd ? "true" : "false", + }); + return stripeSubscriptionSnapshot(payload); + } + + async retrieveCheckoutCompletion(sessionId: string): Promise { + const payload = await this.requestJson>(`/v1/checkout/sessions/${sessionId}?expand[]=subscription.default_payment_method`); + + const subscription = typeof payload.subscription === "object" && payload.subscription ? (payload.subscription as Record) : null; + const subscriptionId = + typeof payload.subscription === "string" ? payload.subscription : subscription && typeof subscription.id === "string" ? subscription.id : null; + const priceId = firstStripePriceId(subscription); + + return { + customerId: typeof payload.customer === "string" ? payload.customer : null, + subscriptionId, + planId: priceId ? this.planIdForPriceId(priceId) : planIdFromMetadata(payload.metadata), + paymentMethodLabel: subscription ? paymentMethodLabelFromObject(subscription.default_payment_method) : "Card on file", + }; + } + + async retrieveSubscription(subscriptionId: string): Promise { + const payload = await this.requestJson>(`/v1/subscriptions/${subscriptionId}?expand[]=default_payment_method`); + return stripeSubscriptionSnapshot(payload); + } + + verifyWebhookEvent(payload: string, signatureHeader: string | null): StripeWebhookEvent { + if (!this.webhookSecret) { + throw new StripeAppError("Stripe webhook secret is not configured", 500); + } + if (!signatureHeader) { + throw new StripeAppError("Missing Stripe signature header", 400); + } + + const parts = Object.fromEntries( + signatureHeader + .split(",") + .map((entry) => entry.split("=")) + .filter((entry): entry is [string, string] => entry.length === 2), + ); + const timestamp = parts.t; + const signature = parts.v1; + if (!timestamp || !signature) { + throw new StripeAppError("Malformed Stripe signature header", 400); + } + + const expected = createHmac("sha256", this.webhookSecret).update(`${timestamp}.${payload}`).digest("hex"); + + const expectedBuffer = Buffer.from(expected, "utf8"); + const actualBuffer = Buffer.from(signature, "utf8"); + if (expectedBuffer.length !== actualBuffer.length || !timingSafeEqual(expectedBuffer, actualBuffer)) { + throw new StripeAppError("Stripe signature verification failed", 400); + } + + return JSON.parse(payload) as StripeWebhookEvent; + } + + planIdForPriceId(priceId: string): FoundryBillingPlanId | null { + if (priceId === this.teamPriceId) { + return "team"; + } + return null; + } + + priceIdForPlan(planId: Exclude): string { + const priceId = this.teamPriceId; + if (!priceId) { + throw new StripeAppError(`Stripe price ID is not configured for ${planId}`, 500); + } + return priceId; + } + + private async requestJson(path: string): Promise { + if (!this.secretKey) { + throw new StripeAppError("Stripe is not configured", 500); + } + + const response = await fetch(`${this.apiBaseUrl}${path}`, { + headers: { + Authorization: `Bearer ${this.secretKey}`, + }, + }); + + const payload = (await response.json()) as T | { error?: { message?: string } }; + if (!response.ok) { + throw new StripeAppError( + typeof payload === "object" && payload && "error" in payload ? (payload.error?.message ?? "Stripe request failed") : "Stripe request failed", + response.status, + ); + } + return payload as T; + } + + private async formRequest(path: string, body: Record): Promise { + if (!this.secretKey) { + throw new StripeAppError("Stripe is not configured", 500); + } + + const form = new URLSearchParams(); + for (const [key, value] of Object.entries(body)) { + form.set(key, value); + } + + const response = await fetch(`${this.apiBaseUrl}${path}`, { + method: "POST", + headers: { + Authorization: `Bearer ${this.secretKey}`, + "Content-Type": "application/x-www-form-urlencoded", + }, + body: form, + }); + + const payload = (await response.json()) as T | { error?: { message?: string } }; + if (!response.ok) { + throw new StripeAppError( + typeof payload === "object" && payload && "error" in payload ? (payload.error?.message ?? "Stripe request failed") : "Stripe request failed", + response.status, + ); + } + return payload as T; + } +} + +function planIdFromMetadata(metadata: unknown): FoundryBillingPlanId | null { + if (!metadata || typeof metadata !== "object") { + return null; + } + const planId = (metadata as Record).planId; + return planId === "team" || planId === "free" ? planId : null; +} + +function firstStripePriceId(subscription: Record | null): string | null { + if (!subscription || typeof subscription.items !== "object" || !subscription.items) { + return null; + } + const data = (subscription.items as { data?: Array> }).data; + const first = data?.[0]; + if (!first || typeof first.price !== "object" || !first.price) { + return null; + } + return typeof (first.price as Record).id === "string" ? ((first.price as Record).id as string) : null; +} + +function paymentMethodLabelFromObject(paymentMethod: unknown): string { + if (!paymentMethod || typeof paymentMethod !== "object") { + return "Card on file"; + } + const card = (paymentMethod as Record).card; + if (card && typeof card === "object") { + const brand = typeof (card as Record).brand === "string" ? ((card as Record).brand as string) : "Card"; + const last4 = typeof (card as Record).last4 === "string" ? ((card as Record).last4 as string) : "file"; + return `${capitalize(brand)} ending in ${last4}`; + } + return "Payment method on file"; +} + +function stripeSubscriptionSnapshot(payload: Record): StripeSubscriptionSnapshot { + return { + id: typeof payload.id === "string" ? payload.id : "", + customerId: typeof payload.customer === "string" ? payload.customer : "", + priceId: firstStripePriceId(payload), + status: typeof payload.status === "string" ? payload.status : "active", + cancelAtPeriodEnd: payload.cancel_at_period_end === true, + currentPeriodEnd: typeof payload.current_period_end === "number" ? payload.current_period_end : null, + trialEnd: typeof payload.trial_end === "number" ? payload.trial_end : null, + defaultPaymentMethodLabel: paymentMethodLabelFromObject(payload.default_payment_method), + }; +} + +function capitalize(value: string): string { + return value.length > 0 ? `${value[0]!.toUpperCase()}${value.slice(1)}` : value; +} diff --git a/factory/packages/backend/src/services/create-flow.ts b/foundry/packages/backend/src/services/create-flow.ts similarity index 89% rename from factory/packages/backend/src/services/create-flow.ts rename to foundry/packages/backend/src/services/create-flow.ts index 281ebb5..8341399 100644 --- a/factory/packages/backend/src/services/create-flow.ts +++ b/foundry/packages/backend/src/services/create-flow.ts @@ -3,7 +3,7 @@ export interface ResolveCreateFlowDecisionInput { explicitTitle?: string; explicitBranchName?: string; localBranches: string[]; - handoffBranches: string[]; + taskBranches: string[]; } export interface ResolveCreateFlowDecisionResult { @@ -20,7 +20,7 @@ function firstNonEmptyLine(input: string): string { } export function deriveFallbackTitle(task: string, explicitTitle?: string): string { - const source = (explicitTitle && explicitTitle.trim()) || firstNonEmptyLine(task) || "update handoff"; + const source = (explicitTitle && explicitTitle.trim()) || firstNonEmptyLine(task) || "update task"; const explicitPrefixMatch = source.match(/^\s*(feat|fix|docs|refactor):\s+(.+)$/i); if (explicitPrefixMatch) { const explicitTypePrefix = explicitPrefixMatch[1]!.toLowerCase(); @@ -34,7 +34,7 @@ export function deriveFallbackTitle(task: string, explicitTitle?: string): strin .slice(0, 62) .trim(); - return `${explicitTypePrefix}: ${explicitSummary || "update handoff"}`; + return `${explicitTypePrefix}: ${explicitSummary || "update task"}`; } const lowered = source.toLowerCase(); @@ -56,7 +56,7 @@ export function deriveFallbackTitle(task: string, explicitTitle?: string): strin .filter((token) => token.length > 0) .join(" "); - const summary = (cleaned || "update handoff").slice(0, 62).trim(); + const summary = (cleaned || "update task").slice(0, 62).trim(); return `${typePrefix}: ${summary}`.trim(); } @@ -92,13 +92,13 @@ export function sanitizeBranchName(input: string): string { export function resolveCreateFlowDecision(input: ResolveCreateFlowDecisionInput): ResolveCreateFlowDecisionResult { const explicitBranch = input.explicitBranchName?.trim(); const title = deriveFallbackTitle(input.task, input.explicitTitle); - const generatedBase = sanitizeBranchName(title) || "handoff"; + const generatedBase = sanitizeBranchName(title) || "task"; const branchBase = explicitBranch && explicitBranch.length > 0 ? explicitBranch : generatedBase; const existingBranches = new Set(input.localBranches.map((value) => value.trim()).filter((value) => value.length > 0)); - const existingHandoffBranches = new Set(input.handoffBranches.map((value) => value.trim()).filter((value) => value.length > 0)); - const conflicts = (name: string): boolean => existingBranches.has(name) || existingHandoffBranches.has(name); + const existingTaskBranches = new Set(input.taskBranches.map((value) => value.trim()).filter((value) => value.length > 0)); + const conflicts = (name: string): boolean => existingBranches.has(name) || existingTaskBranches.has(name); if (explicitBranch && conflicts(branchBase)) { throw new Error(`Branch '${branchBase}' already exists. Choose a different --name/--branch value.`); diff --git a/factory/packages/backend/src/services/openhandoff-paths.ts b/foundry/packages/backend/src/services/foundry-paths.ts similarity index 55% rename from factory/packages/backend/src/services/openhandoff-paths.ts rename to foundry/packages/backend/src/services/foundry-paths.ts index e008154..d56c38d 100644 --- a/factory/packages/backend/src/services/openhandoff-paths.ts +++ b/foundry/packages/backend/src/services/foundry-paths.ts @@ -1,4 +1,4 @@ -import type { AppConfig } from "@openhandoff/shared"; +import type { AppConfig } from "@sandbox-agent/foundry-shared"; import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; @@ -9,12 +9,12 @@ function expandPath(input: string): string { return input; } -export function openhandoffDataDir(config: AppConfig): string { +export function foundryDataDir(config: AppConfig): string { // Keep data collocated with the backend DB by default. const dbPath = expandPath(config.backend.dbPath); return resolve(dirname(dbPath)); } -export function openhandoffRepoClonePath(config: AppConfig, workspaceId: string, repoId: string): string { - return resolve(join(openhandoffDataDir(config), "repos", workspaceId, repoId)); +export function foundryRepoClonePath(config: AppConfig, workspaceId: string, repoId: string): string { + return resolve(join(foundryDataDir(config), "repos", workspaceId, repoId)); } diff --git a/factory/packages/backend/src/services/queue.ts b/foundry/packages/backend/src/services/queue.ts similarity index 100% rename from factory/packages/backend/src/services/queue.ts rename to foundry/packages/backend/src/services/queue.ts diff --git a/factory/packages/backend/src/services/repo-git-lock.ts b/foundry/packages/backend/src/services/repo-git-lock.ts similarity index 100% rename from factory/packages/backend/src/services/repo-git-lock.ts rename to foundry/packages/backend/src/services/repo-git-lock.ts diff --git a/factory/packages/backend/src/services/repo.ts b/foundry/packages/backend/src/services/repo.ts similarity index 58% rename from factory/packages/backend/src/services/repo.ts rename to foundry/packages/backend/src/services/repo.ts index 94c7bd2..910f4e8 100644 --- a/factory/packages/backend/src/services/repo.ts +++ b/foundry/packages/backend/src/services/repo.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import { basename, sep } from "node:path"; export function normalizeRemoteUrl(remoteUrl: string): string { let value = remoteUrl.trim(); @@ -48,3 +49,36 @@ export function repoIdFromRemote(remoteUrl: string): string { const normalized = normalizeRemoteUrl(remoteUrl); return createHash("sha1").update(normalized).digest("hex").slice(0, 16); } + +export function repoLabelFromRemote(remoteUrl: string): string { + const trimmed = remoteUrl.trim(); + if (!trimmed) { + return ""; + } + + try { + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed) || trimmed.startsWith("file:")) { + const url = new URL(trimmed); + const parts = url.pathname.replace(/\/+$/, "").split("/").filter(Boolean); + if (parts.length >= 2) { + return `${parts[parts.length - 2]}/${(parts[parts.length - 1] ?? "").replace(/\.git$/i, "")}`; + } + } else { + const url = new URL(trimmed.startsWith("http") ? trimmed : `https://${trimmed}`); + const parts = url.pathname.replace(/\/+$/, "").split("/").filter(Boolean); + if (parts.length >= 2) { + return `${parts[0]}/${(parts[1] ?? "").replace(/\.git$/i, "")}`; + } + } + } catch { + // Fall through to path-based parsing. + } + + const normalizedPath = trimmed.replace(/\\/g, sep); + const segments = normalizedPath.split(sep).filter(Boolean); + if (segments.length >= 2) { + return `${segments[segments.length - 2]}/${segments[segments.length - 1]!.replace(/\.git$/i, "")}`; + } + + return basename(trimmed.replace(/\.git$/i, "")); +} diff --git a/factory/packages/backend/src/services/tmux.ts b/foundry/packages/backend/src/services/tmux.ts similarity index 100% rename from factory/packages/backend/src/services/tmux.ts rename to foundry/packages/backend/src/services/tmux.ts diff --git a/factory/packages/backend/test/create-flow.test.ts b/foundry/packages/backend/test/create-flow.test.ts similarity index 95% rename from factory/packages/backend/test/create-flow.test.ts rename to foundry/packages/backend/test/create-flow.test.ts index 27d0eaa..498c4dc 100644 --- a/factory/packages/backend/test/create-flow.test.ts +++ b/foundry/packages/backend/test/create-flow.test.ts @@ -21,7 +21,7 @@ describe("create flow decision", () => { const resolved = resolveCreateFlowDecision({ task: "Add auth", localBranches: ["feat-add-auth"], - handoffBranches: ["feat-add-auth-2"], + taskBranches: ["feat-add-auth-2"], }); expect(resolved.title).toBe("feat: Add auth"); @@ -34,7 +34,7 @@ describe("create flow decision", () => { task: "new task", explicitBranchName: "existing-branch", localBranches: ["existing-branch"], - handoffBranches: [], + taskBranches: [], }), ).toThrow("already exists"); }); diff --git a/factory/packages/backend/test/daytona-provider.test.ts b/foundry/packages/backend/test/daytona-provider.test.ts similarity index 95% rename from factory/packages/backend/test/daytona-provider.test.ts rename to foundry/packages/backend/test/daytona-provider.test.ts index 49d4fa9..363b405 100644 --- a/factory/packages/backend/test/daytona-provider.test.ts +++ b/foundry/packages/backend/test/daytona-provider.test.ts @@ -12,7 +12,7 @@ class RecordingDaytonaClient implements DaytonaClientLike { return { id: "sandbox-1", state: "started", - snapshot: "snapshot-openhandoff", + snapshot: "snapshot-foundry", labels: {}, }; } @@ -21,7 +21,7 @@ class RecordingDaytonaClient implements DaytonaClientLike { return { id: sandboxId, state: "started", - snapshot: "snapshot-openhandoff", + snapshot: "snapshot-foundry", labels: {}, }; } @@ -69,7 +69,7 @@ describe("daytona provider snapshot image behavior", () => { repoId: "repo-1", repoRemote: "https://github.com/acme/repo.git", branchName: "feature/test", - handoffId: "handoff-1", + taskId: "task-1", }); expect(client.createSandboxCalls).toHaveLength(1); @@ -92,9 +92,9 @@ describe("daytona provider snapshot image behavior", () => { expect(commands).toContain("GIT_TERMINAL_PROMPT=0"); expect(commands).toContain("GIT_ASKPASS=/bin/echo"); - expect(handle.metadata.snapshot).toBe("snapshot-openhandoff"); + expect(handle.metadata.snapshot).toBe("snapshot-foundry"); expect(handle.metadata.image).toBe("ubuntu:24.04"); - expect(handle.metadata.cwd).toBe("/home/daytona/openhandoff/default/repo-1/handoff-1/repo"); + expect(handle.metadata.cwd).toBe("/home/daytona/foundry/default/repo-1/task-1/repo"); expect(client.executedCommands.length).toBeGreaterThan(0); }); @@ -155,7 +155,7 @@ describe("daytona provider snapshot image behavior", () => { repoId: "repo-1", repoRemote: "https://github.com/acme/repo.git", branchName: "feature/test", - handoffId: "handoff-timeout", + taskId: "task-timeout", }), ).rejects.toThrow("daytona create sandbox timed out after 120ms"); } finally { diff --git a/factory/packages/backend/test/git-spice.test.ts b/foundry/packages/backend/test/git-spice.test.ts similarity index 100% rename from factory/packages/backend/test/git-spice.test.ts rename to foundry/packages/backend/test/git-spice.test.ts diff --git a/factory/packages/backend/test/git-validate-remote.test.ts b/foundry/packages/backend/test/git-validate-remote.test.ts similarity index 97% rename from factory/packages/backend/test/git-validate-remote.test.ts rename to foundry/packages/backend/test/git-validate-remote.test.ts index ea15ac7..47849a2 100644 --- a/factory/packages/backend/test/git-validate-remote.test.ts +++ b/foundry/packages/backend/test/git-validate-remote.test.ts @@ -27,7 +27,7 @@ describe("validateRemote", () => { mkdirSync(brokenRepoDir, { recursive: true }); writeFileSync(resolve(brokenRepoDir, ".git"), "gitdir: /definitely/missing/worktree\n", "utf8"); await execFileAsync("git", ["init", remoteRepoDir]); - await execFileAsync("git", ["-C", remoteRepoDir, "config", "user.name", "OpenHandoff Test"]); + await execFileAsync("git", ["-C", remoteRepoDir, "config", "user.name", "Foundry Test"]); await execFileAsync("git", ["-C", remoteRepoDir, "config", "user.email", "test@example.com"]); writeFileSync(resolve(remoteRepoDir, "README.md"), "# test\n", "utf8"); await execFileAsync("git", ["-C", remoteRepoDir, "add", "README.md"]); diff --git a/factory/packages/backend/test/helpers/test-context.ts b/foundry/packages/backend/test/helpers/test-context.ts similarity index 80% rename from factory/packages/backend/test/helpers/test-context.ts rename to foundry/packages/backend/test/helpers/test-context.ts index d32a8f3..07107ac 100644 --- a/factory/packages/backend/test/helpers/test-context.ts +++ b/foundry/packages/backend/test/helpers/test-context.ts @@ -1,9 +1,10 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; -import { ConfigSchema, type AppConfig } from "@openhandoff/shared"; +import { ConfigSchema, type AppConfig } from "@sandbox-agent/foundry-shared"; import type { BackendDriver } from "../../src/driver.js"; import { initActorRuntimeContext } from "../../src/actors/context.js"; import { createProviderRegistry } from "../../src/providers/index.js"; +import { createDefaultAppShellServices } from "../../src/services/app-shell-runtime.js"; export function createTestConfig(overrides?: Partial): AppConfig { return ConfigSchema.parse({ @@ -29,6 +30,6 @@ export function createTestConfig(overrides?: Partial): AppConfig { export function createTestRuntimeContext(driver: BackendDriver, configOverrides?: Partial): { config: AppConfig } { const config = createTestConfig(configOverrides); const providers = createProviderRegistry(config, driver); - initActorRuntimeContext(config, providers, undefined, driver); + initActorRuntimeContext(config, providers, undefined, driver, createDefaultAppShellServices()); return { config }; } diff --git a/factory/packages/backend/test/helpers/test-driver.ts b/foundry/packages/backend/test/helpers/test-driver.ts similarity index 94% rename from factory/packages/backend/test/helpers/test-driver.ts rename to foundry/packages/backend/test/helpers/test-driver.ts index 97ef444..c5b8bc4 100644 --- a/factory/packages/backend/test/helpers/test-driver.ts +++ b/foundry/packages/backend/test/helpers/test-driver.ts @@ -9,15 +9,7 @@ import type { SandboxAgentClientLike, TmuxDriver, } from "../../src/driver.js"; -import type { - ListEventsRequest, - ListPage, - ListPageRequest, - ProcessInfo, - ProcessLogsResponse, - SessionEvent, - SessionRecord, -} from "sandbox-agent"; +import type { ListEventsRequest, ListPage, ListPageRequest, ProcessInfo, ProcessLogsResponse, SessionEvent, SessionRecord } from "sandbox-agent"; export function createTestDriver(overrides?: Partial): BackendDriver { return { @@ -78,9 +70,7 @@ export function createTestSandboxAgentDriver(overrides?: Partial -): SandboxAgentClientLike { +export function createTestSandboxAgentClient(overrides?: Partial): SandboxAgentClientLike { const defaultProcess: ProcessInfo = { id: "process-1", command: "bash", diff --git a/factory/packages/backend/test/keys.test.ts b/foundry/packages/backend/test/keys.test.ts similarity index 80% rename from factory/packages/backend/test/keys.test.ts rename to foundry/packages/backend/test/keys.test.ts index be69ca0..b00a54d 100644 --- a/factory/packages/backend/test/keys.test.ts +++ b/foundry/packages/backend/test/keys.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { - handoffKey, - handoffStatusSyncKey, + taskKey, + taskStatusSyncKey, historyKey, projectBranchSyncKey, projectKey, @@ -15,12 +15,12 @@ describe("actor keys", () => { const keys = [ workspaceKey("default"), projectKey("default", "repo"), - handoffKey("default", "repo", "handoff"), + taskKey("default", "repo", "task"), sandboxInstanceKey("default", "daytona", "sbx"), historyKey("default", "repo"), projectPrSyncKey("default", "repo"), projectBranchSyncKey("default", "repo"), - handoffStatusSyncKey("default", "repo", "handoff", "sandbox-1", "session-1"), + taskStatusSyncKey("default", "repo", "task", "sandbox-1", "session-1"), ]; for (const key of keys) { diff --git a/factory/packages/backend/test/malformed-uri.test.ts b/foundry/packages/backend/test/malformed-uri.test.ts similarity index 100% rename from factory/packages/backend/test/malformed-uri.test.ts rename to foundry/packages/backend/test/malformed-uri.test.ts diff --git a/factory/packages/backend/test/providers.test.ts b/foundry/packages/backend/test/providers.test.ts similarity index 91% rename from factory/packages/backend/test/providers.test.ts rename to foundry/packages/backend/test/providers.test.ts index 3711f27..f659e27 100644 --- a/factory/packages/backend/test/providers.test.ts +++ b/foundry/packages/backend/test/providers.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { ConfigSchema, type AppConfig } from "@openhandoff/shared"; +import { ConfigSchema, type AppConfig } from "@sandbox-agent/foundry-shared"; import { createProviderRegistry } from "../src/providers/index.js"; function makeConfig(): AppConfig { @@ -10,7 +10,7 @@ function makeConfig(): AppConfig { backend: { host: "127.0.0.1", port: 7741, - dbPath: "~/.local/share/openhandoff/handoff.db", + dbPath: "~/.local/share/foundry/task.db", opencode_poll_interval: 2, github_poll_interval: 30, backup_interval_secs: 3600, diff --git a/foundry/packages/backend/test/repo-normalize.test.ts b/foundry/packages/backend/test/repo-normalize.test.ts new file mode 100644 index 0000000..e391952 --- /dev/null +++ b/foundry/packages/backend/test/repo-normalize.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "vitest"; +import { normalizeRemoteUrl, repoIdFromRemote } from "../src/services/repo.js"; + +describe("normalizeRemoteUrl", () => { + test("accepts GitHub shorthand owner/repo", () => { + expect(normalizeRemoteUrl("rivet-dev/sandbox-agent")).toBe("https://github.com/rivet-dev/sandbox-agent.git"); + }); + + test("accepts github.com/owner/repo without scheme", () => { + expect(normalizeRemoteUrl("github.com/rivet-dev/sandbox-agent")).toBe("https://github.com/rivet-dev/sandbox-agent.git"); + }); + + test("canonicalizes GitHub repo URLs without .git", () => { + expect(normalizeRemoteUrl("https://github.com/rivet-dev/sandbox-agent")).toBe("https://github.com/rivet-dev/sandbox-agent.git"); + }); + + test("canonicalizes GitHub non-clone URLs (e.g. /tree/main)", () => { + expect(normalizeRemoteUrl("https://github.com/rivet-dev/sandbox-agent/tree/main")).toBe("https://github.com/rivet-dev/sandbox-agent.git"); + }); + + test("does not rewrite scp-style ssh remotes", () => { + expect(normalizeRemoteUrl("git@github.com:rivet-dev/sandbox-agent.git")).toBe("git@github.com:rivet-dev/sandbox-agent.git"); + }); +}); + +describe("repoIdFromRemote", () => { + test("repoId is stable across equivalent GitHub inputs", () => { + const a = repoIdFromRemote("rivet-dev/sandbox-agent"); + const b = repoIdFromRemote("https://github.com/rivet-dev/sandbox-agent.git"); + const c = repoIdFromRemote("https://github.com/rivet-dev/sandbox-agent/tree/main"); + expect(a).toBe(b); + expect(b).toBe(c); + }); +}); diff --git a/factory/packages/backend/test/sandbox-instance-persist.test.ts b/foundry/packages/backend/test/sandbox-instance-persist.test.ts similarity index 100% rename from factory/packages/backend/test/sandbox-instance-persist.test.ts rename to foundry/packages/backend/test/sandbox-instance-persist.test.ts diff --git a/factory/packages/backend/test/setup.ts b/foundry/packages/backend/test/setup.ts similarity index 100% rename from factory/packages/backend/test/setup.ts rename to foundry/packages/backend/test/setup.ts diff --git a/factory/packages/backend/test/stack-model.test.ts b/foundry/packages/backend/test/stack-model.test.ts similarity index 100% rename from factory/packages/backend/test/stack-model.test.ts rename to foundry/packages/backend/test/stack-model.test.ts diff --git a/factory/packages/backend/test/workbench-unread.test.ts b/foundry/packages/backend/test/workbench-unread.test.ts similarity index 96% rename from factory/packages/backend/test/workbench-unread.test.ts rename to foundry/packages/backend/test/workbench-unread.test.ts index 28a05d9..f7ed201 100644 --- a/factory/packages/backend/test/workbench-unread.test.ts +++ b/foundry/packages/backend/test/workbench-unread.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { shouldMarkSessionUnreadForStatus } from "../src/actors/handoff/workbench.js"; +import { shouldMarkSessionUnreadForStatus } from "../src/actors/task/workbench.js"; describe("workbench unread status transitions", () => { it("marks unread when a running session first becomes idle", () => { diff --git a/factory/packages/backend/test/workspace-isolation.test.ts b/foundry/packages/backend/test/workspace-isolation.test.ts similarity index 86% rename from factory/packages/backend/test/workspace-isolation.test.ts rename to foundry/packages/backend/test/workspace-isolation.test.ts index 577a3ae..fd0689d 100644 --- a/factory/packages/backend/test/workspace-isolation.test.ts +++ b/foundry/packages/backend/test/workspace-isolation.test.ts @@ -17,7 +17,7 @@ function createRepo(): { repoPath: string } { const repoPath = mkdtempSync(join(tmpdir(), "hf-isolation-repo-")); execFileSync("git", ["init"], { cwd: repoPath }); execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoPath }); - execFileSync("git", ["config", "user.name", "OpenHandoff Test"], { cwd: repoPath }); + execFileSync("git", ["config", "user.name", "Foundry Test"], { cwd: repoPath }); writeFileSync(join(repoPath, "README.md"), "hello\n", "utf8"); execFileSync("git", ["add", "README.md"], { cwd: repoPath }); execFileSync("git", ["commit", "-m", "init"], { cwd: repoPath }); @@ -26,17 +26,17 @@ function createRepo(): { repoPath: string } { async function waitForWorkspaceRows(ws: any, workspaceId: string, expectedCount: number) { for (let attempt = 0; attempt < 40; attempt += 1) { - const rows = await ws.listHandoffs({ workspaceId }); + const rows = await ws.listTasks({ workspaceId }); if (rows.length >= expectedCount) { return rows; } await delay(50); } - return ws.listHandoffs({ workspaceId }); + return ws.listTasks({ workspaceId }); } describe("workspace isolation", () => { - it.skipIf(!runActorIntegration)("keeps handoff lists isolated by workspace", async (t) => { + it.skipIf(!runActorIntegration)("keeps task lists isolated by workspace", async (t) => { const testDriver = createTestDriver(); createTestRuntimeContext(testDriver); @@ -52,7 +52,7 @@ describe("workspace isolation", () => { const repoA = await wsA.addRepo({ workspaceId: "alpha", remoteUrl: repoPath }); const repoB = await wsB.addRepo({ workspaceId: "beta", remoteUrl: repoPath }); - await wsA.createHandoff({ + await wsA.createTask({ workspaceId: "alpha", repoId: repoA.repoId, task: "task A", @@ -61,7 +61,7 @@ describe("workspace isolation", () => { explicitTitle: "A", }); - await wsB.createHandoff({ + await wsB.createTask({ workspaceId: "beta", repoId: repoB.repoId, task: "task B", @@ -77,6 +77,6 @@ describe("workspace isolation", () => { expect(bRows.length).toBe(1); expect(aRows[0]?.workspaceId).toBe("alpha"); expect(bRows[0]?.workspaceId).toBe("beta"); - expect(aRows[0]?.handoffId).not.toBe(bRows[0]?.handoffId); + expect(aRows[0]?.taskId).not.toBe(bRows[0]?.taskId); }); }); diff --git a/factory/packages/backend/test/workspace-star-sandbox-agent-repo.test.ts b/foundry/packages/backend/test/workspace-star-sandbox-agent-repo.test.ts similarity index 100% rename from factory/packages/backend/test/workspace-star-sandbox-agent-repo.test.ts rename to foundry/packages/backend/test/workspace-star-sandbox-agent-repo.test.ts diff --git a/factory/packages/backend/tsconfig.json b/foundry/packages/backend/tsconfig.json similarity index 100% rename from factory/packages/backend/tsconfig.json rename to foundry/packages/backend/tsconfig.json diff --git a/factory/packages/backend/vitest.config.ts b/foundry/packages/backend/vitest.config.ts similarity index 100% rename from factory/packages/backend/vitest.config.ts rename to foundry/packages/backend/vitest.config.ts diff --git a/factory/packages/cli/package.json b/foundry/packages/cli/package.json similarity index 75% rename from factory/packages/cli/package.json rename to foundry/packages/cli/package.json index e6ff8f4..de75c1f 100644 --- a/factory/packages/cli/package.json +++ b/foundry/packages/cli/package.json @@ -1,5 +1,5 @@ { - "name": "@openhandoff/cli", + "name": "@sandbox-agent/foundry-cli", "version": "0.1.0", "private": true, "type": "module", @@ -16,8 +16,8 @@ "dependencies": { "@iarna/toml": "^2.2.5", "@opentui/core": "^0.1.77", - "@openhandoff/client": "workspace:*", - "@openhandoff/shared": "workspace:*", + "@sandbox-agent/foundry-client": "workspace:*", + "@sandbox-agent/foundry-shared": "workspace:*", "zod": "^4.1.5" }, "devDependencies": { diff --git a/factory/packages/cli/src/backend/manager.ts b/foundry/packages/cli/src/backend/manager.ts similarity index 96% rename from factory/packages/cli/src/backend/manager.ts rename to foundry/packages/cli/src/backend/manager.ts index 5d96e4b..bed268c 100644 --- a/factory/packages/cli/src/backend/manager.ts +++ b/foundry/packages/cli/src/backend/manager.ts @@ -3,8 +3,8 @@ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, write import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { checkBackendHealth } from "@openhandoff/client"; -import type { AppConfig } from "@openhandoff/shared"; +import { checkBackendHealth } from "@sandbox-agent/foundry-client"; +import type { AppConfig } from "@sandbox-agent/foundry-shared"; import { CLI_BUILD_ID } from "../build-id.js"; const HEALTH_TIMEOUT_MS = 1_500; @@ -31,10 +31,10 @@ function backendStateDir(): string { const xdgDataHome = process.env.XDG_DATA_HOME?.trim(); if (xdgDataHome) { - return join(xdgDataHome, "openhandoff", "backend"); + return join(xdgDataHome, "foundry", "backend"); } - return join(homedir(), ".local", "share", "openhandoff", "backend"); + return join(homedir(), ".local", "share", "foundry", "backend"); } function backendPidPath(host: string, port: number): string { @@ -204,7 +204,7 @@ function resolveLaunchSpec(host: string, port: number): LaunchSpec { return { command: "pnpm", - args: ["--filter", "@openhandoff/backend", "exec", "bun", "src/index.ts", "start", "--host", host, "--port", String(port)], + args: ["--filter", "@sandbox-agent/foundry-backend", "exec", "bun", "src/index.ts", "start", "--host", host, "--port", String(port)], cwd: repoRoot, }; } diff --git a/factory/packages/cli/src/build-id.ts b/foundry/packages/cli/src/build-id.ts similarity index 100% rename from factory/packages/cli/src/build-id.ts rename to foundry/packages/cli/src/build-id.ts diff --git a/factory/packages/cli/src/index.ts b/foundry/packages/cli/src/index.ts similarity index 81% rename from factory/packages/cli/src/index.ts rename to foundry/packages/cli/src/index.ts index dcb8fae..0e054eb 100644 --- a/factory/packages/cli/src/index.ts +++ b/foundry/packages/cli/src/index.ts @@ -2,8 +2,8 @@ import { spawnSync } from "node:child_process"; import { existsSync } from "node:fs"; import { homedir } from "node:os"; -import { AgentTypeSchema, CreateHandoffInputSchema, type HandoffRecord } from "@openhandoff/shared"; -import { readBackendMetadata, createBackendClientFromConfig, formatRelativeAge, groupHandoffStatus, summarizeHandoffs } from "@openhandoff/client"; +import { AgentTypeSchema, CreateTaskInputSchema, type TaskRecord } from "@sandbox-agent/foundry-shared"; +import { readBackendMetadata, createBackendClientFromConfig, formatRelativeAge, groupTaskStatus, summarizeTasks } from "@sandbox-agent/foundry-client"; import { ensureBackendRunning, getBackendStatus, parseBackendPort, stopBackend } from "./backend/manager.js"; import { openEditorForTask } from "./task-editor.js"; import { spawnCreateTmuxWindow } from "./tmux.js"; @@ -94,19 +94,19 @@ Usage: hf backend status hf backend inspect hf status [--workspace WS] [--json] - hf history [--workspace WS] [--limit N] [--branch NAME] [--handoff ID] [--json] + hf history [--workspace WS] [--limit N] [--branch NAME] [--task ID] [--json] hf workspace use hf tui [--workspace WS] hf create [task] [--workspace WS] --repo [--name NAME|--branch NAME] [--title TITLE] [--agent claude|codex] [--on BRANCH] hf list [--workspace WS] [--format table|json] [--full] - hf switch [handoff-id | -] [--workspace WS] - hf attach [--workspace WS] - hf merge [--workspace WS] - hf archive [--workspace WS] - hf push [--workspace WS] - hf sync [--workspace WS] - hf kill [--workspace WS] [--delete-branch] [--abandon] + hf switch [task-id | -] [--workspace WS] + hf attach [--workspace WS] + hf merge [--workspace WS] + hf archive [--workspace WS] + hf push [--workspace WS] + hf sync [--workspace WS] + hf kill [--workspace WS] [--delete-branch] [--abandon] hf prune [--workspace WS] [--dry-run] [--yes] hf statusline [--workspace WS] [--format table|claude-code] hf db path @@ -115,7 +115,7 @@ Usage: Tips: hf status --help Show status output format and examples hf history --help Show history output format and examples - hf switch - Switch to most recently updated handoff + hf switch - Switch to most recently updated task `); } @@ -127,7 +127,7 @@ Usage: Text Output: workspace= backend running= pid= version= - handoffs total= + tasks total= status queued= running= idle= archived= killed= error= providers = ... providers - @@ -136,7 +136,7 @@ JSON Output: { "workspaceId": "default", "backend": { ...backend status object... }, - "handoffs": { + "tasks": { "total": 4, "byStatus": { "queued": 0, "running": 1, "idle": 2, "archived": 1, "killed": 0, "error": 0 }, "byProvider": { "daytona": 4 } @@ -148,11 +148,11 @@ JSON Output: function printHistoryUsage(): void { console.log(` Usage: - hf history [--workspace WS] [--limit N] [--branch NAME] [--handoff ID] [--json] + hf history [--workspace WS] [--limit N] [--branch NAME] [--task ID] [--json] Text Output: - \t\t\t - \t\t\t + \t\t\t + \t\t\t no events Notes: @@ -164,8 +164,8 @@ JSON Output: { "id": "...", "workspaceId": "default", - "kind": "handoff.created", - "handoffId": "...", + "kind": "task.created", + "taskId": "...", "repoId": "...", "branchName": "feature/foo", "payloadJson": "{\\"providerId\\":\\"daytona\\"}", @@ -262,7 +262,7 @@ async function handleList(args: string[]): Promise { const format = readOption(args, "--format") ?? "table"; const full = hasFlag(args, "--full"); const client = createBackendClientFromConfig(config); - const rows = await client.listHandoffs(workspaceId); + const rows = await client.listTasks(workspaceId); if (format === "json") { console.log(JSON.stringify(rows, null, 2)); @@ -270,13 +270,13 @@ async function handleList(args: string[]): Promise { } if (rows.length === 0) { - console.log("no handoffs"); + console.log("no tasks"); return; } for (const row of rows) { const age = formatRelativeAge(row.updatedAt); - let line = `${row.handoffId}\t${row.branchName}\t${row.status}\t${row.providerId}\t${age}`; + let line = `${row.taskId}\t${row.branchName}\t${row.status}\t${row.providerId}\t${age}`; if (full) { const task = row.task.length > 60 ? `${row.task.slice(0, 57)}...` : row.task; line += `\t${row.title}\t${task}\t${row.activeSessionId ?? "-"}\t${row.activeSandboxId ?? "-"}`; @@ -286,33 +286,33 @@ async function handleList(args: string[]): Promise { } async function handlePush(args: string[]): Promise { - const handoffId = positionals(args)[0]; - if (!handoffId) { - throw new Error("Missing handoff id for push"); + const taskId = positionals(args)[0]; + if (!taskId) { + throw new Error("Missing task id for push"); } const config = loadConfig(); const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config); const client = createBackendClientFromConfig(config); - await client.runAction(workspaceId, handoffId, "push"); + await client.runAction(workspaceId, taskId, "push"); console.log("ok"); } async function handleSync(args: string[]): Promise { - const handoffId = positionals(args)[0]; - if (!handoffId) { - throw new Error("Missing handoff id for sync"); + const taskId = positionals(args)[0]; + if (!taskId) { + throw new Error("Missing task id for sync"); } const config = loadConfig(); const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config); const client = createBackendClientFromConfig(config); - await client.runAction(workspaceId, handoffId, "sync"); + await client.runAction(workspaceId, taskId, "sync"); console.log("ok"); } async function handleKill(args: string[]): Promise { - const handoffId = positionals(args)[0]; - if (!handoffId) { - throw new Error("Missing handoff id for kill"); + const taskId = positionals(args)[0]; + if (!taskId) { + throw new Error("Missing task id for kill"); } const config = loadConfig(); const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config); @@ -327,7 +327,7 @@ async function handleKill(args: string[]): Promise { } const client = createBackendClientFromConfig(config); - await client.runAction(workspaceId, handoffId, "kill"); + await client.runAction(workspaceId, taskId, "kill"); console.log("ok"); } @@ -337,7 +337,7 @@ async function handlePrune(args: string[]): Promise { const dryRun = hasFlag(args, "--dry-run"); const yes = hasFlag(args, "--yes"); const client = createBackendClientFromConfig(config); - const rows = await client.listHandoffs(workspaceId); + const rows = await client.listTasks(workspaceId); const prunable = rows.filter((r) => r.status === "archived" || r.status === "killed"); if (prunable.length === 0) { @@ -347,11 +347,11 @@ async function handlePrune(args: string[]): Promise { for (const row of prunable) { const age = formatRelativeAge(row.updatedAt); - console.log(`${dryRun ? "[dry-run] " : ""}${row.handoffId}\t${row.branchName}\t${row.status}\t${age}`); + console.log(`${dryRun ? "[dry-run] " : ""}${row.taskId}\t${row.branchName}\t${row.status}\t${age}`); } if (dryRun) { - console.log(`\n${prunable.length} handoff(s) would be pruned`); + console.log(`\n${prunable.length} task(s) would be pruned`); return; } @@ -360,7 +360,7 @@ async function handlePrune(args: string[]): Promise { return; } - console.log(`\n${prunable.length} handoff(s) would be pruned (pruning not yet implemented)`); + console.log(`\n${prunable.length} task(s) would be pruned (pruning not yet implemented)`); } async function handleStatusline(args: string[]): Promise { @@ -368,8 +368,8 @@ async function handleStatusline(args: string[]): Promise { const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config); const format = readOption(args, "--format") ?? "table"; const client = createBackendClientFromConfig(config); - const rows = await client.listHandoffs(workspaceId); - const summary = summarizeHandoffs(rows); + const rows = await client.listTasks(workspaceId); + const summary = summarizeTasks(rows); const running = summary.byStatus.running; const idle = summary.byStatus.idle; const errorCount = summary.byStatus.error; @@ -399,29 +399,29 @@ async function handleDb(args: string[]): Promise { throw new Error("Usage: hf db path | hf db nuke"); } -async function waitForHandoffReady( +async function waitForTaskReady( client: ReturnType, workspaceId: string, - handoffId: string, + taskId: string, timeoutMs: number, -): Promise { +): Promise { const start = Date.now(); let delayMs = 250; for (;;) { - const record = await client.getHandoff(workspaceId, handoffId); + const record = await client.getTask(workspaceId, taskId); const hasName = Boolean(record.branchName && record.title); const hasSandbox = Boolean(record.activeSandboxId); if (record.status === "error") { - throw new Error(`handoff entered error state while provisioning: ${handoffId}`); + throw new Error(`task entered error state while provisioning: ${taskId}`); } if (hasName && hasSandbox) { return record; } if (Date.now() - start > timeoutMs) { - throw new Error(`timed out waiting for handoff provisioning: ${handoffId}`); + throw new Error(`timed out waiting for task provisioning: ${taskId}`); } await new Promise((r) => setTimeout(r, delayMs)); @@ -450,7 +450,7 @@ async function handleCreate(args: string[]): Promise { const client = createBackendClientFromConfig(config); const repo = await client.addRepo(workspaceId, repoRemote); - const payload = CreateHandoffInputSchema.parse({ + const payload = CreateTaskInputSchema.parse({ workspaceId, repoId: repo.repoId, task, @@ -460,31 +460,31 @@ async function handleCreate(args: string[]): Promise { onBranch, }); - const created = await client.createHandoff(payload); - const handoff = await waitForHandoffReady(client, workspaceId, created.handoffId, 180_000); - const switched = await client.switchHandoff(workspaceId, handoff.handoffId); - const attached = await client.attachHandoff(workspaceId, handoff.handoffId); + const created = await client.createTask(payload); + const task = await waitForTaskReady(client, workspaceId, created.taskId, 180_000); + const switched = await client.switchTask(workspaceId, task.taskId); + const attached = await client.attachTask(workspaceId, task.taskId); - console.log(`Branch: ${handoff.branchName ?? "-"}`); - console.log(`Handoff: ${handoff.handoffId}`); - console.log(`Provider: ${handoff.providerId}`); + console.log(`Branch: ${task.branchName ?? "-"}`); + console.log(`Task: ${task.taskId}`); + console.log(`Provider: ${task.providerId}`); console.log(`Session: ${attached.sessionId ?? "none"}`); console.log(`Target: ${switched.switchTarget || attached.target}`); - console.log(`Title: ${handoff.title ?? "-"}`); + console.log(`Title: ${task.title ?? "-"}`); const tmuxResult = spawnCreateTmuxWindow({ - branchName: handoff.branchName ?? handoff.handoffId, + branchName: task.branchName ?? task.taskId, targetPath: switched.switchTarget || attached.target, sessionId: attached.sessionId, }); if (tmuxResult.created) { - console.log(`Window: created (${handoff.branchName})`); + console.log(`Window: created (${task.branchName})`); return; } console.log(""); - console.log(`Run: hf switch ${handoff.handoffId}`); + console.log(`Run: hf switch ${task.taskId}`); if ((switched.switchTarget || attached.target).startsWith("/")) { console.log(`cd ${switched.switchTarget || attached.target}`); } @@ -506,8 +506,8 @@ async function handleStatus(args: string[]): Promise { const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config); const client = createBackendClientFromConfig(config); const backendStatus = await getBackendStatus(config.backend.host, config.backend.port); - const rows = await client.listHandoffs(workspaceId); - const summary = summarizeHandoffs(rows); + const rows = await client.listTasks(workspaceId); + const summary = summarizeTasks(rows); if (hasFlag(args, "--json")) { console.log( @@ -515,7 +515,7 @@ async function handleStatus(args: string[]): Promise { { workspaceId, backend: backendStatus, - handoffs: { + tasks: { total: summary.total, byStatus: summary.byStatus, byProvider: summary.byProvider, @@ -530,7 +530,7 @@ async function handleStatus(args: string[]): Promise { console.log(`workspace=${workspaceId}`); console.log(`backend running=${backendStatus.running} pid=${backendStatus.pid ?? "unknown"} version=${backendStatus.version ?? "unknown"}`); - console.log(`handoffs total=${summary.total}`); + console.log(`tasks total=${summary.total}`); console.log( `status queued=${summary.byStatus.queued} running=${summary.byStatus.running} idle=${summary.byStatus.idle} archived=${summary.byStatus.archived} killed=${summary.byStatus.killed} error=${summary.byStatus.error}`, ); @@ -550,13 +550,13 @@ async function handleHistory(args: string[]): Promise { const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config); const limit = parseIntOption(readOption(args, "--limit"), 20, "limit"); const branch = readOption(args, "--branch"); - const handoffId = readOption(args, "--handoff"); + const taskId = readOption(args, "--task"); const client = createBackendClientFromConfig(config); const rows = await client.listHistory({ workspaceId, limit, branch: branch || undefined, - handoffId: handoffId || undefined, + taskId: taskId || undefined, }); if (hasFlag(args, "--json")) { @@ -571,7 +571,7 @@ async function handleHistory(args: string[]): Promise { for (const row of rows) { const ts = new Date(row.createdAt).toISOString(); - const target = row.branchName || row.handoffId || row.repoId || "-"; + const target = row.branchName || row.taskId || row.repoId || "-"; let payload = row.payloadJson; if (payload.length > 120) { payload = `${payload.slice(0, 117)}...`; @@ -581,48 +581,48 @@ async function handleHistory(args: string[]): Promise { } async function handleSwitchLike(cmd: string, args: string[]): Promise { - let handoffId = positionals(args)[0]; - if (!handoffId && cmd === "switch") { + let taskId = positionals(args)[0]; + if (!taskId && cmd === "switch") { await handleTui(args); return; } - if (!handoffId) { - throw new Error(`Missing handoff id for ${cmd}`); + if (!taskId) { + throw new Error(`Missing task id for ${cmd}`); } const config = loadConfig(); const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config); const client = createBackendClientFromConfig(config); - if (cmd === "switch" && handoffId === "-") { - const rows = await client.listHandoffs(workspaceId); + if (cmd === "switch" && taskId === "-") { + const rows = await client.listTasks(workspaceId); const active = rows.filter((r) => { - const group = groupHandoffStatus(r.status); + const group = groupTaskStatus(r.status); return group === "running" || group === "idle" || group === "queued"; }); const sorted = active.sort((a, b) => b.updatedAt - a.updatedAt); const target = sorted[0]; if (!target) { - throw new Error("No active handoffs to switch to"); + throw new Error("No active tasks to switch to"); } - handoffId = target.handoffId; + taskId = target.taskId; } if (cmd === "switch") { - const result = await client.switchHandoff(workspaceId, handoffId); + const result = await client.switchTask(workspaceId, taskId); console.log(`cd ${result.switchTarget}`); return; } if (cmd === "attach") { - const result = await client.attachHandoff(workspaceId, handoffId); + const result = await client.attachTask(workspaceId, taskId); console.log(`target=${result.target} session=${result.sessionId ?? "none"}`); return; } if (cmd === "merge" || cmd === "archive") { - await client.runAction(workspaceId, handoffId, cmd); + await client.runAction(workspaceId, taskId, cmd); console.log("ok"); return; } diff --git a/factory/packages/cli/src/task-editor.ts b/foundry/packages/cli/src/task-editor.ts similarity index 86% rename from factory/packages/cli/src/task-editor.ts rename to foundry/packages/cli/src/task-editor.ts index 5d79284..502aa13 100644 --- a/factory/packages/cli/src/task-editor.ts +++ b/foundry/packages/cli/src/task-editor.ts @@ -3,7 +3,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { spawnSync } from "node:child_process"; -const DEFAULT_EDITOR_TEMPLATE = ["# Enter handoff task details below.", "# Lines starting with # are ignored.", ""].join("\n"); +const DEFAULT_EDITOR_TEMPLATE = ["# Enter task task details below.", "# Lines starting with # are ignored.", ""].join("\n"); export function sanitizeEditorTask(input: string): string { return input @@ -32,7 +32,7 @@ export function openEditorForTask(): string { const raw = readFileSync(taskPath, "utf8"); const task = sanitizeEditorTask(raw); if (!task) { - throw new Error("Missing handoff task text"); + throw new Error("Missing task task text"); } return task; } finally { diff --git a/factory/packages/cli/src/theme.ts b/foundry/packages/cli/src/theme.ts similarity index 99% rename from factory/packages/cli/src/theme.ts rename to foundry/packages/cli/src/theme.ts index 9d47afe..5315a44 100644 --- a/factory/packages/cli/src/theme.ts +++ b/foundry/packages/cli/src/theme.ts @@ -3,7 +3,7 @@ import { homedir } from "node:os"; import { dirname, isAbsolute, join, resolve } from "node:path"; import { cwd } from "node:process"; import * as toml from "@iarna/toml"; -import type { AppConfig } from "@openhandoff/shared"; +import type { AppConfig } from "@sandbox-agent/foundry-shared"; import opencodeThemePackJson from "./themes/opencode-pack.json" with { type: "json" }; export type ThemeMode = "dark" | "light"; @@ -101,7 +101,7 @@ export function resolveTuiTheme(config: AppConfig, baseDir = cwd()): TuiThemeRes return { theme: candidate.theme, name: candidate.name, - source: "openhandoff config", + source: "foundry config", mode, }; } diff --git a/factory/packages/cli/src/themes/opencode-pack.json b/foundry/packages/cli/src/themes/opencode-pack.json similarity index 100% rename from factory/packages/cli/src/themes/opencode-pack.json rename to foundry/packages/cli/src/themes/opencode-pack.json diff --git a/factory/packages/cli/src/tmux.ts b/foundry/packages/cli/src/tmux.ts similarity index 100% rename from factory/packages/cli/src/tmux.ts rename to foundry/packages/cli/src/tmux.ts diff --git a/factory/packages/cli/src/tui.ts b/foundry/packages/cli/src/tui.ts similarity index 90% rename from factory/packages/cli/src/tui.ts rename to foundry/packages/cli/src/tui.ts index 3e645ad..d19a569 100644 --- a/factory/packages/cli/src/tui.ts +++ b/foundry/packages/cli/src/tui.ts @@ -1,6 +1,6 @@ -import type { AppConfig, HandoffRecord } from "@openhandoff/shared"; +import type { AppConfig, TaskRecord } from "@sandbox-agent/foundry-shared"; import { spawnSync } from "node:child_process"; -import { createBackendClientFromConfig, filterHandoffs, formatRelativeAge, groupHandoffStatus } from "@openhandoff/client"; +import { createBackendClientFromConfig, filterTasks, formatRelativeAge, groupTaskStatus } from "@sandbox-agent/foundry-client"; import { CLI_BUILD_ID } from "./build-id.js"; import { resolveTuiTheme, type TuiTheme } from "./theme.js"; @@ -18,7 +18,7 @@ const HELP_LINES = [ "Ctrl-O open PR in browser", "Ctrl-X archive branch / close PR", "Ctrl-Y merge highlighted PR", - "Ctrl-S sync handoff with remote", + "Ctrl-S sync task with remote", "Ctrl-N / Down next row", "Ctrl-P / Up previous row", "Backspace delete filter", @@ -128,8 +128,8 @@ function buildFooterLine(width: number, segments: string[], right: string): stri return `${left}${padding}${right}`; } -function agentSymbol(status: HandoffRecord["status"]): string { - const group = groupHandoffStatus(status); +function agentSymbol(status: TaskRecord["status"]): string { + const group = groupTaskStatus(status); if (group === "running") return "🤖"; if (group === "idle") return "💬"; if (group === "error") return "⚠"; @@ -137,7 +137,7 @@ function agentSymbol(status: HandoffRecord["status"]): string { return "-"; } -function toDisplayRow(row: HandoffRecord): DisplayRow { +function toDisplayRow(row: TaskRecord): DisplayRow { const conflictPrefix = row.conflictsWithMain === "true" ? "\u26A0 " : ""; const prLabel = row.prUrl ? `#${row.prUrl.match(/\/pull\/(\d+)/)?.[1] ?? "?"}` : row.prSubmitted ? "sub" : "-"; @@ -180,7 +180,7 @@ function helpLines(width: number): string[] { } export function formatRows( - rows: HandoffRecord[], + rows: TaskRecord[], selected: number, workspaceId: string, status: string, @@ -322,7 +322,7 @@ export async function runTui(config: AppConfig, workspaceId: string): Promise { + const selectedRow = (): TaskRecord | null => { if (filteredRows.length === 0) { return null; } @@ -500,7 +500,7 @@ export async function runTui(config: AppConfig, workspaceId: string): Promise { try { - const result = await client.switchHandoff(workspaceId, row.handoffId); + const result = await client.switchTask(workspaceId, row.taskId); close(`cd ${result.switchTarget}`); } catch (err) { busy = false; @@ -533,11 +533,11 @@ export async function runTui(config: AppConfig, workspaceId: string): Promise { try { - const result = await client.attachHandoff(workspaceId, row.handoffId); + const result = await client.attachTask(workspaceId, row.taskId); close(`target=${result.target} session=${result.sessionId ?? "none"}`); } catch (err) { busy = false; @@ -553,7 +553,7 @@ export async function runTui(config: AppConfig, workspaceId: string): Promise client.runAction(workspaceId, row.handoffId, "archive"), `archived ${row.handoffId}`); + void runActionWithRefresh(`archiving ${row.taskId}`, async () => client.runAction(workspaceId, row.taskId, "archive"), `archived ${row.taskId}`); return; } @@ -562,7 +562,7 @@ export async function runTui(config: AppConfig, workspaceId: string): Promise client.runAction(workspaceId, row.handoffId, "sync"), `synced ${row.handoffId}`); + void runActionWithRefresh(`syncing ${row.taskId}`, async () => client.runAction(workspaceId, row.taskId, "sync"), `synced ${row.taskId}`); return; } @@ -572,12 +572,12 @@ export async function runTui(config: AppConfig, workspaceId: string): Promise { - await client.runAction(workspaceId, row.handoffId, "merge"); - await client.runAction(workspaceId, row.handoffId, "archive"); + await client.runAction(workspaceId, row.taskId, "merge"); + await client.runAction(workspaceId, row.taskId, "archive"); }, - `merged+archived ${row.handoffId}`, + `merged+archived ${row.taskId}`, ); return; } @@ -585,7 +585,7 @@ export async function runTui(config: AppConfig, workspaceId: string): Promise { }); import { ensureBackendRunning, parseBackendPort } from "../src/backend/manager.js"; -import { ConfigSchema, type AppConfig } from "@openhandoff/shared"; +import { ConfigSchema, type AppConfig } from "@sandbox-agent/foundry-shared"; function backendStateFile(baseDir: string, host: string, port: number, suffix: string): string { const sanitized = host @@ -62,7 +62,7 @@ describe("backend manager", () => { backend: { host: "127.0.0.1", port: 7741, - dbPath: "~/.local/share/openhandoff/handoff.db", + dbPath: "~/.local/share/foundry/task.db", opencode_poll_interval: 2, github_poll_interval: 30, backup_interval_secs: 3600, diff --git a/factory/packages/cli/test/task-editor.test.ts b/foundry/packages/cli/test/task-editor.test.ts similarity index 100% rename from factory/packages/cli/test/task-editor.test.ts rename to foundry/packages/cli/test/task-editor.test.ts diff --git a/factory/packages/cli/test/theme.test.ts b/foundry/packages/cli/test/theme.test.ts similarity index 93% rename from factory/packages/cli/test/theme.test.ts rename to foundry/packages/cli/test/theme.test.ts index d830c35..6b49c75 100644 --- a/factory/packages/cli/test/theme.test.ts +++ b/foundry/packages/cli/test/theme.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { ConfigSchema, type AppConfig } from "@openhandoff/shared"; +import { ConfigSchema, type AppConfig } from "@sandbox-agent/foundry-shared"; import { resolveTuiTheme } from "../src/theme.js"; function withEnv(key: string, value: string | undefined): void { @@ -25,7 +25,7 @@ describe("resolveTuiTheme", () => { backend: { host: "127.0.0.1", port: 7741, - dbPath: "~/.local/share/openhandoff/handoff.db", + dbPath: "~/.local/share/foundry/task.db", opencode_poll_interval: 2, github_poll_interval: 30, backup_interval_secs: 3600, @@ -90,7 +90,7 @@ describe("resolveTuiTheme", () => { expect(resolution.theme.background).toBe("#0a0a0a"); }); - it("prefers explicit openhandoff theme override from config", () => { + it("prefers explicit foundry theme override from config", () => { tempDir = mkdtempSync(join(tmpdir(), "hf-theme-test-")); withEnv("XDG_STATE_HOME", join(tempDir, "state")); withEnv("XDG_CONFIG_HOME", join(tempDir, "config")); @@ -99,6 +99,6 @@ describe("resolveTuiTheme", () => { const resolution = resolveTuiTheme(config, tempDir); expect(resolution.name).toBe("opencode-default"); - expect(resolution.source).toBe("openhandoff config"); + expect(resolution.source).toBe("foundry config"); }); }); diff --git a/factory/packages/cli/test/tmux.test.ts b/foundry/packages/cli/test/tmux.test.ts similarity index 100% rename from factory/packages/cli/test/tmux.test.ts rename to foundry/packages/cli/test/tmux.test.ts diff --git a/factory/packages/cli/test/tui-format.test.ts b/foundry/packages/cli/test/tui-format.test.ts similarity index 84% rename from factory/packages/cli/test/tui-format.test.ts rename to foundry/packages/cli/test/tui-format.test.ts index 9375bc4..e60c839 100644 --- a/factory/packages/cli/test/tui-format.test.ts +++ b/foundry/packages/cli/test/tui-format.test.ts @@ -1,13 +1,13 @@ import { describe, expect, it } from "vitest"; -import type { HandoffRecord } from "@openhandoff/shared"; -import { filterHandoffs, fuzzyMatch } from "@openhandoff/client"; +import type { TaskRecord } from "@sandbox-agent/foundry-shared"; +import { filterTasks, fuzzyMatch } from "@sandbox-agent/foundry-client"; import { formatRows } from "../src/tui.js"; -const sample: HandoffRecord = { +const sample: TaskRecord = { workspaceId: "default", repoId: "repo-a", repoRemote: "https://example.com/repo-a.git", - handoffId: "handoff-1", + taskId: "task-1", branchName: "feature/test", title: "Test Title", task: "Do test", @@ -76,18 +76,18 @@ describe("search", () => { }); it("filters rows across branch and title", () => { - const rows: HandoffRecord[] = [ + const rows: TaskRecord[] = [ sample, { ...sample, - handoffId: "handoff-2", + taskId: "task-2", branchName: "docs/update-intro", title: "Docs Intro Refresh", status: "idle", }, ]; - expect(filterHandoffs(rows, "doc")).toHaveLength(1); - expect(filterHandoffs(rows, "h2")).toHaveLength(1); - expect(filterHandoffs(rows, "test")).toHaveLength(2); + expect(filterTasks(rows, "doc")).toHaveLength(1); + expect(filterTasks(rows, "h2")).toHaveLength(1); + expect(filterTasks(rows, "test")).toHaveLength(2); }); }); diff --git a/factory/packages/cli/test/workspace-config.test.ts b/foundry/packages/cli/test/workspace-config.test.ts similarity index 87% rename from factory/packages/cli/test/workspace-config.test.ts rename to foundry/packages/cli/test/workspace-config.test.ts index 3eedb6c..1f2e33a 100644 --- a/factory/packages/cli/test/workspace-config.test.ts +++ b/foundry/packages/cli/test/workspace-config.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { ConfigSchema } from "@openhandoff/shared"; +import { ConfigSchema } from "@sandbox-agent/foundry-shared"; import { resolveWorkspace } from "../src/workspace/config.js"; describe("cli workspace resolution", () => { @@ -11,7 +11,7 @@ describe("cli workspace resolution", () => { backend: { host: "127.0.0.1", port: 7741, - dbPath: "~/.local/share/openhandoff/handoff.db", + dbPath: "~/.local/share/foundry/task.db", opencode_poll_interval: 2, github_poll_interval: 30, backup_interval_secs: 3600, diff --git a/factory/packages/cli/tsconfig.json b/foundry/packages/cli/tsconfig.json similarity index 100% rename from factory/packages/cli/tsconfig.json rename to foundry/packages/cli/tsconfig.json diff --git a/factory/packages/cli/tsup.config.ts b/foundry/packages/cli/tsup.config.ts similarity index 100% rename from factory/packages/cli/tsup.config.ts rename to foundry/packages/cli/tsup.config.ts diff --git a/factory/packages/client/package.json b/foundry/packages/client/package.json similarity index 88% rename from factory/packages/client/package.json rename to foundry/packages/client/package.json index 50c462b..7d558f0 100644 --- a/factory/packages/client/package.json +++ b/foundry/packages/client/package.json @@ -1,5 +1,5 @@ { - "name": "@openhandoff/client", + "name": "@sandbox-agent/foundry-client", "version": "0.1.0", "private": true, "type": "module", @@ -14,7 +14,7 @@ "test:e2e:workbench-load": "HF_ENABLE_DAEMON_WORKBENCH_LOAD_E2E=1 vitest run test/e2e/workbench-load-e2e.test.ts" }, "dependencies": { - "@openhandoff/shared": "workspace:*", + "@sandbox-agent/foundry-shared": "workspace:*", "rivetkit": "2.1.6", "sandbox-agent": "workspace:*" }, diff --git a/foundry/packages/client/src/app-client.ts b/foundry/packages/client/src/app-client.ts new file mode 100644 index 0000000..1fb95d2 --- /dev/null +++ b/foundry/packages/client/src/app-client.ts @@ -0,0 +1,67 @@ +import type { + FoundryAppSnapshot, + FoundryBillingPlanId, + FoundryOrganization, + FoundryUser, + UpdateFoundryOrganizationProfileInput, +} from "@sandbox-agent/foundry-shared"; +import type { BackendClient } from "./backend-client.js"; +import { getMockFoundryAppClient } from "./mock-app.js"; +import { createRemoteFoundryAppClient } from "./remote/app-client.js"; + +export interface FoundryAppClient { + getSnapshot(): FoundryAppSnapshot; + subscribe(listener: () => void): () => void; + signInWithGithub(userId?: string): Promise; + signOut(): Promise; + skipStarterRepo(): Promise; + starStarterRepo(organizationId: string): Promise; + selectOrganization(organizationId: string): Promise; + updateOrganizationProfile(input: UpdateFoundryOrganizationProfileInput): Promise; + triggerGithubSync(organizationId: string): Promise; + completeHostedCheckout(organizationId: string, planId: FoundryBillingPlanId): Promise; + openBillingPortal(organizationId: string): Promise; + cancelScheduledRenewal(organizationId: string): Promise; + resumeSubscription(organizationId: string): Promise; + reconnectGithub(organizationId: string): Promise; + recordSeatUsage(workspaceId: string): Promise; +} + +export interface CreateFoundryAppClientOptions { + mode: "mock" | "remote"; + backend?: BackendClient; +} + +export function createFoundryAppClient(options: CreateFoundryAppClientOptions): FoundryAppClient { + if (options.mode === "mock") { + return getMockFoundryAppClient() as unknown as FoundryAppClient; + } + if (!options.backend) { + throw new Error("Remote app client requires a backend client"); + } + return createRemoteFoundryAppClient({ backend: options.backend }); +} + +export function currentFoundryUser(snapshot: FoundryAppSnapshot): FoundryUser | null { + if (!snapshot.auth.currentUserId) { + return null; + } + return snapshot.users.find((candidate) => candidate.id === snapshot.auth.currentUserId) ?? null; +} + +export function currentFoundryOrganization(snapshot: FoundryAppSnapshot): FoundryOrganization | null { + if (!snapshot.activeOrganizationId) { + return null; + } + return snapshot.organizations.find((candidate) => candidate.id === snapshot.activeOrganizationId) ?? null; +} + +export function eligibleFoundryOrganizations(snapshot: FoundryAppSnapshot): FoundryOrganization[] { + const user = currentFoundryUser(snapshot); + if (!user) { + return []; + } + + const eligible = new Set(user.eligibleOrganizationIds); + return snapshot.organizations.filter((organization) => eligible.has(organization.id)); +} diff --git a/factory/packages/client/src/backend-client.ts b/foundry/packages/client/src/backend-client.ts similarity index 60% rename from factory/packages/client/src/backend-client.ts rename to foundry/packages/client/src/backend-client.ts index ca5b9c6..f01a110 100644 --- a/factory/packages/client/src/backend-client.ts +++ b/foundry/packages/client/src/backend-client.ts @@ -3,21 +3,23 @@ import type { AgentType, AddRepoInput, AppConfig, - CreateHandoffInput, - HandoffRecord, - HandoffSummary, - HandoffWorkbenchChangeModelInput, - HandoffWorkbenchCreateHandoffInput, - HandoffWorkbenchCreateHandoffResponse, - HandoffWorkbenchDiffInput, - HandoffWorkbenchRenameInput, - HandoffWorkbenchRenameSessionInput, - HandoffWorkbenchSelectInput, - HandoffWorkbenchSetSessionUnreadInput, - HandoffWorkbenchSendMessageInput, - HandoffWorkbenchSnapshot, - HandoffWorkbenchTabInput, - HandoffWorkbenchUpdateDraftInput, + FoundryAppSnapshot, + FoundryBillingPlanId, + CreateTaskInput, + TaskRecord, + TaskSummary, + TaskWorkbenchChangeModelInput, + TaskWorkbenchCreateTaskInput, + TaskWorkbenchCreateTaskResponse, + TaskWorkbenchDiffInput, + TaskWorkbenchRenameInput, + TaskWorkbenchRenameSessionInput, + TaskWorkbenchSelectInput, + TaskWorkbenchSetSessionUnreadInput, + TaskWorkbenchSendMessageInput, + TaskWorkbenchSnapshot, + TaskWorkbenchTabInput, + TaskWorkbenchUpdateDraftInput, HistoryEvent, HistoryQueryInput, ProviderId, @@ -28,18 +30,13 @@ import type { StarSandboxAgentRepoInput, StarSandboxAgentRepoResult, SwitchResult, -} from "@openhandoff/shared"; -import type { - ProcessCreateRequest, - ProcessInfo, - ProcessLogFollowQuery, - ProcessLogsResponse, - ProcessSignalQuery, -} from "sandbox-agent"; + UpdateFoundryOrganizationProfileInput, +} from "@sandbox-agent/foundry-shared"; +import type { ProcessCreateRequest, ProcessInfo, ProcessLogFollowQuery, ProcessLogsResponse, ProcessSignalQuery } from "sandbox-agent"; import { createMockBackendClient } from "./mock/backend-client.js"; import { sandboxInstanceKey, workspaceKey } from "./keys.js"; -export type HandoffAction = "push" | "sync" | "merge" | "archive" | "kill"; +export type TaskAction = "push" | "sync" | "merge" | "archive" | "kill"; type RivetMetadataResponse = { runtime?: string; @@ -74,36 +71,36 @@ export type SandboxProcessRecord = ProcessInfo; interface WorkspaceHandle { addRepo(input: AddRepoInput): Promise; listRepos(input: { workspaceId: string }): Promise; - createHandoff(input: CreateHandoffInput): Promise; - listHandoffs(input: { workspaceId: string; repoId?: string }): Promise; + createTask(input: CreateTaskInput): Promise; + listTasks(input: { workspaceId: string; repoId?: string }): Promise; getRepoOverview(input: { workspaceId: string; repoId: string }): Promise; runRepoStackAction(input: RepoStackActionInput): Promise; history(input: HistoryQueryInput): Promise; - switchHandoff(handoffId: string): Promise; - getHandoff(input: { workspaceId: string; handoffId: string }): Promise; - attachHandoff(input: { workspaceId: string; handoffId: string; reason?: string }): Promise<{ target: string; sessionId: string | null }>; - pushHandoff(input: { workspaceId: string; handoffId: string; reason?: string }): Promise; - syncHandoff(input: { workspaceId: string; handoffId: string; reason?: string }): Promise; - mergeHandoff(input: { workspaceId: string; handoffId: string; reason?: string }): Promise; - archiveHandoff(input: { workspaceId: string; handoffId: string; reason?: string }): Promise; - killHandoff(input: { workspaceId: string; handoffId: string; reason?: string }): Promise; + switchTask(taskId: string): Promise; + getTask(input: { workspaceId: string; taskId: string }): Promise; + attachTask(input: { workspaceId: string; taskId: string; reason?: string }): Promise<{ target: string; sessionId: string | null }>; + pushTask(input: { workspaceId: string; taskId: string; reason?: string }): Promise; + syncTask(input: { workspaceId: string; taskId: string; reason?: string }): Promise; + mergeTask(input: { workspaceId: string; taskId: string; reason?: string }): Promise; + archiveTask(input: { workspaceId: string; taskId: string; reason?: string }): Promise; + killTask(input: { workspaceId: string; taskId: string; reason?: string }): Promise; useWorkspace(input: { workspaceId: string }): Promise<{ workspaceId: string }>; starSandboxAgentRepo(input: StarSandboxAgentRepoInput): Promise; - getWorkbench(input: { workspaceId: string }): Promise; - createWorkbenchHandoff(input: HandoffWorkbenchCreateHandoffInput): Promise; - markWorkbenchUnread(input: HandoffWorkbenchSelectInput): Promise; - renameWorkbenchHandoff(input: HandoffWorkbenchRenameInput): Promise; - renameWorkbenchBranch(input: HandoffWorkbenchRenameInput): Promise; - createWorkbenchSession(input: HandoffWorkbenchSelectInput & { model?: string }): Promise<{ tabId: string }>; - renameWorkbenchSession(input: HandoffWorkbenchRenameSessionInput): Promise; - setWorkbenchSessionUnread(input: HandoffWorkbenchSetSessionUnreadInput): Promise; - updateWorkbenchDraft(input: HandoffWorkbenchUpdateDraftInput): Promise; - changeWorkbenchModel(input: HandoffWorkbenchChangeModelInput): Promise; - sendWorkbenchMessage(input: HandoffWorkbenchSendMessageInput): Promise; - stopWorkbenchSession(input: HandoffWorkbenchTabInput): Promise; - closeWorkbenchSession(input: HandoffWorkbenchTabInput): Promise; - publishWorkbenchPr(input: HandoffWorkbenchSelectInput): Promise; - revertWorkbenchFile(input: HandoffWorkbenchDiffInput): Promise; + getWorkbench(input: { workspaceId: string }): Promise; + createWorkbenchTask(input: TaskWorkbenchCreateTaskInput): Promise; + markWorkbenchUnread(input: TaskWorkbenchSelectInput): Promise; + renameWorkbenchTask(input: TaskWorkbenchRenameInput): Promise; + renameWorkbenchBranch(input: TaskWorkbenchRenameInput): Promise; + createWorkbenchSession(input: TaskWorkbenchSelectInput & { model?: string }): Promise<{ tabId: string }>; + renameWorkbenchSession(input: TaskWorkbenchRenameSessionInput): Promise; + setWorkbenchSessionUnread(input: TaskWorkbenchSetSessionUnreadInput): Promise; + updateWorkbenchDraft(input: TaskWorkbenchUpdateDraftInput): Promise; + changeWorkbenchModel(input: TaskWorkbenchChangeModelInput): Promise; + sendWorkbenchMessage(input: TaskWorkbenchSendMessageInput): Promise; + stopWorkbenchSession(input: TaskWorkbenchTabInput): Promise; + closeWorkbenchSession(input: TaskWorkbenchTabInput): Promise; + publishWorkbenchPr(input: TaskWorkbenchSelectInput): Promise; + revertWorkbenchFile(input: TaskWorkbenchDiffInput): Promise; } interface SandboxInstanceHandle { @@ -150,17 +147,31 @@ export interface BackendMetadata { } export interface BackendClient { + getAppSnapshot(): Promise; + signInWithGithub(): Promise; + signOutApp(): Promise; + skipAppStarterRepo(): Promise; + starAppStarterRepo(organizationId: string): Promise; + selectAppOrganization(organizationId: string): Promise; + updateAppOrganizationProfile(input: UpdateFoundryOrganizationProfileInput): Promise; + triggerAppRepoImport(organizationId: string): Promise; + reconnectAppGithub(organizationId: string): Promise; + completeAppHostedCheckout(organizationId: string, planId: FoundryBillingPlanId): Promise; + openAppBillingPortal(organizationId: string): Promise; + cancelAppScheduledRenewal(organizationId: string): Promise; + resumeAppSubscription(organizationId: string): Promise; + recordAppSeatUsage(workspaceId: string): Promise; addRepo(workspaceId: string, remoteUrl: string): Promise; listRepos(workspaceId: string): Promise; - createHandoff(input: CreateHandoffInput): Promise; - listHandoffs(workspaceId: string, repoId?: string): Promise; + createTask(input: CreateTaskInput): Promise; + listTasks(workspaceId: string, repoId?: string): Promise; getRepoOverview(workspaceId: string, repoId: string): Promise; runRepoStackAction(input: RepoStackActionInput): Promise; - getHandoff(workspaceId: string, handoffId: string): Promise; + getTask(workspaceId: string, taskId: string): Promise; listHistory(input: HistoryQueryInput): Promise; - switchHandoff(workspaceId: string, handoffId: string): Promise; - attachHandoff(workspaceId: string, handoffId: string): Promise<{ target: string; sessionId: string | null }>; - runAction(workspaceId: string, handoffId: string, action: HandoffAction): Promise; + switchTask(workspaceId: string, taskId: string): Promise; + attachTask(workspaceId: string, taskId: string): Promise<{ target: string; sessionId: string | null }>; + runAction(workspaceId: string, taskId: string, action: TaskAction): Promise; createSandboxSession(input: { workspaceId: string; providerId: ProviderId; @@ -181,50 +192,31 @@ export interface BackendClient { sandboxId: string, input: { sessionId: string; cursor?: string; limit?: number }, ): Promise<{ items: SandboxSessionEventRecord[]; nextCursor?: string }>; - createSandboxProcess(input: { - workspaceId: string; - providerId: ProviderId; - sandboxId: string; - request: ProcessCreateRequest; - }): Promise; - listSandboxProcesses( - workspaceId: string, - providerId: ProviderId, - sandboxId: string - ): Promise<{ processes: SandboxProcessRecord[] }>; + createSandboxProcess(input: { workspaceId: string; providerId: ProviderId; sandboxId: string; request: ProcessCreateRequest }): Promise; + listSandboxProcesses(workspaceId: string, providerId: ProviderId, sandboxId: string): Promise<{ processes: SandboxProcessRecord[] }>; getSandboxProcessLogs( workspaceId: string, providerId: ProviderId, sandboxId: string, processId: string, - query?: ProcessLogFollowQuery + query?: ProcessLogFollowQuery, ): Promise; stopSandboxProcess( workspaceId: string, providerId: ProviderId, sandboxId: string, processId: string, - query?: ProcessSignalQuery + query?: ProcessSignalQuery, ): Promise; killSandboxProcess( workspaceId: string, providerId: ProviderId, sandboxId: string, processId: string, - query?: ProcessSignalQuery + query?: ProcessSignalQuery, ): Promise; - deleteSandboxProcess( - workspaceId: string, - providerId: ProviderId, - sandboxId: string, - processId: string - ): Promise; - subscribeSandboxProcesses( - workspaceId: string, - providerId: ProviderId, - sandboxId: string, - listener: () => void - ): () => void; + deleteSandboxProcess(workspaceId: string, providerId: ProviderId, sandboxId: string, processId: string): Promise; + subscribeSandboxProcesses(workspaceId: string, providerId: ProviderId, sandboxId: string, listener: () => void): () => void; sendSandboxPrompt(input: { workspaceId: string; providerId: ProviderId; @@ -244,27 +236,23 @@ export interface BackendClient { providerId: ProviderId, sandboxId: string, ): Promise<{ providerId: ProviderId; sandboxId: string; state: string; at: number }>; - getSandboxAgentConnection( - workspaceId: string, - providerId: ProviderId, - sandboxId: string - ): Promise<{ endpoint: string; token?: string }>; - getWorkbench(workspaceId: string): Promise; + getSandboxAgentConnection(workspaceId: string, providerId: ProviderId, sandboxId: string): Promise<{ endpoint: string; token?: string }>; + getWorkbench(workspaceId: string): Promise; subscribeWorkbench(workspaceId: string, listener: () => void): () => void; - createWorkbenchHandoff(workspaceId: string, input: HandoffWorkbenchCreateHandoffInput): Promise; - markWorkbenchUnread(workspaceId: string, input: HandoffWorkbenchSelectInput): Promise; - renameWorkbenchHandoff(workspaceId: string, input: HandoffWorkbenchRenameInput): Promise; - renameWorkbenchBranch(workspaceId: string, input: HandoffWorkbenchRenameInput): Promise; - createWorkbenchSession(workspaceId: string, input: HandoffWorkbenchSelectInput & { model?: string }): Promise<{ tabId: string }>; - renameWorkbenchSession(workspaceId: string, input: HandoffWorkbenchRenameSessionInput): Promise; - setWorkbenchSessionUnread(workspaceId: string, input: HandoffWorkbenchSetSessionUnreadInput): Promise; - updateWorkbenchDraft(workspaceId: string, input: HandoffWorkbenchUpdateDraftInput): Promise; - changeWorkbenchModel(workspaceId: string, input: HandoffWorkbenchChangeModelInput): Promise; - sendWorkbenchMessage(workspaceId: string, input: HandoffWorkbenchSendMessageInput): Promise; - stopWorkbenchSession(workspaceId: string, input: HandoffWorkbenchTabInput): Promise; - closeWorkbenchSession(workspaceId: string, input: HandoffWorkbenchTabInput): Promise; - publishWorkbenchPr(workspaceId: string, input: HandoffWorkbenchSelectInput): Promise; - revertWorkbenchFile(workspaceId: string, input: HandoffWorkbenchDiffInput): Promise; + createWorkbenchTask(workspaceId: string, input: TaskWorkbenchCreateTaskInput): Promise; + markWorkbenchUnread(workspaceId: string, input: TaskWorkbenchSelectInput): Promise; + renameWorkbenchTask(workspaceId: string, input: TaskWorkbenchRenameInput): Promise; + renameWorkbenchBranch(workspaceId: string, input: TaskWorkbenchRenameInput): Promise; + createWorkbenchSession(workspaceId: string, input: TaskWorkbenchSelectInput & { model?: string }): Promise<{ tabId: string }>; + renameWorkbenchSession(workspaceId: string, input: TaskWorkbenchRenameSessionInput): Promise; + setWorkbenchSessionUnread(workspaceId: string, input: TaskWorkbenchSetSessionUnreadInput): Promise; + updateWorkbenchDraft(workspaceId: string, input: TaskWorkbenchUpdateDraftInput): Promise; + changeWorkbenchModel(workspaceId: string, input: TaskWorkbenchChangeModelInput): Promise; + sendWorkbenchMessage(workspaceId: string, input: TaskWorkbenchSendMessageInput): Promise; + stopWorkbenchSession(workspaceId: string, input: TaskWorkbenchTabInput): Promise; + closeWorkbenchSession(workspaceId: string, input: TaskWorkbenchTabInput): Promise; + publishWorkbenchPr(workspaceId: string, input: TaskWorkbenchSelectInput): Promise; + revertWorkbenchFile(workspaceId: string, input: TaskWorkbenchDiffInput): Promise; health(): Promise<{ ok: true }>; useWorkspace(workspaceId: string): Promise<{ workspaceId: string }>; starSandboxAgentRepo(workspaceId: string): Promise; @@ -399,6 +387,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien } let clientPromise: Promise | null = null; + let appSessionId = typeof window !== "undefined" ? window.localStorage.getItem("sandbox-agent-foundry:remote-app-session") : null; const workbenchSubscriptions = new Map< string, { @@ -414,6 +403,59 @@ export function createBackendClient(options: BackendClientOptions): BackendClien } >(); + const persistAppSessionId = (nextSessionId: string | null): void => { + appSessionId = nextSessionId; + if (typeof window === "undefined") { + return; + } + if (nextSessionId) { + window.localStorage.setItem("sandbox-agent-foundry:remote-app-session", nextSessionId); + } else { + window.localStorage.removeItem("sandbox-agent-foundry:remote-app-session"); + } + }; + + if (typeof window !== "undefined") { + const url = new URL(window.location.href); + const sessionFromUrl = url.searchParams.get("foundrySession"); + if (sessionFromUrl) { + persistAppSessionId(sessionFromUrl); + url.searchParams.delete("foundrySession"); + window.history.replaceState({}, document.title, `${url.pathname}${url.search}${url.hash}`); + } + } + + const appRequest = async (path: string, init?: RequestInit): Promise => { + const headers = new Headers(init?.headers); + if (appSessionId) { + headers.set("x-foundry-session", appSessionId); + } + if (init?.body && !headers.has("Content-Type")) { + headers.set("Content-Type", "application/json"); + } + + const res = await fetch(`${options.endpoint.replace(/\/$/, "")}${path}`, { + ...init, + headers, + credentials: "include", + }); + const nextSessionId = res.headers.get("x-foundry-session"); + if (nextSessionId) { + persistAppSessionId(nextSessionId); + } + if (!res.ok) { + throw new Error(`app request failed: ${res.status} ${res.statusText}`); + } + return (await res.json()) as T; + }; + + const redirectTo = async (path: string, init?: RequestInit): Promise => { + const response = await appRequest<{ url: string }>(path, init); + if (typeof window !== "undefined") { + window.location.assign(response.url); + } + }; + const getClient = async (): Promise => { if (clientPromise) { return clientPromise; @@ -467,14 +509,14 @@ export function createBackendClient(options: BackendClientOptions): BackendClien return message.includes("Actor not found"); } - const sandboxByActorIdFromHandoff = async (workspaceId: string, providerId: ProviderId, sandboxId: string): Promise => { + const sandboxByActorIdFromTask = async (workspaceId: string, providerId: ProviderId, sandboxId: string): Promise => { const ws = await workspace(workspaceId); - const rows = await ws.listHandoffs({ workspaceId }); + const rows = await ws.listTasks({ workspaceId }); const candidates = [...rows].sort((a, b) => b.updatedAt - a.updatedAt); for (const row of candidates) { try { - const detail = await ws.getHandoff({ workspaceId, handoffId: row.handoffId }); + const detail = await ws.getTask({ workspaceId, taskId: row.taskId }); if (detail.providerId !== providerId) { continue; } @@ -491,10 +533,10 @@ export function createBackendClient(options: BackendClientOptions): BackendClien } } catch (error) { const message = error instanceof Error ? error.message : String(error); - if (!isActorNotFoundError(error) && !message.includes("Unknown handoff")) { + if (!isActorNotFoundError(error) && !message.includes("Unknown task")) { throw error; } - // Best effort fallback path; ignore missing handoff actors here. + // Best effort fallback path; ignore missing task actors here. } } @@ -514,7 +556,7 @@ export function createBackendClient(options: BackendClientOptions): BackendClien if (!isActorNotFoundError(error)) { throw error; } - const fallback = await sandboxByActorIdFromHandoff(workspaceId, providerId, sandboxId); + const fallback = await sandboxByActorIdFromTask(workspaceId, providerId, sandboxId); if (!fallback) { throw error; } @@ -573,18 +615,9 @@ export function createBackendClient(options: BackendClientOptions): BackendClien }; }; - const sandboxProcessSubscriptionKey = ( - workspaceId: string, - providerId: ProviderId, - sandboxId: string, - ): string => `${workspaceId}:${providerId}:${sandboxId}`; + const sandboxProcessSubscriptionKey = (workspaceId: string, providerId: ProviderId, sandboxId: string): string => `${workspaceId}:${providerId}:${sandboxId}`; - const subscribeSandboxProcesses = ( - workspaceId: string, - providerId: ProviderId, - sandboxId: string, - listener: () => void, - ): (() => void) => { + const subscribeSandboxProcesses = (workspaceId: string, providerId: ProviderId, sandboxId: string, listener: () => void): (() => void) => { const key = sandboxProcessSubscriptionKey(workspaceId, providerId, sandboxId); let entry = sandboxProcessSubscriptions.get(key); if (!entry) { @@ -637,6 +670,94 @@ export function createBackendClient(options: BackendClientOptions): BackendClien }; return { + async getAppSnapshot(): Promise { + return await appRequest("/app/snapshot"); + }, + + async signInWithGithub(): Promise { + if (typeof window !== "undefined") { + window.location.assign(`${options.endpoint.replace(/\/$/, "")}/app/auth/github/start`); + return; + } + await redirectTo("/app/auth/github/start"); + }, + + async signOutApp(): Promise { + return await appRequest("/app/sign-out", { method: "POST" }); + }, + + async skipAppStarterRepo(): Promise { + return await appRequest("/app/onboarding/starter-repo/skip", { + method: "POST", + }); + }, + + async starAppStarterRepo(organizationId: string): Promise { + return await appRequest(`/app/organizations/${organizationId}/starter-repo/star`, { + method: "POST", + }); + }, + + async selectAppOrganization(organizationId: string): Promise { + return await appRequest(`/app/organizations/${organizationId}/select`, { + method: "POST", + }); + }, + + async updateAppOrganizationProfile(input: UpdateFoundryOrganizationProfileInput): Promise { + return await appRequest(`/app/organizations/${input.organizationId}/profile`, { + method: "PATCH", + body: JSON.stringify({ + displayName: input.displayName, + slug: input.slug, + primaryDomain: input.primaryDomain, + }), + }); + }, + + async triggerAppRepoImport(organizationId: string): Promise { + return await appRequest(`/app/organizations/${organizationId}/import`, { + method: "POST", + }); + }, + + async reconnectAppGithub(organizationId: string): Promise { + await redirectTo(`/app/organizations/${organizationId}/reconnect`, { + method: "POST", + }); + }, + + async completeAppHostedCheckout(organizationId: string, planId: FoundryBillingPlanId): Promise { + await redirectTo(`/app/organizations/${organizationId}/billing/checkout`, { + method: "POST", + body: JSON.stringify({ planId }), + }); + }, + + async openAppBillingPortal(organizationId: string): Promise { + await redirectTo(`/app/organizations/${organizationId}/billing/portal`, { + method: "POST", + }); + }, + + async cancelAppScheduledRenewal(organizationId: string): Promise { + return await appRequest(`/app/organizations/${organizationId}/billing/cancel`, { + method: "POST", + }); + }, + + async resumeAppSubscription(organizationId: string): Promise { + return await appRequest(`/app/organizations/${organizationId}/billing/resume`, { + method: "POST", + }); + }, + + async recordAppSeatUsage(workspaceId: string): Promise { + return await appRequest(`/app/workspaces/${workspaceId}/seat-usage`, { + method: "POST", + }); + }, + async addRepo(workspaceId: string, remoteUrl: string): Promise { return (await workspace(workspaceId)).addRepo({ workspaceId, remoteUrl }); }, @@ -645,16 +766,16 @@ export function createBackendClient(options: BackendClientOptions): BackendClien return (await workspace(workspaceId)).listRepos({ workspaceId }); }, - async createHandoff(input: CreateHandoffInput): Promise { - return (await workspace(input.workspaceId)).createHandoff(input); + async createTask(input: CreateTaskInput): Promise { + return (await workspace(input.workspaceId)).createTask(input); }, async starSandboxAgentRepo(workspaceId: string): Promise { return (await workspace(workspaceId)).starSandboxAgentRepo({ workspaceId }); }, - async listHandoffs(workspaceId: string, repoId?: string): Promise { - return (await workspace(workspaceId)).listHandoffs({ workspaceId, repoId }); + async listTasks(workspaceId: string, repoId?: string): Promise { + return (await workspace(workspaceId)).listTasks({ workspaceId, repoId }); }, async getRepoOverview(workspaceId: string, repoId: string): Promise { @@ -665,10 +786,10 @@ export function createBackendClient(options: BackendClientOptions): BackendClien return (await workspace(input.workspaceId)).runRepoStackAction(input); }, - async getHandoff(workspaceId: string, handoffId: string): Promise { - return (await workspace(workspaceId)).getHandoff({ + async getTask(workspaceId: string, taskId: string): Promise { + return (await workspace(workspaceId)).getTask({ workspaceId, - handoffId, + taskId, }); }, @@ -676,54 +797,54 @@ export function createBackendClient(options: BackendClientOptions): BackendClien return (await workspace(input.workspaceId)).history(input); }, - async switchHandoff(workspaceId: string, handoffId: string): Promise { - return (await workspace(workspaceId)).switchHandoff(handoffId); + async switchTask(workspaceId: string, taskId: string): Promise { + return (await workspace(workspaceId)).switchTask(taskId); }, - async attachHandoff(workspaceId: string, handoffId: string): Promise<{ target: string; sessionId: string | null }> { - return (await workspace(workspaceId)).attachHandoff({ + async attachTask(workspaceId: string, taskId: string): Promise<{ target: string; sessionId: string | null }> { + return (await workspace(workspaceId)).attachTask({ workspaceId, - handoffId, + taskId, reason: "cli.attach", }); }, - async runAction(workspaceId: string, handoffId: string, action: HandoffAction): Promise { + async runAction(workspaceId: string, taskId: string, action: TaskAction): Promise { if (action === "push") { - await (await workspace(workspaceId)).pushHandoff({ + await (await workspace(workspaceId)).pushTask({ workspaceId, - handoffId, + taskId, reason: "cli.push", }); return; } if (action === "sync") { - await (await workspace(workspaceId)).syncHandoff({ + await (await workspace(workspaceId)).syncTask({ workspaceId, - handoffId, + taskId, reason: "cli.sync", }); return; } if (action === "merge") { - await (await workspace(workspaceId)).mergeHandoff({ + await (await workspace(workspaceId)).mergeTask({ workspaceId, - handoffId, + taskId, reason: "cli.merge", }); return; } if (action === "archive") { - await (await workspace(workspaceId)).archiveHandoff({ + await (await workspace(workspaceId)).archiveTask({ workspaceId, - handoffId, + taskId, reason: "cli.archive", }); return; } - await (await workspace(workspaceId)).killHandoff({ + await (await workspace(workspaceId)).killTask({ workspaceId, - handoffId, + taskId, reason: "cli.kill", }); }, @@ -776,25 +897,11 @@ export function createBackendClient(options: BackendClientOptions): BackendClien sandboxId: string; request: ProcessCreateRequest; }): Promise { - return await withSandboxHandle( - input.workspaceId, - input.providerId, - input.sandboxId, - async (handle) => handle.createProcess(input.request) - ); + return await withSandboxHandle(input.workspaceId, input.providerId, input.sandboxId, async (handle) => handle.createProcess(input.request)); }, - async listSandboxProcesses( - workspaceId: string, - providerId: ProviderId, - sandboxId: string - ): Promise<{ processes: SandboxProcessRecord[] }> { - return await withSandboxHandle( - workspaceId, - providerId, - sandboxId, - async (handle) => handle.listProcesses() - ); + async listSandboxProcesses(workspaceId: string, providerId: ProviderId, sandboxId: string): Promise<{ processes: SandboxProcessRecord[] }> { + return await withSandboxHandle(workspaceId, providerId, sandboxId, async (handle) => handle.listProcesses()); }, async getSandboxProcessLogs( @@ -802,14 +909,9 @@ export function createBackendClient(options: BackendClientOptions): BackendClien providerId: ProviderId, sandboxId: string, processId: string, - query?: ProcessLogFollowQuery + query?: ProcessLogFollowQuery, ): Promise { - return await withSandboxHandle( - workspaceId, - providerId, - sandboxId, - async (handle) => handle.getProcessLogs({ processId, query }) - ); + return await withSandboxHandle(workspaceId, providerId, sandboxId, async (handle) => handle.getProcessLogs({ processId, query })); }, async stopSandboxProcess( @@ -817,14 +919,9 @@ export function createBackendClient(options: BackendClientOptions): BackendClien providerId: ProviderId, sandboxId: string, processId: string, - query?: ProcessSignalQuery + query?: ProcessSignalQuery, ): Promise { - return await withSandboxHandle( - workspaceId, - providerId, - sandboxId, - async (handle) => handle.stopProcess({ processId, query }) - ); + return await withSandboxHandle(workspaceId, providerId, sandboxId, async (handle) => handle.stopProcess({ processId, query })); }, async killSandboxProcess( @@ -832,36 +929,16 @@ export function createBackendClient(options: BackendClientOptions): BackendClien providerId: ProviderId, sandboxId: string, processId: string, - query?: ProcessSignalQuery + query?: ProcessSignalQuery, ): Promise { - return await withSandboxHandle( - workspaceId, - providerId, - sandboxId, - async (handle) => handle.killProcess({ processId, query }) - ); + return await withSandboxHandle(workspaceId, providerId, sandboxId, async (handle) => handle.killProcess({ processId, query })); }, - async deleteSandboxProcess( - workspaceId: string, - providerId: ProviderId, - sandboxId: string, - processId: string - ): Promise { - await withSandboxHandle( - workspaceId, - providerId, - sandboxId, - async (handle) => handle.deleteProcess({ processId }) - ); + async deleteSandboxProcess(workspaceId: string, providerId: ProviderId, sandboxId: string, processId: string): Promise { + await withSandboxHandle(workspaceId, providerId, sandboxId, async (handle) => handle.deleteProcess({ processId })); }, - subscribeSandboxProcesses( - workspaceId: string, - providerId: ProviderId, - sandboxId: string, - listener: () => void - ): () => void { + subscribeSandboxProcesses(workspaceId: string, providerId: ProviderId, sandboxId: string, listener: () => void): () => void { return subscribeSandboxProcesses(workspaceId, providerId, sandboxId, listener); }, @@ -899,20 +976,11 @@ export function createBackendClient(options: BackendClientOptions): BackendClien return await withSandboxHandle(workspaceId, providerId, sandboxId, async (handle) => handle.providerState()); }, - async getSandboxAgentConnection( - workspaceId: string, - providerId: ProviderId, - sandboxId: string - ): Promise<{ endpoint: string; token?: string }> { - return await withSandboxHandle( - workspaceId, - providerId, - sandboxId, - async (handle) => handle.sandboxAgentConnection() - ); + async getSandboxAgentConnection(workspaceId: string, providerId: ProviderId, sandboxId: string): Promise<{ endpoint: string; token?: string }> { + return await withSandboxHandle(workspaceId, providerId, sandboxId, async (handle) => handle.sandboxAgentConnection()); }, - async getWorkbench(workspaceId: string): Promise { + async getWorkbench(workspaceId: string): Promise { return (await workspace(workspaceId)).getWorkbench({ workspaceId }); }, @@ -920,59 +988,59 @@ export function createBackendClient(options: BackendClientOptions): BackendClien return subscribeWorkbench(workspaceId, listener); }, - async createWorkbenchHandoff(workspaceId: string, input: HandoffWorkbenchCreateHandoffInput): Promise { - return (await workspace(workspaceId)).createWorkbenchHandoff(input); + async createWorkbenchTask(workspaceId: string, input: TaskWorkbenchCreateTaskInput): Promise { + return (await workspace(workspaceId)).createWorkbenchTask(input); }, - async markWorkbenchUnread(workspaceId: string, input: HandoffWorkbenchSelectInput): Promise { + async markWorkbenchUnread(workspaceId: string, input: TaskWorkbenchSelectInput): Promise { await (await workspace(workspaceId)).markWorkbenchUnread(input); }, - async renameWorkbenchHandoff(workspaceId: string, input: HandoffWorkbenchRenameInput): Promise { - await (await workspace(workspaceId)).renameWorkbenchHandoff(input); + async renameWorkbenchTask(workspaceId: string, input: TaskWorkbenchRenameInput): Promise { + await (await workspace(workspaceId)).renameWorkbenchTask(input); }, - async renameWorkbenchBranch(workspaceId: string, input: HandoffWorkbenchRenameInput): Promise { + async renameWorkbenchBranch(workspaceId: string, input: TaskWorkbenchRenameInput): Promise { await (await workspace(workspaceId)).renameWorkbenchBranch(input); }, - async createWorkbenchSession(workspaceId: string, input: HandoffWorkbenchSelectInput & { model?: string }): Promise<{ tabId: string }> { + async createWorkbenchSession(workspaceId: string, input: TaskWorkbenchSelectInput & { model?: string }): Promise<{ tabId: string }> { return await (await workspace(workspaceId)).createWorkbenchSession(input); }, - async renameWorkbenchSession(workspaceId: string, input: HandoffWorkbenchRenameSessionInput): Promise { + async renameWorkbenchSession(workspaceId: string, input: TaskWorkbenchRenameSessionInput): Promise { await (await workspace(workspaceId)).renameWorkbenchSession(input); }, - async setWorkbenchSessionUnread(workspaceId: string, input: HandoffWorkbenchSetSessionUnreadInput): Promise { + async setWorkbenchSessionUnread(workspaceId: string, input: TaskWorkbenchSetSessionUnreadInput): Promise { await (await workspace(workspaceId)).setWorkbenchSessionUnread(input); }, - async updateWorkbenchDraft(workspaceId: string, input: HandoffWorkbenchUpdateDraftInput): Promise { + async updateWorkbenchDraft(workspaceId: string, input: TaskWorkbenchUpdateDraftInput): Promise { await (await workspace(workspaceId)).updateWorkbenchDraft(input); }, - async changeWorkbenchModel(workspaceId: string, input: HandoffWorkbenchChangeModelInput): Promise { + async changeWorkbenchModel(workspaceId: string, input: TaskWorkbenchChangeModelInput): Promise { await (await workspace(workspaceId)).changeWorkbenchModel(input); }, - async sendWorkbenchMessage(workspaceId: string, input: HandoffWorkbenchSendMessageInput): Promise { + async sendWorkbenchMessage(workspaceId: string, input: TaskWorkbenchSendMessageInput): Promise { await (await workspace(workspaceId)).sendWorkbenchMessage(input); }, - async stopWorkbenchSession(workspaceId: string, input: HandoffWorkbenchTabInput): Promise { + async stopWorkbenchSession(workspaceId: string, input: TaskWorkbenchTabInput): Promise { await (await workspace(workspaceId)).stopWorkbenchSession(input); }, - async closeWorkbenchSession(workspaceId: string, input: HandoffWorkbenchTabInput): Promise { + async closeWorkbenchSession(workspaceId: string, input: TaskWorkbenchTabInput): Promise { await (await workspace(workspaceId)).closeWorkbenchSession(input); }, - async publishWorkbenchPr(workspaceId: string, input: HandoffWorkbenchSelectInput): Promise { + async publishWorkbenchPr(workspaceId: string, input: TaskWorkbenchSelectInput): Promise { await (await workspace(workspaceId)).publishWorkbenchPr(input); }, - async revertWorkbenchFile(workspaceId: string, input: HandoffWorkbenchDiffInput): Promise { + async revertWorkbenchFile(workspaceId: string, input: TaskWorkbenchDiffInput): Promise { await (await workspace(workspaceId)).revertWorkbenchFile(input); }, diff --git a/factory/packages/client/src/index.ts b/foundry/packages/client/src/index.ts similarity index 68% rename from factory/packages/client/src/index.ts rename to foundry/packages/client/src/index.ts index 1162160..a959744 100644 --- a/factory/packages/client/src/index.ts +++ b/foundry/packages/client/src/index.ts @@ -1,4 +1,6 @@ +export * from "./app-client.js"; export * from "./backend-client.js"; export * from "./keys.js"; +export * from "./mock-app.js"; export * from "./view-model.js"; export * from "./workbench-client.js"; diff --git a/factory/packages/client/src/keys.ts b/foundry/packages/client/src/keys.ts similarity index 68% rename from factory/packages/client/src/keys.ts rename to foundry/packages/client/src/keys.ts index ee89704..f6b210e 100644 --- a/factory/packages/client/src/keys.ts +++ b/foundry/packages/client/src/keys.ts @@ -8,8 +8,8 @@ export function projectKey(workspaceId: string, repoId: string): ActorKey { return ["ws", workspaceId, "project", repoId]; } -export function handoffKey(workspaceId: string, repoId: string, handoffId: string): ActorKey { - return ["ws", workspaceId, "project", repoId, "handoff", handoffId]; +export function taskKey(workspaceId: string, repoId: string, taskId: string): ActorKey { + return ["ws", workspaceId, "project", repoId, "task", taskId]; } export function sandboxInstanceKey(workspaceId: string, providerId: string, sandboxId: string): ActorKey { @@ -28,7 +28,7 @@ export function projectBranchSyncKey(workspaceId: string, repoId: string): Actor return ["ws", workspaceId, "project", repoId, "branch-sync"]; } -export function handoffStatusSyncKey(workspaceId: string, repoId: string, handoffId: string, sandboxId: string, sessionId: string): ActorKey { - // Include sandbox + session so multiple sandboxes/sessions can be tracked per handoff. - return ["ws", workspaceId, "project", repoId, "handoff", handoffId, "status-sync", sandboxId, sessionId]; +export function taskStatusSyncKey(workspaceId: string, repoId: string, taskId: string, sandboxId: string, sessionId: string): ActorKey { + // Include sandbox + session so multiple sandboxes/sessions can be tracked per task. + return ["ws", workspaceId, "project", repoId, "task", taskId, "status-sync", sandboxId, sessionId]; } diff --git a/foundry/packages/client/src/mock-app.ts b/foundry/packages/client/src/mock-app.ts new file mode 100644 index 0000000..61dadd2 --- /dev/null +++ b/foundry/packages/client/src/mock-app.ts @@ -0,0 +1,697 @@ +import { injectMockLatency } from "./mock/latency.js"; + +export type MockBillingPlanId = "free" | "team"; +export type MockBillingStatus = "active" | "trialing" | "past_due" | "scheduled_cancel"; +export type MockGithubInstallationStatus = "connected" | "install_required" | "reconnect_required"; +export type MockGithubSyncStatus = "pending" | "syncing" | "synced" | "error"; +export type MockOrganizationKind = "personal" | "organization"; +export type MockStarterRepoStatus = "pending" | "starred" | "skipped"; + +export interface MockFoundryUser { + id: string; + name: string; + email: string; + githubLogin: string; + roleLabel: string; + eligibleOrganizationIds: string[]; +} + +export interface MockFoundryOrganizationMember { + id: string; + name: string; + email: string; + role: "owner" | "admin" | "member"; + state: "active" | "invited"; +} + +export interface MockFoundryInvoice { + id: string; + label: string; + issuedAt: string; + amountUsd: number; + status: "paid" | "open"; +} + +export interface MockFoundryBillingState { + planId: MockBillingPlanId; + status: MockBillingStatus; + seatsIncluded: number; + trialEndsAt: string | null; + renewalAt: string | null; + stripeCustomerId: string; + paymentMethodLabel: string; + invoices: MockFoundryInvoice[]; +} + +export interface MockFoundryGithubState { + connectedAccount: string; + installationStatus: MockGithubInstallationStatus; + syncStatus: MockGithubSyncStatus; + importedRepoCount: number; + lastSyncLabel: string; + lastSyncAt: number | null; +} + +export interface MockFoundryOrganizationSettings { + displayName: string; + slug: string; + primaryDomain: string; + seatAccrualMode: "first_prompt"; + defaultModel: "claude-sonnet-4" | "claude-opus-4" | "gpt-4o" | "o3"; + autoImportRepos: boolean; +} + +export interface MockFoundryOrganization { + id: string; + workspaceId: string; + kind: MockOrganizationKind; + settings: MockFoundryOrganizationSettings; + github: MockFoundryGithubState; + billing: MockFoundryBillingState; + members: MockFoundryOrganizationMember[]; + seatAssignments: string[]; + repoCatalog: string[]; +} + +export interface MockFoundryAppSnapshot { + auth: { + status: "signed_out" | "signed_in"; + currentUserId: string | null; + }; + activeOrganizationId: string | null; + onboarding: { + starterRepo: { + repoFullName: string; + repoUrl: string; + status: MockStarterRepoStatus; + starredAt: number | null; + skippedAt: number | null; + }; + }; + users: MockFoundryUser[]; + organizations: MockFoundryOrganization[]; +} + +export interface UpdateMockOrganizationProfileInput { + organizationId: string; + displayName: string; + slug: string; + primaryDomain: string; +} + +export interface MockFoundryAppClient { + getSnapshot(): MockFoundryAppSnapshot; + subscribe(listener: () => void): () => void; + signInWithGithub(userId: string): Promise; + signOut(): Promise; + skipStarterRepo(): Promise; + starStarterRepo(organizationId: string): Promise; + selectOrganization(organizationId: string): Promise; + updateOrganizationProfile(input: UpdateMockOrganizationProfileInput): Promise; + triggerGithubSync(organizationId: string): Promise; + completeHostedCheckout(organizationId: string, planId: MockBillingPlanId): Promise; + openBillingPortal(organizationId: string): Promise; + cancelScheduledRenewal(organizationId: string): Promise; + resumeSubscription(organizationId: string): Promise; + reconnectGithub(organizationId: string): Promise; + recordSeatUsage(workspaceId: string): void; +} + +const STORAGE_KEY = "sandbox-agent-foundry:mock-app:v1"; + +function isoDate(daysFromNow: number): string { + const value = new Date(); + value.setDate(value.getDate() + daysFromNow); + return value.toISOString(); +} + +function syncStatusFromLegacy(value: unknown): MockGithubSyncStatus { + switch (value) { + case "ready": + case "synced": + return "synced"; + case "importing": + case "syncing": + return "syncing"; + case "error": + return "error"; + default: + return "pending"; + } +} + +function buildDefaultSnapshot(): MockFoundryAppSnapshot { + return { + auth: { + status: "signed_out", + currentUserId: null, + }, + activeOrganizationId: null, + onboarding: { + starterRepo: { + repoFullName: "rivet-dev/sandbox-agent", + repoUrl: "https://github.com/rivet-dev/sandbox-agent", + status: "pending", + starredAt: null, + skippedAt: null, + }, + }, + users: [ + { + id: "user-nathan", + name: "Nathan", + email: "nathan@acme.dev", + githubLogin: "nathan", + roleLabel: "Founder", + eligibleOrganizationIds: ["personal-nathan", "acme", "rivet"], + }, + { + id: "user-maya", + name: "Maya", + email: "maya@acme.dev", + githubLogin: "maya", + roleLabel: "Staff Engineer", + eligibleOrganizationIds: ["acme"], + }, + { + id: "user-jamie", + name: "Jamie", + email: "jamie@rivet.dev", + githubLogin: "jamie", + roleLabel: "Platform Lead", + eligibleOrganizationIds: ["personal-jamie", "rivet"], + }, + ], + organizations: [ + { + id: "personal-nathan", + workspaceId: "personal-nathan", + kind: "personal", + settings: { + displayName: "Nathan", + slug: "nathan", + primaryDomain: "personal", + seatAccrualMode: "first_prompt", + defaultModel: "claude-sonnet-4", + autoImportRepos: true, + }, + github: { + connectedAccount: "nathan", + installationStatus: "connected", + syncStatus: "synced", + importedRepoCount: 1, + lastSyncLabel: "Synced just now", + lastSyncAt: Date.now() - 60_000, + }, + billing: { + planId: "free", + status: "active", + seatsIncluded: 1, + trialEndsAt: null, + renewalAt: null, + stripeCustomerId: "cus_mock_personal_nathan", + paymentMethodLabel: "No card required", + invoices: [], + }, + members: [{ id: "member-nathan", name: "Nathan", email: "nathan@acme.dev", role: "owner", state: "active" }], + seatAssignments: ["nathan@acme.dev"], + repoCatalog: ["nathan/personal-site"], + }, + { + id: "acme", + workspaceId: "acme", + kind: "organization", + settings: { + displayName: "Acme", + slug: "acme", + primaryDomain: "acme.dev", + seatAccrualMode: "first_prompt", + defaultModel: "claude-sonnet-4", + autoImportRepos: true, + }, + github: { + connectedAccount: "acme", + installationStatus: "connected", + syncStatus: "pending", + importedRepoCount: 3, + lastSyncLabel: "Waiting for first import", + lastSyncAt: null, + }, + billing: { + planId: "team", + status: "active", + seatsIncluded: 5, + trialEndsAt: null, + renewalAt: isoDate(18), + stripeCustomerId: "cus_mock_acme_team", + paymentMethodLabel: "Visa ending in 4242", + invoices: [ + { id: "inv-acme-001", label: "March 2026", issuedAt: "2026-03-01", amountUsd: 240, status: "paid" }, + { id: "inv-acme-000", label: "February 2026", issuedAt: "2026-02-01", amountUsd: 240, status: "paid" }, + ], + }, + members: [ + { id: "member-acme-nathan", name: "Nathan", email: "nathan@acme.dev", role: "owner", state: "active" }, + { id: "member-acme-maya", name: "Maya", email: "maya@acme.dev", role: "admin", state: "active" }, + { id: "member-acme-priya", name: "Priya", email: "priya@acme.dev", role: "member", state: "active" }, + { id: "member-acme-devon", name: "Devon", email: "devon@acme.dev", role: "member", state: "invited" }, + ], + seatAssignments: ["nathan@acme.dev", "maya@acme.dev"], + repoCatalog: ["acme/backend", "acme/frontend", "acme/infra"], + }, + { + id: "rivet", + workspaceId: "rivet", + kind: "organization", + settings: { + displayName: "Rivet", + slug: "rivet", + primaryDomain: "rivet.dev", + seatAccrualMode: "first_prompt", + defaultModel: "o3", + autoImportRepos: true, + }, + github: { + connectedAccount: "rivet-dev", + installationStatus: "reconnect_required", + syncStatus: "error", + importedRepoCount: 4, + lastSyncLabel: "Sync stalled 2 hours ago", + lastSyncAt: Date.now() - 2 * 60 * 60_000, + }, + billing: { + planId: "team", + status: "trialing", + seatsIncluded: 5, + trialEndsAt: isoDate(12), + renewalAt: isoDate(12), + stripeCustomerId: "cus_mock_rivet_team", + paymentMethodLabel: "Visa ending in 4242", + invoices: [{ id: "inv-rivet-001", label: "Team pilot", issuedAt: "2026-03-04", amountUsd: 0, status: "paid" }], + }, + members: [ + { id: "member-rivet-jamie", name: "Jamie", email: "jamie@rivet.dev", role: "owner", state: "active" }, + { id: "member-rivet-nathan", name: "Nathan", email: "nathan@acme.dev", role: "member", state: "active" }, + { id: "member-rivet-lena", name: "Lena", email: "lena@rivet.dev", role: "admin", state: "active" }, + ], + seatAssignments: ["jamie@rivet.dev"], + repoCatalog: ["rivet/dashboard", "rivet/agents", "rivet/billing", "rivet/infrastructure"], + }, + { + id: "personal-jamie", + workspaceId: "personal-jamie", + kind: "personal", + settings: { + displayName: "Jamie", + slug: "jamie", + primaryDomain: "personal", + seatAccrualMode: "first_prompt", + defaultModel: "claude-opus-4", + autoImportRepos: true, + }, + github: { + connectedAccount: "jamie", + installationStatus: "connected", + syncStatus: "synced", + importedRepoCount: 1, + lastSyncLabel: "Synced yesterday", + lastSyncAt: Date.now() - 24 * 60 * 60_000, + }, + billing: { + planId: "free", + status: "active", + seatsIncluded: 1, + trialEndsAt: null, + renewalAt: null, + stripeCustomerId: "cus_mock_personal_jamie", + paymentMethodLabel: "No card required", + invoices: [], + }, + members: [{ id: "member-jamie", name: "Jamie", email: "jamie@rivet.dev", role: "owner", state: "active" }], + seatAssignments: ["jamie@rivet.dev"], + repoCatalog: ["jamie/demo-app"], + }, + ], + }; +} + +function parseStoredSnapshot(): MockFoundryAppSnapshot | null { + if (typeof window === "undefined") { + return null; + } + + const raw = window.localStorage.getItem(STORAGE_KEY); + if (!raw) { + return null; + } + + try { + const parsed = JSON.parse(raw) as MockFoundryAppSnapshot & { + organizations?: Array; + }; + if (!parsed || typeof parsed !== "object") { + return null; + } + return { + ...parsed, + onboarding: { + starterRepo: { + repoFullName: parsed.onboarding?.starterRepo?.repoFullName ?? "rivet-dev/sandbox-agent", + repoUrl: parsed.onboarding?.starterRepo?.repoUrl ?? "https://github.com/rivet-dev/sandbox-agent", + status: parsed.onboarding?.starterRepo?.status ?? "pending", + starredAt: parsed.onboarding?.starterRepo?.starredAt ?? null, + skippedAt: parsed.onboarding?.starterRepo?.skippedAt ?? null, + }, + }, + organizations: (parsed.organizations ?? []).map((organization: MockFoundryOrganization & { repoImportStatus?: string }) => ({ + ...organization, + github: { + ...organization.github, + syncStatus: syncStatusFromLegacy(organization.github?.syncStatus ?? organization.repoImportStatus), + lastSyncAt: organization.github?.lastSyncAt ?? null, + }, + })), + }; + } catch { + return null; + } +} + +function saveSnapshot(snapshot: MockFoundryAppSnapshot): void { + if (typeof window === "undefined") { + return; + } + + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(snapshot)); +} + +function planSeatsIncluded(planId: MockBillingPlanId): number { + switch (planId) { + case "free": + return 1; + case "team": + return 5; + } +} + +class MockFoundryAppStore implements MockFoundryAppClient { + private snapshot = parseStoredSnapshot() ?? buildDefaultSnapshot(); + private listeners = new Set<() => void>(); + private importTimers = new Map>(); + + getSnapshot(): MockFoundryAppSnapshot { + return this.snapshot; + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + async signInWithGithub(userId: string): Promise { + await this.injectAsyncLatency(); + const user = this.snapshot.users.find((candidate) => candidate.id === userId); + if (!user) { + throw new Error(`Unknown mock user ${userId}`); + } + + this.updateSnapshot((current) => { + const activeOrganizationId = user.eligibleOrganizationIds.length === 1 ? (user.eligibleOrganizationIds[0] ?? null) : null; + return { + ...current, + auth: { + status: "signed_in", + currentUserId: userId, + }, + activeOrganizationId, + }; + }); + + if (user.eligibleOrganizationIds.length === 1) { + await this.selectOrganization(user.eligibleOrganizationIds[0]!); + } + } + + async signOut(): Promise { + await this.injectAsyncLatency(); + this.updateSnapshot((current) => ({ + ...current, + auth: { + status: "signed_out", + currentUserId: null, + }, + activeOrganizationId: null, + onboarding: { + starterRepo: { + ...current.onboarding.starterRepo, + status: "pending", + starredAt: null, + skippedAt: null, + }, + }, + })); + } + + async skipStarterRepo(): Promise { + await this.injectAsyncLatency(); + this.updateSnapshot((current) => ({ + ...current, + onboarding: { + starterRepo: { + ...current.onboarding.starterRepo, + status: "skipped", + skippedAt: Date.now(), + starredAt: null, + }, + }, + })); + } + + async starStarterRepo(organizationId: string): Promise { + await this.injectAsyncLatency(); + this.requireOrganization(organizationId); + this.updateSnapshot((current) => ({ + ...current, + onboarding: { + starterRepo: { + ...current.onboarding.starterRepo, + status: "starred", + starredAt: Date.now(), + skippedAt: null, + }, + }, + })); + } + + async selectOrganization(organizationId: string): Promise { + await this.injectAsyncLatency(); + const org = this.requireOrganization(organizationId); + this.updateSnapshot((current) => ({ + ...current, + activeOrganizationId: organizationId, + })); + + if (org.github.syncStatus !== "synced") { + await this.triggerGithubSync(organizationId); + } + } + + async updateOrganizationProfile(input: UpdateMockOrganizationProfileInput): Promise { + await this.injectAsyncLatency(); + this.requireOrganization(input.organizationId); + this.updateOrganization(input.organizationId, (organization) => ({ + ...organization, + settings: { + ...organization.settings, + displayName: input.displayName.trim() || organization.settings.displayName, + slug: input.slug.trim() || organization.settings.slug, + primaryDomain: input.primaryDomain.trim() || organization.settings.primaryDomain, + }, + })); + } + + async triggerGithubSync(organizationId: string): Promise { + await this.injectAsyncLatency(); + this.requireOrganization(organizationId); + const existingTimer = this.importTimers.get(organizationId); + if (existingTimer) { + clearTimeout(existingTimer); + } + + this.updateOrganization(organizationId, (organization) => ({ + ...organization, + github: { + ...organization.github, + syncStatus: "syncing", + lastSyncLabel: "Syncing repositories...", + }, + })); + + const timer = setTimeout(() => { + this.updateOrganization(organizationId, (organization) => ({ + ...organization, + github: { + ...organization.github, + importedRepoCount: organization.repoCatalog.length, + installationStatus: "connected", + syncStatus: "synced", + lastSyncLabel: "Synced just now", + lastSyncAt: Date.now(), + }, + })); + this.importTimers.delete(organizationId); + }, 1_250); + + this.importTimers.set(organizationId, timer); + } + + async completeHostedCheckout(organizationId: string, planId: MockBillingPlanId): Promise { + await this.injectAsyncLatency(); + this.requireOrganization(organizationId); + this.updateOrganization(organizationId, (organization) => ({ + ...organization, + billing: { + ...organization.billing, + planId, + status: "active", + seatsIncluded: planSeatsIncluded(planId), + trialEndsAt: null, + renewalAt: isoDate(30), + paymentMethodLabel: "Visa ending in 4242", + invoices: [ + { + id: `inv-${organizationId}-${Date.now()}`, + label: `${organization.settings.displayName} ${planId} upgrade`, + issuedAt: new Date().toISOString().slice(0, 10), + amountUsd: planId === "team" ? 240 : 0, + status: "paid", + }, + ...organization.billing.invoices, + ], + }, + })); + } + + async openBillingPortal(_organizationId: string): Promise { + await this.injectAsyncLatency(); + } + + async cancelScheduledRenewal(organizationId: string): Promise { + await this.injectAsyncLatency(); + this.requireOrganization(organizationId); + this.updateOrganization(organizationId, (organization) => ({ + ...organization, + billing: { + ...organization.billing, + status: "scheduled_cancel", + }, + })); + } + + async resumeSubscription(organizationId: string): Promise { + await this.injectAsyncLatency(); + this.requireOrganization(organizationId); + this.updateOrganization(organizationId, (organization) => ({ + ...organization, + billing: { + ...organization.billing, + status: "active", + }, + })); + } + + async reconnectGithub(organizationId: string): Promise { + await this.injectAsyncLatency(); + this.requireOrganization(organizationId); + this.updateOrganization(organizationId, (organization) => ({ + ...organization, + github: { + ...organization.github, + installationStatus: "connected", + syncStatus: "pending", + lastSyncLabel: "Reconnected just now", + lastSyncAt: Date.now(), + }, + })); + } + + recordSeatUsage(workspaceId: string): void { + const org = this.snapshot.organizations.find((candidate) => candidate.workspaceId === workspaceId); + const currentUser = currentMockUser(this.snapshot); + if (!org || !currentUser) { + return; + } + + if (org.seatAssignments.includes(currentUser.email)) { + return; + } + + this.updateOrganization(org.id, (organization) => ({ + ...organization, + seatAssignments: [...organization.seatAssignments, currentUser.email], + })); + } + + private injectAsyncLatency(): Promise { + return injectMockLatency(); + } + + private updateOrganization(organizationId: string, updater: (organization: MockFoundryOrganization) => MockFoundryOrganization): void { + this.updateSnapshot((current) => ({ + ...current, + organizations: current.organizations.map((organization) => (organization.id === organizationId ? updater(organization) : organization)), + })); + } + + private updateSnapshot(updater: (current: MockFoundryAppSnapshot) => MockFoundryAppSnapshot): void { + this.snapshot = updater(this.snapshot); + saveSnapshot(this.snapshot); + for (const listener of this.listeners) { + listener(); + } + } + + private requireOrganization(organizationId: string): MockFoundryOrganization { + const organization = this.snapshot.organizations.find((candidate) => candidate.id === organizationId); + if (!organization) { + throw new Error(`Unknown mock organization ${organizationId}`); + } + return organization; + } +} + +function currentMockUser(snapshot: MockFoundryAppSnapshot): MockFoundryUser | null { + if (!snapshot.auth.currentUserId) { + return null; + } + return snapshot.users.find((candidate) => candidate.id === snapshot.auth.currentUserId) ?? null; +} + +const mockFoundryAppStore = new MockFoundryAppStore(); + +export function getMockFoundryAppClient(): MockFoundryAppClient { + return mockFoundryAppStore; +} + +export function currentMockFoundryUser(snapshot: MockFoundryAppSnapshot): MockFoundryUser | null { + return currentMockUser(snapshot); +} + +export function currentMockFoundryOrganization(snapshot: MockFoundryAppSnapshot): MockFoundryOrganization | null { + if (!snapshot.activeOrganizationId) { + return null; + } + return snapshot.organizations.find((candidate) => candidate.id === snapshot.activeOrganizationId) ?? null; +} + +export function eligibleMockOrganizations(snapshot: MockFoundryAppSnapshot): MockFoundryOrganization[] { + const user = currentMockUser(snapshot); + if (!user) { + return []; + } + + const eligible = new Set(user.eligibleOrganizationIds); + return snapshot.organizations.filter((organization) => eligible.has(organization.id)); +} diff --git a/factory/packages/client/src/mock/backend-client.ts b/foundry/packages/client/src/mock/backend-client.ts similarity index 55% rename from factory/packages/client/src/mock/backend-client.ts rename to foundry/packages/client/src/mock/backend-client.ts index 543f515..7b0ad7f 100644 --- a/factory/packages/client/src/mock/backend-client.ts +++ b/foundry/packages/client/src/mock/backend-client.ts @@ -1,20 +1,21 @@ import type { AddRepoInput, - CreateHandoffInput, - HandoffRecord, - HandoffSummary, - HandoffWorkbenchChangeModelInput, - HandoffWorkbenchCreateHandoffInput, - HandoffWorkbenchCreateHandoffResponse, - HandoffWorkbenchDiffInput, - HandoffWorkbenchRenameInput, - HandoffWorkbenchRenameSessionInput, - HandoffWorkbenchSelectInput, - HandoffWorkbenchSetSessionUnreadInput, - HandoffWorkbenchSendMessageInput, - HandoffWorkbenchSnapshot, - HandoffWorkbenchTabInput, - HandoffWorkbenchUpdateDraftInput, + CreateTaskInput, + FoundryAppSnapshot, + TaskRecord, + TaskSummary, + TaskWorkbenchChangeModelInput, + TaskWorkbenchCreateTaskInput, + TaskWorkbenchCreateTaskResponse, + TaskWorkbenchDiffInput, + TaskWorkbenchRenameInput, + TaskWorkbenchRenameSessionInput, + TaskWorkbenchSelectInput, + TaskWorkbenchSetSessionUnreadInput, + TaskWorkbenchSendMessageInput, + TaskWorkbenchSnapshot, + TaskWorkbenchTabInput, + TaskWorkbenchUpdateDraftInput, HistoryEvent, HistoryQueryInput, ProviderId, @@ -24,19 +25,9 @@ import type { RepoStackActionResult, StarSandboxAgentRepoResult, SwitchResult, -} from "@openhandoff/shared"; -import type { - ProcessCreateRequest, - ProcessLogFollowQuery, - ProcessLogsResponse, - ProcessSignalQuery, -} from "sandbox-agent"; -import type { - BackendClient, - SandboxProcessRecord, - SandboxSessionEventRecord, - SandboxSessionRecord, -} from "../backend-client.js"; +} from "@sandbox-agent/foundry-shared"; +import type { ProcessCreateRequest, ProcessLogFollowQuery, ProcessLogsResponse, ProcessSignalQuery } from "sandbox-agent"; +import type { BackendClient, SandboxProcessRecord, SandboxSessionEventRecord, SandboxSessionRecord } from "../backend-client.js"; import { getSharedMockWorkbenchClient } from "./workbench-client.js"; interface MockProcessRecord extends SandboxProcessRecord { @@ -62,11 +53,29 @@ function mockRepoRemote(label: string): string { return `https://example.test/${label}.git`; } -function mockCwd(repoLabel: string, handoffId: string): string { - return `/mock/${repoLabel.replace(/\//g, "-")}/${handoffId}`; +function mockCwd(repoLabel: string, taskId: string): string { + return `/mock/${repoLabel.replace(/\//g, "-")}/${taskId}`; } -function toHandoffStatus(status: HandoffRecord["status"], archived: boolean): HandoffRecord["status"] { +function unsupportedAppSnapshot(): FoundryAppSnapshot { + return { + auth: { status: "signed_out", currentUserId: null }, + activeOrganizationId: null, + onboarding: { + starterRepo: { + repoFullName: "rivet-dev/sandbox-agent", + repoUrl: "https://github.com/rivet-dev/sandbox-agent", + status: "pending", + starredAt: null, + skippedAt: null, + }, + }, + users: [], + organizations: [], + }; +} + +function toTaskStatus(status: TaskRecord["status"], archived: boolean): TaskRecord["status"] { if (archived) { return "archived"; } @@ -80,12 +89,12 @@ export function createMockBackendClient(defaultWorkspaceId = "default"): Backend let nextPid = 4000; let nextProcessId = 1; - const requireHandoff = (handoffId: string) => { - const handoff = workbench.getSnapshot().handoffs.find((candidate) => candidate.id === handoffId); - if (!handoff) { - throw new Error(`Unknown mock handoff ${handoffId}`); + const requireTask = (taskId: string) => { + const task = workbench.getSnapshot().tasks.find((candidate) => candidate.id === taskId); + if (!task) { + throw new Error(`Unknown mock task ${taskId}`); } - return handoff; + return task; }; const ensureProcessList = (sandboxId: string): MockProcessRecord[] => { @@ -108,65 +117,59 @@ export function createMockBackendClient(defaultWorkspaceId = "default"): Backend } }; - const buildHandoffRecord = (handoffId: string): HandoffRecord => { - const handoff = requireHandoff(handoffId); - const cwd = mockCwd(handoff.repoName, handoff.id); - const archived = handoff.status === "archived"; + const buildTaskRecord = (taskId: string): TaskRecord => { + const task = requireTask(taskId); + const cwd = mockCwd(task.repoName, task.id); + const archived = task.status === "archived"; return { workspaceId: defaultWorkspaceId, - repoId: handoff.repoId, - repoRemote: mockRepoRemote(handoff.repoName), - handoffId: handoff.id, - branchName: handoff.branch, - title: handoff.title, - task: handoff.title, + repoId: task.repoId, + repoRemote: mockRepoRemote(task.repoName), + taskId: task.id, + branchName: task.branch, + title: task.title, + task: task.title, providerId: "local", - status: toHandoffStatus(archived ? "archived" : "running", archived), + status: toTaskStatus(archived ? "archived" : "running", archived), statusMessage: archived ? "archived" : "mock sandbox ready", - activeSandboxId: handoff.id, - activeSessionId: handoff.tabs[0]?.sessionId ?? null, + activeSandboxId: task.id, + activeSessionId: task.tabs[0]?.sessionId ?? null, sandboxes: [ { - sandboxId: handoff.id, + sandboxId: task.id, providerId: "local", sandboxActorId: "mock-sandbox", - switchTarget: `mock://${handoff.id}`, + switchTarget: `mock://${task.id}`, cwd, - createdAt: handoff.updatedAtMs, - updatedAt: handoff.updatedAtMs, + createdAt: task.updatedAtMs, + updatedAt: task.updatedAtMs, }, ], - agentType: handoff.tabs[0]?.agent === "Codex" ? "codex" : "claude", - prSubmitted: Boolean(handoff.pullRequest), - diffStat: handoff.fileChanges.length > 0 ? `+${handoff.fileChanges.length}/-${handoff.fileChanges.length}` : "+0/-0", - prUrl: handoff.pullRequest ? `https://example.test/pr/${handoff.pullRequest.number}` : null, - prAuthor: handoff.pullRequest ? "mock" : null, + agentType: task.tabs[0]?.agent === "Codex" ? "codex" : "claude", + prSubmitted: Boolean(task.pullRequest), + diffStat: task.fileChanges.length > 0 ? `+${task.fileChanges.length}/-${task.fileChanges.length}` : "+0/-0", + prUrl: task.pullRequest ? `https://example.test/pr/${task.pullRequest.number}` : null, + prAuthor: task.pullRequest ? "mock" : null, ciStatus: null, reviewStatus: null, reviewer: null, conflictsWithMain: "0", - hasUnpushed: handoff.fileChanges.length > 0 ? "1" : "0", + hasUnpushed: task.fileChanges.length > 0 ? "1" : "0", parentBranch: null, - createdAt: handoff.updatedAtMs, - updatedAt: handoff.updatedAtMs, + createdAt: task.updatedAtMs, + updatedAt: task.updatedAtMs, }; }; const cloneProcess = (process: MockProcessRecord): MockProcessRecord => ({ ...process }); - const createProcessRecord = ( - sandboxId: string, - cwd: string, - request: ProcessCreateRequest, - ): MockProcessRecord => { + const createProcessRecord = (sandboxId: string, cwd: string, request: ProcessCreateRequest): MockProcessRecord => { const processId = `proc_${nextProcessId++}`; const createdAtMs = nowMs(); const args = request.args ?? []; const interactive = request.interactive ?? false; const tty = request.tty ?? false; - const statusLine = interactive && tty - ? "Mock terminal session created.\nInteractive transport is unavailable in mock mode.\n" - : "Mock process created.\n"; + const statusLine = interactive && tty ? "Mock terminal session created.\nInteractive transport is unavailable in mock mode.\n" : "Mock process created.\n"; const commandLine = `$ ${[request.command, ...args].join(" ").trim()}\n`; return { id: processId, @@ -185,6 +188,62 @@ export function createMockBackendClient(defaultWorkspaceId = "default"): Backend }; return { + async getAppSnapshot(): Promise { + return unsupportedAppSnapshot(); + }, + + async signInWithGithub(): Promise { + notSupported("signInWithGithub"); + }, + + async signOutApp(): Promise { + return unsupportedAppSnapshot(); + }, + + async skipAppStarterRepo(): Promise { + return unsupportedAppSnapshot(); + }, + + async starAppStarterRepo(): Promise { + return unsupportedAppSnapshot(); + }, + + async selectAppOrganization(): Promise { + return unsupportedAppSnapshot(); + }, + + async updateAppOrganizationProfile(): Promise { + return unsupportedAppSnapshot(); + }, + + async triggerAppRepoImport(): Promise { + return unsupportedAppSnapshot(); + }, + + async reconnectAppGithub(): Promise { + notSupported("reconnectAppGithub"); + }, + + async completeAppHostedCheckout(): Promise { + notSupported("completeAppHostedCheckout"); + }, + + async openAppBillingPortal(): Promise { + notSupported("openAppBillingPortal"); + }, + + async cancelAppScheduledRenewal(): Promise { + return unsupportedAppSnapshot(); + }, + + async resumeAppSubscription(): Promise { + return unsupportedAppSnapshot(); + }, + + async recordAppSeatUsage(): Promise { + return unsupportedAppSnapshot(); + }, + async addRepo(_workspaceId: string, _remoteUrl: string): Promise { notSupported("addRepo"); }, @@ -199,23 +258,22 @@ export function createMockBackendClient(defaultWorkspaceId = "default"): Backend })); }, - async createHandoff(_input: CreateHandoffInput): Promise { - notSupported("createHandoff"); + async createTask(_input: CreateTaskInput): Promise { + notSupported("createTask"); }, - async listHandoffs(_workspaceId: string, repoId?: string): Promise { + async listTasks(_workspaceId: string, repoId?: string): Promise { return workbench .getSnapshot() - .handoffs - .filter((handoff) => !repoId || handoff.repoId === repoId) - .map((handoff) => ({ + .tasks.filter((task) => !repoId || task.repoId === repoId) + .map((task) => ({ workspaceId: defaultWorkspaceId, - repoId: handoff.repoId, - handoffId: handoff.id, - branchName: handoff.branch, - title: handoff.title, - status: handoff.status === "archived" ? "archived" : "running", - updatedAt: handoff.updatedAtMs, + repoId: task.repoId, + taskId: task.id, + branchName: task.branch, + title: task.title, + status: task.status === "archived" ? "archived" : "running", + updatedAt: task.updatedAtMs, })); }, @@ -227,31 +285,31 @@ export function createMockBackendClient(defaultWorkspaceId = "default"): Backend notSupported("runRepoStackAction"); }, - async getHandoff(_workspaceId: string, handoffId: string): Promise { - return buildHandoffRecord(handoffId); + async getTask(_workspaceId: string, taskId: string): Promise { + return buildTaskRecord(taskId); }, async listHistory(_input: HistoryQueryInput): Promise { return []; }, - async switchHandoff(_workspaceId: string, handoffId: string): Promise { + async switchTask(_workspaceId: string, taskId: string): Promise { return { workspaceId: defaultWorkspaceId, - handoffId, + taskId, providerId: "local", - switchTarget: `mock://${handoffId}`, + switchTarget: `mock://${taskId}`, }; }, - async attachHandoff(_workspaceId: string, handoffId: string): Promise<{ target: string; sessionId: string | null }> { + async attachTask(_workspaceId: string, taskId: string): Promise<{ target: string; sessionId: string | null }> { return { - target: `mock://${handoffId}`, - sessionId: requireHandoff(handoffId).tabs[0]?.sessionId ?? null, + target: `mock://${taskId}`, + sessionId: requireTask(taskId).tabs[0]?.sessionId ?? null, }; }, - async runAction(_workspaceId: string, _handoffId: string): Promise { + async runAction(_workspaceId: string, _taskId: string): Promise { notSupported("runAction"); }, @@ -273,19 +331,15 @@ export function createMockBackendClient(defaultWorkspaceId = "default"): Backend sandboxId: string; request: ProcessCreateRequest; }): Promise { - const handoff = requireHandoff(input.sandboxId); + const task = requireTask(input.sandboxId); const processes = ensureProcessList(input.sandboxId); - const created = createProcessRecord(input.sandboxId, mockCwd(handoff.repoName, handoff.id), input.request); + const created = createProcessRecord(input.sandboxId, mockCwd(task.repoName, task.id), input.request); processes.unshift(created); notifySandbox(input.sandboxId); return cloneProcess(created); }, - async listSandboxProcesses( - _workspaceId: string, - _providerId: ProviderId, - sandboxId: string, - ): Promise<{ processes: SandboxProcessRecord[] }> { + async listSandboxProcesses(_workspaceId: string, _providerId: ProviderId, sandboxId: string): Promise<{ processes: SandboxProcessRecord[] }> { return { processes: ensureProcessList(sandboxId).map((process) => cloneProcess(process)), }; @@ -357,12 +411,7 @@ export function createMockBackendClient(defaultWorkspaceId = "default"): Backend return cloneProcess(process); }, - async deleteSandboxProcess( - _workspaceId: string, - _providerId: ProviderId, - sandboxId: string, - processId: string, - ): Promise { + async deleteSandboxProcess(_workspaceId: string, _providerId: ProviderId, sandboxId: string, processId: string): Promise { processesBySandboxId.set( sandboxId, ensureProcessList(sandboxId).filter((candidate) => candidate.id !== processId), @@ -370,12 +419,7 @@ export function createMockBackendClient(defaultWorkspaceId = "default"): Backend notifySandbox(sandboxId); }, - subscribeSandboxProcesses( - _workspaceId: string, - _providerId: ProviderId, - sandboxId: string, - listener: () => void, - ): () => void { + subscribeSandboxProcesses(_workspaceId: string, _providerId: ProviderId, sandboxId: string, listener: () => void): () => void { let listeners = listenersBySandboxId.get(sandboxId); if (!listeners) { listeners = new Set(); @@ -414,7 +458,7 @@ export function createMockBackendClient(defaultWorkspaceId = "default"): Backend return { endpoint: "mock://terminal-unavailable" }; }, - async getWorkbench(): Promise { + async getWorkbench(): Promise { return workbench.getSnapshot(); }, @@ -422,68 +466,59 @@ export function createMockBackendClient(defaultWorkspaceId = "default"): Backend return workbench.subscribe(listener); }, - async createWorkbenchHandoff( - _workspaceId: string, - input: HandoffWorkbenchCreateHandoffInput, - ): Promise { - return await workbench.createHandoff(input); + async createWorkbenchTask(_workspaceId: string, input: TaskWorkbenchCreateTaskInput): Promise { + return await workbench.createTask(input); }, - async markWorkbenchUnread(_workspaceId: string, input: HandoffWorkbenchSelectInput): Promise { - await workbench.markHandoffUnread(input); + async markWorkbenchUnread(_workspaceId: string, input: TaskWorkbenchSelectInput): Promise { + await workbench.markTaskUnread(input); }, - async renameWorkbenchHandoff(_workspaceId: string, input: HandoffWorkbenchRenameInput): Promise { - await workbench.renameHandoff(input); + async renameWorkbenchTask(_workspaceId: string, input: TaskWorkbenchRenameInput): Promise { + await workbench.renameTask(input); }, - async renameWorkbenchBranch(_workspaceId: string, input: HandoffWorkbenchRenameInput): Promise { + async renameWorkbenchBranch(_workspaceId: string, input: TaskWorkbenchRenameInput): Promise { await workbench.renameBranch(input); }, - async createWorkbenchSession( - _workspaceId: string, - input: HandoffWorkbenchSelectInput & { model?: string }, - ): Promise<{ tabId: string }> { + async createWorkbenchSession(_workspaceId: string, input: TaskWorkbenchSelectInput & { model?: string }): Promise<{ tabId: string }> { return await workbench.addTab(input); }, - async renameWorkbenchSession(_workspaceId: string, input: HandoffWorkbenchRenameSessionInput): Promise { + async renameWorkbenchSession(_workspaceId: string, input: TaskWorkbenchRenameSessionInput): Promise { await workbench.renameSession(input); }, - async setWorkbenchSessionUnread( - _workspaceId: string, - input: HandoffWorkbenchSetSessionUnreadInput, - ): Promise { + async setWorkbenchSessionUnread(_workspaceId: string, input: TaskWorkbenchSetSessionUnreadInput): Promise { await workbench.setSessionUnread(input); }, - async updateWorkbenchDraft(_workspaceId: string, input: HandoffWorkbenchUpdateDraftInput): Promise { + async updateWorkbenchDraft(_workspaceId: string, input: TaskWorkbenchUpdateDraftInput): Promise { await workbench.updateDraft(input); }, - async changeWorkbenchModel(_workspaceId: string, input: HandoffWorkbenchChangeModelInput): Promise { + async changeWorkbenchModel(_workspaceId: string, input: TaskWorkbenchChangeModelInput): Promise { await workbench.changeModel(input); }, - async sendWorkbenchMessage(_workspaceId: string, input: HandoffWorkbenchSendMessageInput): Promise { + async sendWorkbenchMessage(_workspaceId: string, input: TaskWorkbenchSendMessageInput): Promise { await workbench.sendMessage(input); }, - async stopWorkbenchSession(_workspaceId: string, input: HandoffWorkbenchTabInput): Promise { + async stopWorkbenchSession(_workspaceId: string, input: TaskWorkbenchTabInput): Promise { await workbench.stopAgent(input); }, - async closeWorkbenchSession(_workspaceId: string, input: HandoffWorkbenchTabInput): Promise { + async closeWorkbenchSession(_workspaceId: string, input: TaskWorkbenchTabInput): Promise { await workbench.closeTab(input); }, - async publishWorkbenchPr(_workspaceId: string, input: HandoffWorkbenchSelectInput): Promise { + async publishWorkbenchPr(_workspaceId: string, input: TaskWorkbenchSelectInput): Promise { await workbench.publishPr(input); }, - async revertWorkbenchFile(_workspaceId: string, input: HandoffWorkbenchDiffInput): Promise { + async revertWorkbenchFile(_workspaceId: string, input: TaskWorkbenchDiffInput): Promise { await workbench.revertFile(input); }, diff --git a/foundry/packages/client/src/mock/latency.ts b/foundry/packages/client/src/mock/latency.ts new file mode 100644 index 0000000..54d2d58 --- /dev/null +++ b/foundry/packages/client/src/mock/latency.ts @@ -0,0 +1,12 @@ +const MOCK_LATENCY_MIN_MS = 1; +const MOCK_LATENCY_MAX_MS = 200; + +export function randomMockLatencyMs(): number { + return Math.floor(Math.random() * (MOCK_LATENCY_MAX_MS - MOCK_LATENCY_MIN_MS + 1)) + MOCK_LATENCY_MIN_MS; +} + +export function injectMockLatency(): Promise { + return new Promise((resolve) => { + setTimeout(resolve, randomMockLatencyMs()); + }); +} diff --git a/foundry/packages/client/src/mock/workbench-client.ts b/foundry/packages/client/src/mock/workbench-client.ts new file mode 100644 index 0000000..c35aace --- /dev/null +++ b/foundry/packages/client/src/mock/workbench-client.ts @@ -0,0 +1,443 @@ +import { + MODEL_GROUPS, + buildInitialMockLayoutViewModel, + groupWorkbenchProjects, + nowMs, + providerAgent, + randomReply, + removeFileTreePath, + slugify, + uid, +} from "../workbench-model.js"; +import type { + TaskWorkbenchAddTabResponse, + TaskWorkbenchChangeModelInput, + TaskWorkbenchCreateTaskInput, + TaskWorkbenchCreateTaskResponse, + TaskWorkbenchDiffInput, + TaskWorkbenchRenameInput, + TaskWorkbenchRenameSessionInput, + TaskWorkbenchSelectInput, + TaskWorkbenchSetSessionUnreadInput, + TaskWorkbenchSendMessageInput, + TaskWorkbenchSnapshot, + TaskWorkbenchTabInput, + TaskWorkbenchUpdateDraftInput, + WorkbenchAgentTab as AgentTab, + WorkbenchTask as Task, + WorkbenchTranscriptEvent as TranscriptEvent, +} from "@sandbox-agent/foundry-shared"; +import type { TaskWorkbenchClient } from "../workbench-client.js"; + +function buildTranscriptEvent(params: { + sessionId: string; + sender: "client" | "agent"; + createdAt: number; + payload: unknown; + eventIndex: number; +}): TranscriptEvent { + return { + id: uid(), + sessionId: params.sessionId, + sender: params.sender, + createdAt: params.createdAt, + payload: params.payload, + connectionId: "mock-connection", + eventIndex: params.eventIndex, + }; +} + +class MockWorkbenchStore implements TaskWorkbenchClient { + private snapshot = buildInitialMockLayoutViewModel(); + private listeners = new Set<() => void>(); + private pendingTimers = new Map>(); + + getSnapshot(): TaskWorkbenchSnapshot { + return this.snapshot; + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + async createTask(input: TaskWorkbenchCreateTaskInput): Promise { + const id = uid(); + const tabId = `session-${id}`; + const repo = this.snapshot.repos.find((candidate) => candidate.id === input.repoId); + if (!repo) { + throw new Error(`Cannot create mock task for unknown repo ${input.repoId}`); + } + const nextTask: Task = { + id, + repoId: repo.id, + title: input.title?.trim() || "New Task", + status: "new", + repoName: repo.label, + updatedAtMs: nowMs(), + branch: input.branch?.trim() || null, + pullRequest: null, + tabs: [ + { + id: tabId, + sessionId: tabId, + sessionName: "Session 1", + agent: providerAgent( + MODEL_GROUPS.find((group) => group.models.some((model) => model.id === (input.model ?? "claude-sonnet-4")))?.provider ?? "Claude", + ), + model: input.model ?? "claude-sonnet-4", + status: "idle", + thinkingSinceMs: null, + unread: false, + created: false, + draft: { text: "", attachments: [], updatedAtMs: null }, + transcript: [], + }, + ], + fileChanges: [], + diffs: {}, + fileTree: [], + }; + + this.updateState((current) => ({ + ...current, + tasks: [nextTask, ...current.tasks], + })); + return { taskId: id, tabId }; + } + + async markTaskUnread(input: TaskWorkbenchSelectInput): Promise { + this.updateTask(input.taskId, (task) => { + const targetTab = task.tabs[task.tabs.length - 1] ?? null; + if (!targetTab) { + return task; + } + + return { + ...task, + tabs: task.tabs.map((tab) => (tab.id === targetTab.id ? { ...tab, unread: true } : tab)), + }; + }); + } + + async renameTask(input: TaskWorkbenchRenameInput): Promise { + const value = input.value.trim(); + if (!value) { + throw new Error(`Cannot rename task ${input.taskId} to an empty title`); + } + 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 { + this.updateTask(input.taskId, (task) => ({ ...task, status: "archived", updatedAtMs: nowMs() })); + } + + async publishPr(input: TaskWorkbenchSelectInput): Promise { + const nextPrNumber = Math.max(0, ...this.snapshot.tasks.map((task) => task.pullRequest?.number ?? 0)) + 1; + this.updateTask(input.taskId, (task) => ({ + ...task, + updatedAtMs: nowMs(), + pullRequest: { number: nextPrNumber, status: "ready" }, + })); + } + + async revertFile(input: TaskWorkbenchDiffInput): Promise { + this.updateTask(input.taskId, (task) => { + const file = task.fileChanges.find((entry) => entry.path === input.path); + const nextDiffs = { ...task.diffs }; + delete nextDiffs[input.path]; + + return { + ...task, + fileChanges: task.fileChanges.filter((entry) => entry.path !== input.path), + diffs: nextDiffs, + fileTree: file?.type === "A" ? removeFileTreePath(task.fileTree, input.path) : task.fileTree, + }; + }); + } + + async updateDraft(input: TaskWorkbenchUpdateDraftInput): Promise { + this.assertTab(input.taskId, input.tabId); + this.updateTask(input.taskId, (task) => ({ + ...task, + updatedAtMs: nowMs(), + tabs: task.tabs.map((tab) => + tab.id === input.tabId + ? { + ...tab, + draft: { + text: input.text, + attachments: input.attachments, + updatedAtMs: nowMs(), + }, + } + : tab, + ), + })); + } + + async sendMessage(input: TaskWorkbenchSendMessageInput): Promise { + const text = input.text.trim(); + if (!text) { + throw new Error(`Cannot send an empty mock prompt for task ${input.taskId}`); + } + + this.assertTab(input.taskId, input.tabId); + const startedAtMs = nowMs(); + + this.updateTask(input.taskId, (currentTask) => { + const isFirstOnTask = currentTask.status === "new"; + const newTitle = isFirstOnTask ? (text.length > 50 ? `${text.slice(0, 47)}...` : text) : currentTask.title; + const newBranch = isFirstOnTask ? `feat/${slugify(newTitle)}` : currentTask.branch; + const userMessageLines = [text, ...input.attachments.map((attachment) => `@ ${attachment.filePath}:${attachment.lineNumber}`)]; + const userEvent = buildTranscriptEvent({ + sessionId: input.tabId, + sender: "client", + createdAt: startedAtMs, + eventIndex: candidateEventIndex(currentTask, input.tabId), + payload: { + method: "session/prompt", + params: { + prompt: userMessageLines.map((line) => ({ type: "text", text: line })), + }, + }, + }); + + return { + ...currentTask, + title: newTitle, + branch: newBranch, + status: "running", + updatedAtMs: startedAtMs, + tabs: currentTask.tabs.map((candidate) => + candidate.id === input.tabId + ? { + ...candidate, + created: true, + status: "running", + unread: false, + thinkingSinceMs: startedAtMs, + draft: { text: "", attachments: [], updatedAtMs: startedAtMs }, + transcript: [...candidate.transcript, userEvent], + } + : candidate, + ), + }; + }); + + const existingTimer = this.pendingTimers.get(input.tabId); + if (existingTimer) { + clearTimeout(existingTimer); + } + + const timer = setTimeout(() => { + const task = this.requireTask(input.taskId); + const replyTab = this.requireTab(task, input.tabId); + const completedAtMs = nowMs(); + const replyEvent = buildTranscriptEvent({ + sessionId: input.tabId, + sender: "agent", + createdAt: completedAtMs, + eventIndex: candidateEventIndex(task, input.tabId), + payload: { + result: { + text: randomReply(), + durationMs: completedAtMs - startedAtMs, + }, + }, + }); + + this.updateTask(input.taskId, (currentTask) => { + const updatedTabs = currentTask.tabs.map((candidate) => { + if (candidate.id !== input.tabId) { + return candidate; + } + + return { + ...candidate, + status: "idle" as const, + thinkingSinceMs: null, + unread: true, + transcript: [...candidate.transcript, replyEvent], + }; + }); + const anyRunning = updatedTabs.some((candidate) => candidate.status === "running"); + + return { + ...currentTask, + updatedAtMs: completedAtMs, + tabs: updatedTabs, + status: currentTask.status === "archived" ? "archived" : anyRunning ? "running" : "idle", + }; + }); + + this.pendingTimers.delete(input.tabId); + }, 2_500); + + this.pendingTimers.set(input.tabId, timer); + } + + async stopAgent(input: TaskWorkbenchTabInput): Promise { + this.assertTab(input.taskId, input.tabId); + const existing = this.pendingTimers.get(input.tabId); + if (existing) { + clearTimeout(existing); + this.pendingTimers.delete(input.tabId); + } + + this.updateTask(input.taskId, (currentTask) => { + const updatedTabs = currentTask.tabs.map((candidate) => + candidate.id === input.tabId ? { ...candidate, status: "idle" as const, thinkingSinceMs: null } : candidate, + ); + const anyRunning = updatedTabs.some((candidate) => candidate.status === "running"); + + return { + ...currentTask, + updatedAtMs: nowMs(), + tabs: updatedTabs, + status: currentTask.status === "archived" ? "archived" : anyRunning ? "running" : "idle", + }; + }); + } + + async setSessionUnread(input: TaskWorkbenchSetSessionUnreadInput): Promise { + this.updateTask(input.taskId, (currentTask) => ({ + ...currentTask, + tabs: currentTask.tabs.map((candidate) => (candidate.id === input.tabId ? { ...candidate, unread: input.unread } : candidate)), + })); + } + + async renameSession(input: TaskWorkbenchRenameSessionInput): Promise { + const title = input.title.trim(); + if (!title) { + throw new Error(`Cannot rename session ${input.tabId} to an empty title`); + } + this.updateTask(input.taskId, (currentTask) => ({ + ...currentTask, + tabs: currentTask.tabs.map((candidate) => (candidate.id === input.tabId ? { ...candidate, sessionName: title } : candidate)), + })); + } + + async closeTab(input: TaskWorkbenchTabInput): Promise { + this.updateTask(input.taskId, (currentTask) => { + if (currentTask.tabs.length <= 1) { + return currentTask; + } + + return { + ...currentTask, + tabs: currentTask.tabs.filter((candidate) => candidate.id !== input.tabId), + }; + }); + } + + async addTab(input: TaskWorkbenchSelectInput): Promise { + this.assertTask(input.taskId); + const nextTab: AgentTab = { + id: uid(), + sessionId: null, + sessionName: `Session ${this.requireTask(input.taskId).tabs.length + 1}`, + agent: "Claude", + model: "claude-sonnet-4", + status: "idle", + thinkingSinceMs: null, + unread: false, + created: false, + draft: { text: "", attachments: [], updatedAtMs: null }, + transcript: [], + }; + + this.updateTask(input.taskId, (currentTask) => ({ + ...currentTask, + updatedAtMs: nowMs(), + tabs: [...currentTask.tabs, nextTab], + })); + return { tabId: nextTab.id }; + } + + async changeModel(input: TaskWorkbenchChangeModelInput): 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}`); + } + + this.updateTask(input.taskId, (currentTask) => ({ + ...currentTask, + tabs: currentTask.tabs.map((candidate) => + candidate.id === input.tabId ? { ...candidate, model: input.model, agent: providerAgent(group.provider) } : candidate, + ), + })); + } + + private updateState(updater: (current: TaskWorkbenchSnapshot) => TaskWorkbenchSnapshot): void { + const nextSnapshot = updater(this.snapshot); + this.snapshot = { + ...nextSnapshot, + projects: groupWorkbenchProjects(nextSnapshot.repos, nextSnapshot.tasks), + }; + this.notify(); + } + + private updateTask(taskId: string, updater: (task: Task) => Task): void { + this.assertTask(taskId); + this.updateState((current) => ({ + ...current, + tasks: current.tasks.map((task) => (task.id === taskId ? updater(task) : task)), + })); + } + + private notify(): void { + for (const listener of this.listeners) { + listener(); + } + } + + private assertTask(taskId: string): void { + this.requireTask(taskId); + } + + private assertTab(taskId: string, tabId: string): void { + const task = this.requireTask(taskId); + this.requireTab(task, tabId); + } + + private requireTask(taskId: string): Task { + const task = this.snapshot.tasks.find((candidate) => candidate.id === taskId); + if (!task) { + throw new Error(`Unable to find mock task ${taskId}`); + } + return task; + } + + private requireTab(task: Task, tabId: string): AgentTab { + const tab = task.tabs.find((candidate) => candidate.id === tabId); + if (!tab) { + throw new Error(`Unable to find mock tab ${tabId} in task ${task.id}`); + } + return tab; + } +} + +function candidateEventIndex(task: Task, tabId: string): number { + const tab = task.tabs.find((candidate) => candidate.id === tabId); + return (tab?.transcript.length ?? 0) + 1; +} + +let sharedMockWorkbenchClient: TaskWorkbenchClient | null = null; + +export function getSharedMockWorkbenchClient(): TaskWorkbenchClient { + if (!sharedMockWorkbenchClient) { + sharedMockWorkbenchClient = new MockWorkbenchStore(); + } + return sharedMockWorkbenchClient; +} diff --git a/foundry/packages/client/src/remote/app-client.ts b/foundry/packages/client/src/remote/app-client.ts new file mode 100644 index 0000000..e381540 --- /dev/null +++ b/foundry/packages/client/src/remote/app-client.ts @@ -0,0 +1,152 @@ +import type { FoundryAppSnapshot, FoundryBillingPlanId, UpdateFoundryOrganizationProfileInput } from "@sandbox-agent/foundry-shared"; +import type { BackendClient } from "../backend-client.js"; +import type { FoundryAppClient } from "../app-client.js"; + +export interface RemoteFoundryAppClientOptions { + backend: BackendClient; +} + +class RemoteFoundryAppStore implements FoundryAppClient { + private readonly backend: BackendClient; + private snapshot: FoundryAppSnapshot = { + auth: { status: "signed_out", currentUserId: null }, + activeOrganizationId: null, + onboarding: { + starterRepo: { + repoFullName: "rivet-dev/sandbox-agent", + repoUrl: "https://github.com/rivet-dev/sandbox-agent", + status: "pending", + starredAt: null, + skippedAt: null, + }, + }, + users: [], + organizations: [], + }; + private readonly listeners = new Set<() => void>(); + private refreshPromise: Promise | null = null; + private syncPollTimeout: ReturnType | null = null; + + constructor(options: RemoteFoundryAppClientOptions) { + this.backend = options.backend; + } + + getSnapshot(): FoundryAppSnapshot { + return this.snapshot; + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + void this.refresh(); + return () => { + this.listeners.delete(listener); + }; + } + + async signInWithGithub(userId?: string): Promise { + void userId; + await this.backend.signInWithGithub(); + } + + async signOut(): Promise { + this.snapshot = await this.backend.signOutApp(); + this.notify(); + } + + async skipStarterRepo(): Promise { + this.snapshot = await this.backend.skipAppStarterRepo(); + this.notify(); + } + + async starStarterRepo(organizationId: string): Promise { + this.snapshot = await this.backend.starAppStarterRepo(organizationId); + this.notify(); + } + + async selectOrganization(organizationId: string): Promise { + this.snapshot = await this.backend.selectAppOrganization(organizationId); + this.notify(); + this.scheduleSyncPollingIfNeeded(); + } + + async updateOrganizationProfile(input: UpdateFoundryOrganizationProfileInput): Promise { + this.snapshot = await this.backend.updateAppOrganizationProfile(input); + this.notify(); + } + + async triggerGithubSync(organizationId: string): Promise { + this.snapshot = await this.backend.triggerAppRepoImport(organizationId); + this.notify(); + this.scheduleSyncPollingIfNeeded(); + } + + async completeHostedCheckout(organizationId: string, planId: FoundryBillingPlanId): Promise { + await this.backend.completeAppHostedCheckout(organizationId, planId); + } + + async openBillingPortal(organizationId: string): Promise { + await this.backend.openAppBillingPortal(organizationId); + } + + async cancelScheduledRenewal(organizationId: string): Promise { + this.snapshot = await this.backend.cancelAppScheduledRenewal(organizationId); + this.notify(); + } + + async resumeSubscription(organizationId: string): Promise { + this.snapshot = await this.backend.resumeAppSubscription(organizationId); + this.notify(); + } + + async reconnectGithub(organizationId: string): Promise { + await this.backend.reconnectAppGithub(organizationId); + } + + async recordSeatUsage(workspaceId: string): Promise { + this.snapshot = await this.backend.recordAppSeatUsage(workspaceId); + this.notify(); + } + + private scheduleSyncPollingIfNeeded(): void { + if (this.syncPollTimeout) { + clearTimeout(this.syncPollTimeout); + this.syncPollTimeout = null; + } + + if (!this.snapshot.organizations.some((organization) => organization.github.syncStatus === "syncing")) { + return; + } + + this.syncPollTimeout = setTimeout(() => { + this.syncPollTimeout = null; + void this.refresh(); + }, 500); + } + + private async refresh(): Promise { + if (this.refreshPromise) { + await this.refreshPromise; + return; + } + + this.refreshPromise = (async () => { + this.snapshot = await this.backend.getAppSnapshot(); + this.notify(); + this.scheduleSyncPollingIfNeeded(); + })().finally(() => { + this.refreshPromise = null; + }); + + await this.refreshPromise; + } + + private notify(): void { + for (const listener of [...this.listeners]) { + listener(); + } + } +} + +export function createRemoteFoundryAppClient(options: RemoteFoundryAppClientOptions): FoundryAppClient { + return new RemoteFoundryAppStore(options); +} diff --git a/factory/packages/client/src/remote/workbench-client.ts b/foundry/packages/client/src/remote/workbench-client.ts similarity index 65% rename from factory/packages/client/src/remote/workbench-client.ts rename to foundry/packages/client/src/remote/workbench-client.ts index ebb21b7..4b25193 100644 --- a/factory/packages/client/src/remote/workbench-client.ts +++ b/foundry/packages/client/src/remote/workbench-client.ts @@ -1,31 +1,31 @@ import type { - HandoffWorkbenchAddTabResponse, - HandoffWorkbenchChangeModelInput, - HandoffWorkbenchCreateHandoffInput, - HandoffWorkbenchCreateHandoffResponse, - HandoffWorkbenchDiffInput, - HandoffWorkbenchRenameInput, - HandoffWorkbenchRenameSessionInput, - HandoffWorkbenchSelectInput, - HandoffWorkbenchSetSessionUnreadInput, - HandoffWorkbenchSendMessageInput, - HandoffWorkbenchSnapshot, - HandoffWorkbenchTabInput, - HandoffWorkbenchUpdateDraftInput, -} from "@openhandoff/shared"; + TaskWorkbenchAddTabResponse, + TaskWorkbenchChangeModelInput, + TaskWorkbenchCreateTaskInput, + TaskWorkbenchCreateTaskResponse, + TaskWorkbenchDiffInput, + TaskWorkbenchRenameInput, + TaskWorkbenchRenameSessionInput, + TaskWorkbenchSelectInput, + TaskWorkbenchSetSessionUnreadInput, + TaskWorkbenchSendMessageInput, + TaskWorkbenchSnapshot, + TaskWorkbenchTabInput, + TaskWorkbenchUpdateDraftInput, +} from "@sandbox-agent/foundry-shared"; import type { BackendClient } from "../backend-client.js"; import { groupWorkbenchProjects } from "../workbench-model.js"; -import type { HandoffWorkbenchClient } from "../workbench-client.js"; +import type { TaskWorkbenchClient } from "../workbench-client.js"; export interface RemoteWorkbenchClientOptions { backend: BackendClient; workspaceId: string; } -class RemoteWorkbenchStore implements HandoffWorkbenchClient { +class RemoteWorkbenchStore implements TaskWorkbenchClient { private readonly backend: BackendClient; private readonly workspaceId: string; - private snapshot: HandoffWorkbenchSnapshot; + private snapshot: TaskWorkbenchSnapshot; private readonly listeners = new Set<() => void>(); private unsubscribeWorkbench: (() => void) | null = null; private refreshPromise: Promise | null = null; @@ -38,11 +38,11 @@ class RemoteWorkbenchStore implements HandoffWorkbenchClient { workspaceId: options.workspaceId, repos: [], projects: [], - handoffs: [], + tasks: [], }; } - getSnapshot(): HandoffWorkbenchSnapshot { + getSnapshot(): TaskWorkbenchSnapshot { return this.snapshot; } @@ -62,79 +62,79 @@ class RemoteWorkbenchStore implements HandoffWorkbenchClient { }; } - async createHandoff(input: HandoffWorkbenchCreateHandoffInput): Promise { - const created = await this.backend.createWorkbenchHandoff(this.workspaceId, input); + async createTask(input: TaskWorkbenchCreateTaskInput): Promise { + const created = await this.backend.createWorkbenchTask(this.workspaceId, input); await this.refresh(); return created; } - async markHandoffUnread(input: HandoffWorkbenchSelectInput): Promise { + async markTaskUnread(input: TaskWorkbenchSelectInput): Promise { await this.backend.markWorkbenchUnread(this.workspaceId, input); await this.refresh(); } - async renameHandoff(input: HandoffWorkbenchRenameInput): Promise { - await this.backend.renameWorkbenchHandoff(this.workspaceId, input); + async renameTask(input: TaskWorkbenchRenameInput): Promise { + await this.backend.renameWorkbenchTask(this.workspaceId, input); await this.refresh(); } - async renameBranch(input: HandoffWorkbenchRenameInput): Promise { + async renameBranch(input: TaskWorkbenchRenameInput): Promise { await this.backend.renameWorkbenchBranch(this.workspaceId, input); await this.refresh(); } - async archiveHandoff(input: HandoffWorkbenchSelectInput): Promise { - await this.backend.runAction(this.workspaceId, input.handoffId, "archive"); + async archiveTask(input: TaskWorkbenchSelectInput): Promise { + await this.backend.runAction(this.workspaceId, input.taskId, "archive"); await this.refresh(); } - async publishPr(input: HandoffWorkbenchSelectInput): Promise { + async publishPr(input: TaskWorkbenchSelectInput): Promise { await this.backend.publishWorkbenchPr(this.workspaceId, input); await this.refresh(); } - async revertFile(input: HandoffWorkbenchDiffInput): Promise { + async revertFile(input: TaskWorkbenchDiffInput): Promise { await this.backend.revertWorkbenchFile(this.workspaceId, input); await this.refresh(); } - async updateDraft(input: HandoffWorkbenchUpdateDraftInput): Promise { + async updateDraft(input: TaskWorkbenchUpdateDraftInput): Promise { await this.backend.updateWorkbenchDraft(this.workspaceId, input); await this.refresh(); } - async sendMessage(input: HandoffWorkbenchSendMessageInput): Promise { + async sendMessage(input: TaskWorkbenchSendMessageInput): Promise { await this.backend.sendWorkbenchMessage(this.workspaceId, input); await this.refresh(); } - async stopAgent(input: HandoffWorkbenchTabInput): Promise { + async stopAgent(input: TaskWorkbenchTabInput): Promise { await this.backend.stopWorkbenchSession(this.workspaceId, input); await this.refresh(); } - async setSessionUnread(input: HandoffWorkbenchSetSessionUnreadInput): Promise { + async setSessionUnread(input: TaskWorkbenchSetSessionUnreadInput): Promise { await this.backend.setWorkbenchSessionUnread(this.workspaceId, input); await this.refresh(); } - async renameSession(input: HandoffWorkbenchRenameSessionInput): Promise { + async renameSession(input: TaskWorkbenchRenameSessionInput): Promise { await this.backend.renameWorkbenchSession(this.workspaceId, input); await this.refresh(); } - async closeTab(input: HandoffWorkbenchTabInput): Promise { + async closeTab(input: TaskWorkbenchTabInput): Promise { await this.backend.closeWorkbenchSession(this.workspaceId, input); await this.refresh(); } - async addTab(input: HandoffWorkbenchSelectInput): Promise { + async addTab(input: TaskWorkbenchSelectInput): Promise { const created = await this.backend.createWorkbenchSession(this.workspaceId, input); await this.refresh(); return created; } - async changeModel(input: HandoffWorkbenchChangeModelInput): Promise { + async changeModel(input: TaskWorkbenchChangeModelInput): Promise { await this.backend.changeWorkbenchModel(this.workspaceId, input); await this.refresh(); } @@ -179,7 +179,7 @@ class RemoteWorkbenchStore implements HandoffWorkbenchClient { } this.snapshot = { ...nextSnapshot, - projects: nextSnapshot.projects ?? groupWorkbenchProjects(nextSnapshot.repos, nextSnapshot.handoffs), + projects: nextSnapshot.projects ?? groupWorkbenchProjects(nextSnapshot.repos, nextSnapshot.tasks), }; for (const listener of [...this.listeners]) { listener(); @@ -192,6 +192,6 @@ class RemoteWorkbenchStore implements HandoffWorkbenchClient { } } -export function createRemoteWorkbenchClient(options: RemoteWorkbenchClientOptions): HandoffWorkbenchClient { +export function createRemoteWorkbenchClient(options: RemoteWorkbenchClientOptions): TaskWorkbenchClient { return new RemoteWorkbenchStore(options); } diff --git a/factory/packages/client/src/view-model.ts b/foundry/packages/client/src/view-model.ts similarity index 69% rename from factory/packages/client/src/view-model.ts rename to foundry/packages/client/src/view-model.ts index 76621cb..4764bac 100644 --- a/factory/packages/client/src/view-model.ts +++ b/foundry/packages/client/src/view-model.ts @@ -1,10 +1,10 @@ -import type { HandoffRecord, HandoffStatus } from "@openhandoff/shared"; +import type { TaskRecord, TaskStatus } from "@sandbox-agent/foundry-shared"; -export const HANDOFF_STATUS_GROUPS = ["queued", "running", "idle", "archived", "killed", "error"] as const; +export const TASK_STATUS_GROUPS = ["queued", "running", "idle", "archived", "killed", "error"] as const; -export type HandoffStatusGroup = (typeof HANDOFF_STATUS_GROUPS)[number]; +export type TaskStatusGroup = (typeof TASK_STATUS_GROUPS)[number]; -const QUEUED_STATUSES = new Set([ +const QUEUED_STATUSES = new Set([ "init_bootstrap_db", "init_enqueue_provision", "init_ensure_name", @@ -23,7 +23,7 @@ const QUEUED_STATUSES = new Set([ "kill_finalize", ]); -export function groupHandoffStatus(status: HandoffStatus): HandoffStatusGroup { +export function groupTaskStatus(status: TaskStatus): TaskStatusGroup { if (status === "running") return "running"; if (status === "idle") return "idle"; if (status === "archived") return "archived"; @@ -33,7 +33,7 @@ export function groupHandoffStatus(status: HandoffStatus): HandoffStatusGroup { return "queued"; } -function emptyStatusCounts(): Record { +function emptyStatusCounts(): Record { return { queued: 0, running: 0, @@ -44,9 +44,9 @@ function emptyStatusCounts(): Record { }; } -export interface HandoffSummary { +export interface TaskSummary { total: number; - byStatus: Record; + byStatus: Record; byProvider: Record; } @@ -64,14 +64,14 @@ export function fuzzyMatch(target: string, query: string): boolean { return true; } -export function filterHandoffs(rows: HandoffRecord[], query: string): HandoffRecord[] { +export function filterTasks(rows: TaskRecord[], query: string): TaskRecord[] { const q = query.trim(); if (!q) { return rows; } return rows.filter((row) => { - const fields = [row.branchName ?? "", row.title ?? "", row.handoffId, row.task, row.prAuthor ?? "", row.reviewer ?? ""]; + const fields = [row.branchName ?? "", row.title ?? "", row.taskId, row.task, row.prAuthor ?? "", row.reviewer ?? ""]; return fields.some((field) => fuzzyMatch(field, q)); }); } @@ -87,12 +87,12 @@ export function formatRelativeAge(updatedAt: number, now = Date.now()): string { return `${days}d`; } -export function summarizeHandoffs(rows: HandoffRecord[]): HandoffSummary { +export function summarizeTasks(rows: TaskRecord[]): TaskSummary { const byStatus = emptyStatusCounts(); const byProvider: Record = {}; for (const row of rows) { - byStatus[groupHandoffStatus(row.status)] += 1; + byStatus[groupTaskStatus(row.status)] += 1; byProvider[row.providerId] = (byProvider[row.providerId] ?? 0) + 1; } diff --git a/foundry/packages/client/src/workbench-client.ts b/foundry/packages/client/src/workbench-client.ts new file mode 100644 index 0000000..b6990fc --- /dev/null +++ b/foundry/packages/client/src/workbench-client.ts @@ -0,0 +1,64 @@ +import type { + TaskWorkbenchAddTabResponse, + TaskWorkbenchChangeModelInput, + TaskWorkbenchCreateTaskInput, + TaskWorkbenchCreateTaskResponse, + TaskWorkbenchDiffInput, + TaskWorkbenchRenameInput, + TaskWorkbenchRenameSessionInput, + TaskWorkbenchSelectInput, + TaskWorkbenchSetSessionUnreadInput, + TaskWorkbenchSendMessageInput, + TaskWorkbenchSnapshot, + TaskWorkbenchTabInput, + 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; + workspaceId?: 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: TaskWorkbenchTabInput): Promise; + setSessionUnread(input: TaskWorkbenchSetSessionUnreadInput): Promise; + renameSession(input: TaskWorkbenchRenameSessionInput): Promise; + closeTab(input: TaskWorkbenchTabInput): Promise; + addTab(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.workspaceId) { + throw new Error("Remote task workbench client requires a workspace id"); + } + + return createRemoteWorkbenchClient({ + backend: options.backend, + workspaceId: options.workspaceId, + }); +} diff --git a/factory/packages/client/src/workbench-model.ts b/foundry/packages/client/src/workbench-model.ts similarity index 94% rename from factory/packages/client/src/workbench-model.ts rename to foundry/packages/client/src/workbench-model.ts index a0b28b7..4887572 100644 --- a/factory/packages/client/src/workbench-model.ts +++ b/foundry/packages/client/src/workbench-model.ts @@ -3,8 +3,8 @@ import type { WorkbenchAgentTab as AgentTab, WorkbenchDiffLineKind as DiffLineKind, WorkbenchFileTreeNode as FileTreeNode, - WorkbenchHandoff as Handoff, - HandoffWorkbenchSnapshot, + WorkbenchTask as Task, + TaskWorkbenchSnapshot, WorkbenchHistoryEvent as HistoryEvent, WorkbenchModelGroup as ModelGroup, WorkbenchModelId as ModelId, @@ -12,7 +12,7 @@ import type { WorkbenchProjectSection, WorkbenchRepo, WorkbenchTranscriptEvent as TranscriptEvent, -} from "@openhandoff/shared"; +} from "@sandbox-agent/foundry-shared"; export const MODEL_GROUPS: ModelGroup[] = [ { @@ -264,7 +264,7 @@ export function removeFileTreePath(nodes: FileTreeNode[], targetPath: string): F }); } -export function buildInitialHandoffs(): Handoff[] { +export function buildInitialTasks(): Task[] { return [ // ── rivet-dev/sandbox-agent ── { @@ -316,14 +316,14 @@ export function buildInitialHandoffs(): Handoff[] { role: "user", agent: null, createdAtMs: minutesAgo(12), - lines: ["Does this also handle the case where protocolVersion is a float string like \"2.0\"?"], + lines: ['Does this also handle the case where protocolVersion is a float string like "2.0"?'], }, { id: "m4", role: "agent", agent: "claude", createdAtMs: minutesAgo(11), - lines: ["Yes — the `parse_json_number` helper tries u64, then i64, then f64 parsing in order. So \"2.0\" becomes `2.0` as a JSON number."], + lines: ['Yes — the `parse_json_number` helper tries u64, then i64, then f64 parsing in order. So "2.0" becomes `2.0` as a JSON number.'], durationMs: 8_000, }, ]), @@ -358,7 +358,7 @@ export function buildInitialHandoffs(): Handoff[] { diffs: { "server/packages/sandbox-agent/src/acp_proxy_runtime.rs": [ "@@ -134,6 +134,8 @@ impl AcpProxyRuntime {", - " \"acp_proxy: instance resolved\"", + ' "acp_proxy: instance resolved"', " );", " ", "+ let payload = normalize_payload_for_agent(instance.agent, payload);", @@ -376,13 +376,13 @@ export function buildInitialHandoffs(): Handoff[] { "+", "+fn normalize_pi_payload(mut payload: Value) -> Value {", "+ let method = payload", - "+ .get(\"method\")", + '+ .get("method")', "+ .and_then(Value::as_str)", "+ .unwrap_or_default();", "+", "+ match method {", - "+ \"initialize\" => {", - "+ if let Some(protocol) = payload.pointer_mut(\"/params/protocolVersion\") {", + '+ "initialize" => {', + '+ if let Some(protocol) = payload.pointer_mut("/params/protocolVersion") {', "+ if let Some(raw) = protocol.as_str() {", "+ if let Some(number) = parse_json_number(raw) {", "+ *protocol = Value::Number(number);", @@ -390,9 +390,9 @@ export function buildInitialHandoffs(): Handoff[] { "+ }", "+ }", "+ }", - "+ \"session/new\" => {", - "+ if let Some(params) = payload.get_mut(\"params\").and_then(Value::as_object_mut) {", - "+ params.entry(\"mcpServers\".to_string())", + '+ "session/new" => {', + '+ if let Some(params) = payload.get_mut("params").and_then(Value::as_object_mut) {', + '+ params.entry("mcpServers".to_string())', "+ .or_insert_with(|| Value::Array(Vec::new()));", "+ }", "+ }", @@ -672,8 +672,8 @@ export function buildInitialHandoffs(): Handoff[] { "+", "+#[utoipa::path(", "+ post,", - "+ operation_id = \"actors_reschedule\",", - "+ path = \"/actors/{actor_id}/reschedule\",", + '+ operation_id = "actors_reschedule",', + '+ path = "/actors/{actor_id}/reschedule",', "+)]", "+#[tracing::instrument(skip_all)]", "+pub async fn reschedule(", @@ -693,7 +693,7 @@ export function buildInitialHandoffs(): Handoff[] { "+ reset_rescheduling: true,", "+ })", "+ .to_workflow::()", - "+ .tag(\"actor_id\", path.actor_id)", + '+ .tag("actor_id", path.actor_id)', "+ .send().await?;", "+", "+ Ok(RescheduleResponse {})", @@ -841,7 +841,9 @@ export function buildInitialHandoffs(): Handoff[] { role: "agent", agent: "claude", createdAtMs: minutesAgo(36), - lines: ["Yes — the `Registry` type is now exported from `actors/index.ts` so downstream consumers can reference it. Also bumped rivetkit to `2.0.4-rc.1` in pnpm overrides."], + lines: [ + "Yes — the `Registry` type is now exported from `actors/index.ts` so downstream consumers can reference it. Also bumped rivetkit to `2.0.4-rc.1` in pnpm overrides.", + ], durationMs: 11_000, }, ]), @@ -1084,7 +1086,9 @@ export function buildInitialHandoffs(): Handoff[] { role: "user", agent: null, createdAtMs: minutesAgo(100), - lines: ["Audit and harden the namespace isolation for nested container execution. Make sure PID, network, and mount namespaces are correctly unshared."], + lines: [ + "Audit and harden the namespace isolation for nested container execution. Make sure PID, network, and mount namespaces are correctly unshared.", + ], }, { id: "m81", @@ -1109,7 +1113,7 @@ export function buildInitialHandoffs(): Handoff[] { ]; } -export function buildInitialMockLayoutViewModel(): HandoffWorkbenchSnapshot { +export function buildInitialMockLayoutViewModel(): TaskWorkbenchSnapshot { const repos: WorkbenchRepo[] = [ { id: "sandbox-agent", label: "rivet-dev/sandbox-agent" }, { id: "rivet", label: "rivet-dev/rivet" }, @@ -1117,16 +1121,16 @@ export function buildInitialMockLayoutViewModel(): HandoffWorkbenchSnapshot { { id: "engine-ee", label: "rivet-dev/engine-ee" }, { id: "secure-exec", label: "rivet-dev/secure-exec" }, ]; - const handoffs = buildInitialHandoffs(); + const tasks = buildInitialTasks(); return { workspaceId: "default", repos, - projects: groupWorkbenchProjects(repos, handoffs), - handoffs, + projects: groupWorkbenchProjects(repos, tasks), + tasks, }; } -export function groupWorkbenchProjects(repos: WorkbenchRepo[], handoffs: Handoff[]): WorkbenchProjectSection[] { +export function groupWorkbenchProjects(repos: WorkbenchRepo[], tasks: Task[]): WorkbenchProjectSection[] { const grouped = new Map(); for (const repo of repos) { @@ -1134,29 +1138,29 @@ export function groupWorkbenchProjects(repos: WorkbenchRepo[], handoffs: Handoff id: repo.id, label: repo.label, updatedAtMs: 0, - handoffs: [], + tasks: [], }); } - for (const handoff of handoffs) { - const existing = grouped.get(handoff.repoId) ?? { - id: handoff.repoId, - label: handoff.repoName, + for (const task of tasks) { + const existing = grouped.get(task.repoId) ?? { + id: task.repoId, + label: task.repoName, updatedAtMs: 0, - handoffs: [], + tasks: [], }; - existing.handoffs.push(handoff); - existing.updatedAtMs = Math.max(existing.updatedAtMs, handoff.updatedAtMs); - grouped.set(handoff.repoId, existing); + existing.tasks.push(task); + existing.updatedAtMs = Math.max(existing.updatedAtMs, task.updatedAtMs); + grouped.set(task.repoId, existing); } return [...grouped.values()] .map((project) => ({ ...project, - handoffs: [...project.handoffs].sort((a, b) => b.updatedAtMs - a.updatedAtMs), - updatedAtMs: project.handoffs.length > 0 ? Math.max(...project.handoffs.map((handoff) => handoff.updatedAtMs)) : project.updatedAtMs, + tasks: [...project.tasks].sort((a, b) => b.updatedAtMs - a.updatedAtMs), + updatedAtMs: project.tasks.length > 0 ? Math.max(...project.tasks.map((task) => task.updatedAtMs)) : project.updatedAtMs, })) - .filter((project) => project.handoffs.length > 0) + .filter((project) => project.tasks.length > 0) .sort((a, b) => b.updatedAtMs - a.updatedAtMs); } diff --git a/factory/packages/client/test/e2e/full-integration-e2e.test.ts b/foundry/packages/client/test/e2e/full-integration-e2e.test.ts similarity index 98% rename from factory/packages/client/test/e2e/full-integration-e2e.test.ts rename to foundry/packages/client/test/e2e/full-integration-e2e.test.ts index fe14b3b..1697d52 100644 --- a/factory/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 "@openhandoff/shared"; +import type { HistoryEvent, RepoOverview } from "@sandbox-agent/foundry-shared"; import { createBackendClient } from "../../src/backend-client.js"; const RUN_FULL_E2E = process.env.HF_ENABLE_DAEMON_FULL_E2E === "1"; diff --git a/factory/packages/client/test/e2e/github-pr-e2e.test.ts b/foundry/packages/client/test/e2e/github-pr-e2e.test.ts similarity index 79% rename from factory/packages/client/test/e2e/github-pr-e2e.test.ts rename to foundry/packages/client/test/e2e/github-pr-e2e.test.ts index 67441e1..cdd4557 100644 --- a/factory/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 { HandoffRecord, HistoryEvent } from "@openhandoff/shared"; +import type { TaskRecord, HistoryEvent } from "@sandbox-agent/foundry-shared"; import { createBackendClient } from "../../src/backend-client.js"; const RUN_E2E = process.env.HF_ENABLE_DAEMON_E2E === "1"; @@ -79,20 +79,20 @@ function parseHistoryPayload(event: HistoryEvent): Record { } } -async function debugDump(client: ReturnType, workspaceId: string, handoffId: string): Promise { +async function debugDump(client: ReturnType, workspaceId: string, taskId: string): Promise { try { - const handoff = await client.getHandoff(workspaceId, handoffId); - const history = await client.listHistory({ workspaceId, handoffId, limit: 80 }).catch(() => []); + const task = await client.getTask(workspaceId, taskId); + const history = await client.listHistory({ workspaceId, taskId, limit: 80 }).catch(() => []); const historySummary = history .slice(0, 20) .map((e) => `${new Date(e.createdAt).toISOString()} ${e.kind}`) .join("\n"); let sessionEventsSummary = ""; - if (handoff.activeSandboxId && handoff.activeSessionId) { + if (task.activeSandboxId && task.activeSessionId) { const events = await client - .listSandboxSessionEvents(workspaceId, handoff.providerId, handoff.activeSandboxId, { - sessionId: handoff.activeSessionId, + .listSandboxSessionEvents(workspaceId, task.providerId, task.activeSandboxId, { + sessionId: task.activeSessionId, limit: 50, }) .then((r) => r.items) @@ -104,17 +104,17 @@ async function debugDump(client: ReturnType, workspa } return [ - "=== handoff ===", + "=== task ===", JSON.stringify( { - status: handoff.status, - statusMessage: handoff.statusMessage, - title: handoff.title, - branchName: handoff.branchName, - activeSandboxId: handoff.activeSandboxId, - activeSessionId: handoff.activeSessionId, - prUrl: handoff.prUrl, - prSubmitted: handoff.prSubmitted, + status: task.status, + statusMessage: task.statusMessage, + title: task.title, + branchName: task.branchName, + activeSandboxId: task.activeSandboxId, + activeSessionId: task.activeSessionId, + prUrl: task.prUrl, + prSubmitted: task.prSubmitted, }, null, 2, @@ -143,7 +143,7 @@ async function githubApi(token: string, path: string, init?: RequestInit): Promi } describe("e2e: backend -> sandbox-agent -> git -> PR", () => { - it.skipIf(!RUN_E2E)("creates a handoff, waits for agent to implement, and opens a PR", { timeout: 15 * 60_000 }, async () => { + it.skipIf(!RUN_E2E)("creates a task, waits for agent to implement, and opens a PR", { timeout: 15 * 60_000 }, async () => { const endpoint = process.env.HF_E2E_BACKEND_ENDPOINT?.trim() || "http://127.0.0.1:7741/api/rivet"; const workspaceId = process.env.HF_E2E_WORKSPACE?.trim() || "default"; const repoRemote = requiredEnv("HF_E2E_GITHUB_REPO"); @@ -160,7 +160,7 @@ describe("e2e: backend -> sandbox-agent -> git -> PR", () => { const repo = await client.addRepo(workspaceId, repoRemote); - const created = await client.createHandoff({ + const created = await client.createTask({ workspaceId, repoId: repo.repoId, task: [ @@ -183,42 +183,42 @@ describe("e2e: backend -> sandbox-agent -> git -> PR", () => { let lastStatus: string | null = null; try { - const namedAndProvisioned = await poll( - "handoff naming + sandbox provisioning", + const namedAndProvisioned = await poll( + "task naming + sandbox provisioning", // Cold Daytona snapshot/image preparation can exceed 5 minutes on first run. 8 * 60_000, 1_000, - async () => client.getHandoff(workspaceId, created.handoffId), + async () => client.getTask(workspaceId, created.taskId), (h) => Boolean(h.title && h.branchName && h.activeSandboxId), (h) => { if (h.status !== lastStatus) { lastStatus = h.status; } if (h.status === "error") { - throw new Error("handoff entered error state during provisioning"); + throw new Error("task entered error state during provisioning"); } }, ).catch(async (err) => { - const dump = await debugDump(client, workspaceId, created.handoffId); + const dump = await debugDump(client, workspaceId, created.taskId); throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`); }); branchName = namedAndProvisioned.branchName!; sandboxId = namedAndProvisioned.activeSandboxId!; - const withSession = await poll( - "handoff to create active session", + const withSession = await poll( + "task to create active session", 3 * 60_000, 1_500, - async () => client.getHandoff(workspaceId, created.handoffId), + async () => client.getTask(workspaceId, created.taskId), (h) => Boolean(h.activeSessionId), (h) => { if (h.status === "error") { - throw new Error("handoff entered error state while waiting for active session"); + throw new Error("task entered error state while waiting for active session"); } }, ).catch(async (err) => { - const dump = await debugDump(client, workspaceId, created.handoffId); + const dump = await debugDump(client, workspaceId, created.taskId); throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`); }); @@ -237,23 +237,23 @@ describe("e2e: backend -> sandbox-agent -> git -> PR", () => { ).items, (events) => events.length > 0, ).catch(async (err) => { - const dump = await debugDump(client, workspaceId, created.handoffId); + const dump = await debugDump(client, workspaceId, created.taskId); throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`); }); - await poll( - "handoff to reach idle state", + await poll( + "task to reach idle state", 8 * 60_000, 2_000, - async () => client.getHandoff(workspaceId, created.handoffId), + async () => client.getTask(workspaceId, created.taskId), (h) => h.status === "idle", (h) => { if (h.status === "error") { - throw new Error("handoff entered error state while waiting for idle"); + throw new Error("task entered error state while waiting for idle"); } }, ).catch(async (err) => { - const dump = await debugDump(client, workspaceId, created.handoffId); + const dump = await debugDump(client, workspaceId, created.taskId); throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`); }); @@ -261,14 +261,14 @@ describe("e2e: backend -> sandbox-agent -> git -> PR", () => { "PR creation history event", 3 * 60_000, 2_000, - async () => client.listHistory({ workspaceId, handoffId: created.handoffId, limit: 200 }), - (events) => events.some((e) => e.kind === "handoff.pr_created"), + async () => client.listHistory({ workspaceId, taskId: created.taskId, limit: 200 }), + (events) => events.some((e) => e.kind === "task.pr_created"), ) .catch(async (err) => { - const dump = await debugDump(client, workspaceId, created.handoffId); + const dump = await debugDump(client, workspaceId, created.taskId); throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`); }) - .then((events) => events.find((e) => e.kind === "handoff.pr_created")!); + .then((events) => events.find((e) => e.kind === "task.pr_created")!); const payload = parseHistoryPayload(prCreatedEvent); prNumber = Number(payload.prNumber); @@ -285,17 +285,17 @@ describe("e2e: backend -> sandbox-agent -> git -> PR", () => { const prFiles = (await prFilesRes.json()) as Array<{ filename: string }>; expect(prFiles.some((f) => f.filename === expectedFile)).toBe(true); - // Close the handoff and assert the sandbox is released (stopped). - await client.runAction(workspaceId, created.handoffId, "archive"); + // Close the task and assert the sandbox is released (stopped). + await client.runAction(workspaceId, created.taskId, "archive"); - await poll( - "handoff to become archived (session released)", + await poll( + "task to become archived (session released)", 60_000, 1_000, - async () => client.getHandoff(workspaceId, created.handoffId), + async () => client.getTask(workspaceId, created.taskId), (h) => h.status === "archived" && h.activeSessionId === null, ).catch(async (err) => { - const dump = await debugDump(client, workspaceId, created.handoffId); + const dump = await debugDump(client, workspaceId, created.taskId); throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`); }); @@ -310,7 +310,7 @@ describe("e2e: backend -> sandbox-agent -> git -> PR", () => { return st.includes("stopped") || st.includes("suspended") || st.includes("paused"); }, ).catch(async (err) => { - const dump = await debugDump(client, workspaceId, created.handoffId); + const dump = await debugDump(client, workspaceId, created.taskId); const state = await client.sandboxProviderState(workspaceId, "daytona", sandboxId!).catch(() => null); throw new Error(`${err instanceof Error ? err.message : String(err)}\n` + `sandbox state: ${state ? state.state : "unknown"}\n` + `${dump}`); }); diff --git a/factory/packages/client/test/e2e/workbench-e2e.test.ts b/foundry/packages/client/test/e2e/workbench-e2e.test.ts similarity index 70% rename from factory/packages/client/test/e2e/workbench-e2e.test.ts rename to foundry/packages/client/test/e2e/workbench-e2e.test.ts index d768bff..b4e97d6 100644 --- a/factory/packages/client/test/e2e/workbench-e2e.test.ts +++ b/foundry/packages/client/test/e2e/workbench-e2e.test.ts @@ -1,7 +1,7 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { describe, expect, it } from "vitest"; -import type { HandoffWorkbenchSnapshot, WorkbenchAgentTab, WorkbenchHandoff, WorkbenchModelId, WorkbenchTranscriptEvent } from "@openhandoff/shared"; +import type { TaskWorkbenchSnapshot, WorkbenchAgentTab, WorkbenchTask, WorkbenchModelId, WorkbenchTranscriptEvent } from "@sandbox-agent/foundry-shared"; import { createBackendClient } from "../../src/backend-client.js"; const RUN_WORKBENCH_E2E = process.env.HF_ENABLE_DAEMON_WORKBENCH_E2E === "1"; @@ -32,14 +32,14 @@ async function sleep(ms: number): Promise { await new Promise((resolve) => setTimeout(resolve, ms)); } -async function seedSandboxFile(workspaceId: string, handoffId: string, filePath: string, content: string): Promise { - const repoPath = `/root/.local/share/openhandoff/local-sandboxes/${workspaceId}/${handoffId}/repo`; +async function seedSandboxFile(workspaceId: string, taskId: string, filePath: string, content: string): Promise { + const repoPath = `/root/.local/share/foundry/local-sandboxes/${workspaceId}/${taskId}/repo`; const script = [ `cd ${JSON.stringify(repoPath)}`, `mkdir -p ${JSON.stringify(filePath.includes("/") ? filePath.slice(0, filePath.lastIndexOf("/")) : ".")}`, `printf '%s\\n' ${JSON.stringify(content)} > ${JSON.stringify(filePath)}`, ].join(" && "); - await execFileAsync("docker", ["exec", "openhandoff-backend-1", "bash", "-lc", script]); + await execFileAsync("docker", ["exec", "foundry-backend-1", "bash", "-lc", script]); } async function poll(label: string, timeoutMs: number, intervalMs: number, fn: () => Promise, isDone: (value: T) => boolean): Promise { @@ -58,18 +58,18 @@ async function poll(label: string, timeoutMs: number, intervalMs: number, fn: } } -function findHandoff(snapshot: HandoffWorkbenchSnapshot, handoffId: string): WorkbenchHandoff { - const handoff = snapshot.handoffs.find((candidate) => candidate.id === handoffId); - if (!handoff) { - throw new Error(`handoff ${handoffId} missing from snapshot`); +function findTask(snapshot: TaskWorkbenchSnapshot, taskId: string): WorkbenchTask { + const task = snapshot.tasks.find((candidate) => candidate.id === taskId); + if (!task) { + throw new Error(`task ${taskId} missing from snapshot`); } - return handoff; + return task; } -function findTab(handoff: WorkbenchHandoff, tabId: string): WorkbenchAgentTab { - const tab = handoff.tabs.find((candidate) => candidate.id === tabId); +function findTab(task: WorkbenchTask, tabId: string): WorkbenchAgentTab { + const tab = task.tabs.find((candidate) => candidate.id === tabId); if (!tab) { - throw new Error(`tab ${tabId} missing from handoff ${handoff.id}`); + throw new Error(`tab ${tabId} missing from task ${task.id}`); } return tab; } @@ -144,7 +144,7 @@ function transcriptIncludesAgentText(transcript: WorkbenchTranscriptEvent[], exp } describe("e2e(client): workbench flows", () => { - it.skipIf(!RUN_WORKBENCH_E2E)("creates a handoff, adds sessions, exchanges messages, and manages workbench state", { timeout: 20 * 60_000 }, async () => { + it.skipIf(!RUN_WORKBENCH_E2E)("creates a task, adds sessions, exchanges messages, and manages workbench state", { timeout: 20 * 60_000 }, async () => { const endpoint = process.env.HF_E2E_BACKEND_ENDPOINT?.trim() || "http://127.0.0.1:7741/api/rivet"; const workspaceId = process.env.HF_E2E_WORKSPACE?.trim() || "default"; const repoRemote = requiredEnv("HF_E2E_GITHUB_REPO"); @@ -160,7 +160,7 @@ describe("e2e(client): workbench flows", () => { }); const repo = await client.addRepo(workspaceId, repoRemote); - const created = await client.createWorkbenchHandoff(workspaceId, { + const created = await client.createWorkbenchTask(workspaceId, { repoId: repo.repoId, title: `Workbench E2E ${runId}`, branch: `e2e/${runId}`, @@ -169,11 +169,11 @@ describe("e2e(client): workbench flows", () => { }); const provisioned = await poll( - "handoff provisioning", + "task provisioning", 12 * 60_000, 2_000, - async () => findHandoff(await client.getWorkbench(workspaceId), created.handoffId), - (handoff) => handoff.branch === `e2e/${runId}` && handoff.tabs.length > 0, + async () => findTask(await client.getWorkbench(workspaceId), created.taskId), + (task) => task.branch === `e2e/${runId}` && task.tabs.length > 0, ); const primaryTab = provisioned.tabs[0]!; @@ -182,50 +182,50 @@ describe("e2e(client): workbench flows", () => { "initial agent response", 12 * 60_000, 2_000, - async () => findHandoff(await client.getWorkbench(workspaceId), created.handoffId), - (handoff) => { - const tab = findTab(handoff, primaryTab.id); - return handoff.status === "idle" && tab.status === "idle" && transcriptIncludesAgentText(tab.transcript, expectedInitialReply); + async () => findTask(await client.getWorkbench(workspaceId), created.taskId), + (task) => { + const tab = findTab(task, primaryTab.id); + return task.status === "idle" && tab.status === "idle" && transcriptIncludesAgentText(tab.transcript, expectedInitialReply); }, ); expect(findTab(initialCompleted, primaryTab.id).sessionId).toBeTruthy(); expect(transcriptIncludesAgentText(findTab(initialCompleted, primaryTab.id).transcript, expectedInitialReply)).toBe(true); - await seedSandboxFile(workspaceId, created.handoffId, expectedFile, runId); + await seedSandboxFile(workspaceId, created.taskId, expectedFile, runId); const fileSeeded = await poll( "seeded sandbox file reflected in workbench", 30_000, 1_000, - async () => findHandoff(await client.getWorkbench(workspaceId), created.handoffId), - (handoff) => handoff.fileChanges.some((file) => file.path === expectedFile), + async () => findTask(await client.getWorkbench(workspaceId), created.taskId), + (task) => task.fileChanges.some((file) => file.path === expectedFile), ); expect(fileSeeded.fileChanges.some((file) => file.path === expectedFile)).toBe(true); - await client.renameWorkbenchHandoff(workspaceId, { - handoffId: created.handoffId, + await client.renameWorkbenchTask(workspaceId, { + taskId: created.taskId, value: `Workbench E2E ${runId} Renamed`, }); await client.renameWorkbenchSession(workspaceId, { - handoffId: created.handoffId, + taskId: created.taskId, tabId: primaryTab.id, title: "Primary Session", }); const secondTab = await client.createWorkbenchSession(workspaceId, { - handoffId: created.handoffId, + taskId: created.taskId, model, }); await client.renameWorkbenchSession(workspaceId, { - handoffId: created.handoffId, + taskId: created.taskId, tabId: secondTab.tabId, title: "Follow-up Session", }); await client.updateWorkbenchDraft(workspaceId, { - handoffId: created.handoffId, + taskId: created.taskId, tabId: secondTab.tabId, text: `Reply with exactly: ${expectedReply}`, attachments: [ @@ -238,12 +238,12 @@ describe("e2e(client): workbench flows", () => { ], }); - const drafted = findHandoff(await client.getWorkbench(workspaceId), created.handoffId); + const drafted = findTask(await client.getWorkbench(workspaceId), created.taskId); expect(findTab(drafted, secondTab.tabId).draft.text).toContain(expectedReply); expect(findTab(drafted, secondTab.tabId).draft.attachments).toHaveLength(1); await client.sendWorkbenchMessage(workspaceId, { - handoffId: created.handoffId, + taskId: created.taskId, tabId: secondTab.tabId, text: `Reply with exactly: ${expectedReply}`, attachments: [], @@ -253,9 +253,9 @@ describe("e2e(client): workbench flows", () => { "follow-up session response", 10 * 60_000, 2_000, - async () => findHandoff(await client.getWorkbench(workspaceId), created.handoffId), - (handoff) => { - const tab = findTab(handoff, secondTab.tabId); + async () => findTask(await client.getWorkbench(workspaceId), created.taskId), + (task) => { + const tab = findTab(task, secondTab.tabId); return tab.status === "idle" && transcriptIncludesAgentText(tab.transcript, expectedReply); }, ); @@ -264,17 +264,17 @@ describe("e2e(client): workbench flows", () => { expect(transcriptIncludesAgentText(secondTranscript, expectedReply)).toBe(true); await client.setWorkbenchSessionUnread(workspaceId, { - handoffId: created.handoffId, + taskId: created.taskId, tabId: secondTab.tabId, unread: false, }); - await client.markWorkbenchUnread(workspaceId, { handoffId: created.handoffId }); + await client.markWorkbenchUnread(workspaceId, { taskId: created.taskId }); - const unreadSnapshot = findHandoff(await client.getWorkbench(workspaceId), created.handoffId); + const unreadSnapshot = findTask(await client.getWorkbench(workspaceId), created.taskId); expect(unreadSnapshot.tabs.some((tab) => tab.unread)).toBe(true); await client.closeWorkbenchSession(workspaceId, { - handoffId: created.handoffId, + taskId: created.taskId, tabId: secondTab.tabId, }); @@ -282,13 +282,13 @@ describe("e2e(client): workbench flows", () => { "secondary session closed", 30_000, 1_000, - async () => findHandoff(await client.getWorkbench(workspaceId), created.handoffId), - (handoff) => !handoff.tabs.some((tab) => tab.id === secondTab.tabId), + async () => findTask(await client.getWorkbench(workspaceId), created.taskId), + (task) => !task.tabs.some((tab) => tab.id === secondTab.tabId), ); expect(closedSnapshot.tabs).toHaveLength(1); await client.revertWorkbenchFile(workspaceId, { - handoffId: created.handoffId, + taskId: created.taskId, path: expectedFile, }); @@ -296,8 +296,8 @@ describe("e2e(client): workbench flows", () => { "file revert reflected in workbench", 30_000, 1_000, - async () => findHandoff(await client.getWorkbench(workspaceId), created.handoffId), - (handoff) => !handoff.fileChanges.some((file) => file.path === expectedFile), + async () => findTask(await client.getWorkbench(workspaceId), created.taskId), + (task) => !task.fileChanges.some((file) => file.path === expectedFile), ); expect(revertedSnapshot.fileChanges.some((file) => file.path === expectedFile)).toBe(false); diff --git a/factory/packages/client/test/e2e/workbench-load-e2e.test.ts b/foundry/packages/client/test/e2e/workbench-load-e2e.test.ts similarity index 76% rename from factory/packages/client/test/e2e/workbench-load-e2e.test.ts rename to foundry/packages/client/test/e2e/workbench-load-e2e.test.ts index 11b0fc0..fa76be7 100644 --- a/factory/packages/client/test/e2e/workbench-load-e2e.test.ts +++ b/foundry/packages/client/test/e2e/workbench-load-e2e.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { HandoffWorkbenchSnapshot, WorkbenchAgentTab, WorkbenchHandoff, WorkbenchModelId, WorkbenchTranscriptEvent } from "@openhandoff/shared"; +import type { TaskWorkbenchSnapshot, WorkbenchAgentTab, WorkbenchTask, WorkbenchModelId, WorkbenchTranscriptEvent } from "@sandbox-agent/foundry-shared"; import { createBackendClient } from "../../src/backend-client.js"; const RUN_WORKBENCH_LOAD_E2E = process.env.HF_ENABLE_DAEMON_WORKBENCH_LOAD_E2E === "1"; @@ -54,18 +54,18 @@ async function poll(label: string, timeoutMs: number, intervalMs: number, fn: } } -function findHandoff(snapshot: HandoffWorkbenchSnapshot, handoffId: string): WorkbenchHandoff { - const handoff = snapshot.handoffs.find((candidate) => candidate.id === handoffId); - if (!handoff) { - throw new Error(`handoff ${handoffId} missing from snapshot`); +function findTask(snapshot: TaskWorkbenchSnapshot, taskId: string): WorkbenchTask { + const task = snapshot.tasks.find((candidate) => candidate.id === taskId); + if (!task) { + throw new Error(`task ${taskId} missing from snapshot`); } - return handoff; + return task; } -function findTab(handoff: WorkbenchHandoff, tabId: string): WorkbenchAgentTab { - const tab = handoff.tabs.find((candidate) => candidate.id === tabId); +function findTab(task: WorkbenchTask, tabId: string): WorkbenchAgentTab { + const tab = task.tabs.find((candidate) => candidate.id === tabId); if (!tab) { - throw new Error(`tab ${tabId} missing from handoff ${handoff.id}`); + throw new Error(`tab ${tabId} missing from task ${task.id}`); } return tab; } @@ -140,12 +140,12 @@ async function measureWorkbenchSnapshot( avgMs: number; maxMs: number; payloadBytes: number; - handoffCount: number; + taskCount: number; tabCount: number; transcriptEventCount: number; }> { const durations: number[] = []; - let snapshot: HandoffWorkbenchSnapshot | null = null; + let snapshot: TaskWorkbenchSnapshot | null = null; for (let index = 0; index < iterations; index += 1) { const startedAt = performance.now(); @@ -157,20 +157,17 @@ async function measureWorkbenchSnapshot( workspaceId, repos: [], projects: [], - handoffs: [], + tasks: [], }; const payloadBytes = Buffer.byteLength(JSON.stringify(finalSnapshot), "utf8"); - const tabCount = finalSnapshot.handoffs.reduce((sum, handoff) => sum + handoff.tabs.length, 0); - const transcriptEventCount = finalSnapshot.handoffs.reduce( - (sum, handoff) => sum + handoff.tabs.reduce((tabSum, tab) => tabSum + tab.transcript.length, 0), - 0, - ); + const tabCount = finalSnapshot.tasks.reduce((sum, task) => sum + task.tabs.length, 0); + const transcriptEventCount = finalSnapshot.tasks.reduce((sum, task) => sum + task.tabs.reduce((tabSum, tab) => tabSum + tab.transcript.length, 0), 0); return { avgMs: Math.round(average(durations)), maxMs: Math.round(Math.max(...durations, 0)), payloadBytes, - handoffCount: finalSnapshot.handoffs.length, + taskCount: finalSnapshot.tasks.length, tabCount, transcriptEventCount, }; @@ -182,7 +179,7 @@ describe("e2e(client): workbench load", () => { const workspaceId = process.env.HF_E2E_WORKSPACE?.trim() || "default"; const repoRemote = requiredEnv("HF_E2E_GITHUB_REPO"); const model = workbenchModelEnv("HF_E2E_MODEL", "gpt-4o"); - const handoffCount = intEnv("HF_LOAD_HANDOFF_COUNT", 3); + 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); @@ -192,12 +189,12 @@ describe("e2e(client): workbench load", () => { }); const repo = await client.addRepo(workspaceId, repoRemote); - const createHandoffLatencies: number[] = []; + const createTaskLatencies: number[] = []; const provisionLatencies: number[] = []; const createSessionLatencies: number[] = []; const messageRoundTripLatencies: number[] = []; const snapshotSeries: Array<{ - handoffCount: number; + taskCount: number; avgMs: number; maxMs: number; payloadBytes: number; @@ -207,29 +204,29 @@ describe("e2e(client): workbench load", () => { snapshotSeries.push(await measureWorkbenchSnapshot(client, workspaceId, 2)); - for (let handoffIndex = 0; handoffIndex < handoffCount; handoffIndex += 1) { - const runId = `load-${handoffIndex}-${Date.now().toString(36)}`; + 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.createWorkbenchHandoff(workspaceId, { + const created = await client.createWorkbenchTask(workspaceId, { repoId: repo.repoId, title: `Workbench Load ${runId}`, branch: `load/${runId}`, model, task: `Reply with exactly: ${initialReply}`, }); - createHandoffLatencies.push(performance.now() - createStartedAt); + createTaskLatencies.push(performance.now() - createStartedAt); const provisionStartedAt = performance.now(); const provisioned = await poll( - `handoff ${runId} provisioning`, + `task ${runId} provisioning`, 12 * 60_000, pollIntervalMs, - async () => findHandoff(await client.getWorkbench(workspaceId), created.handoffId), - (handoff) => { - const tab = handoff.tabs[0]; - return Boolean(tab && handoff.status === "idle" && tab.status === "idle" && transcriptIncludesAgentText(tab.transcript, initialReply)); + async () => findTask(await client.getWorkbench(workspaceId), created.taskId), + (task) => { + const tab = task.tabs[0]; + return Boolean(tab && task.status === "idle" && tab.status === "idle" && transcriptIncludesAgentText(tab.transcript, initialReply)); }, ); provisionLatencies.push(performance.now() - provisionStartedAt); @@ -242,13 +239,13 @@ describe("e2e(client): workbench load", () => { const expectedReply = `LOAD_REPLY_${runId}_${sessionIndex}`; const createSessionStartedAt = performance.now(); const createdSession = await client.createWorkbenchSession(workspaceId, { - handoffId: created.handoffId, + taskId: created.taskId, model, }); createSessionLatencies.push(performance.now() - createSessionStartedAt); await client.sendWorkbenchMessage(workspaceId, { - handoffId: created.handoffId, + taskId: created.taskId, tabId: createdSession.tabId, text: `Run pwd in the repo, then reply with exactly: ${expectedReply}`, attachments: [], @@ -256,12 +253,12 @@ describe("e2e(client): workbench load", () => { const messageStartedAt = performance.now(); const withReply = await poll( - `handoff ${runId} session ${sessionIndex} reply`, + `task ${runId} session ${sessionIndex} reply`, 10 * 60_000, pollIntervalMs, - async () => findHandoff(await client.getWorkbench(workspaceId), created.handoffId), - (handoff) => { - const tab = findTab(handoff, createdSession.tabId); + async () => findTask(await client.getWorkbench(workspaceId), created.taskId), + (task) => { + const tab = findTab(task, createdSession.tabId); return tab.status === "idle" && transcriptIncludesAgentText(tab.transcript, expectedReply); }, ); @@ -275,7 +272,7 @@ describe("e2e(client): workbench load", () => { console.info( "[workbench-load-snapshot]", JSON.stringify({ - handoffIndex: handoffIndex + 1, + taskIndex: taskIndex + 1, ...snapshotMetrics, }), ); @@ -284,9 +281,9 @@ describe("e2e(client): workbench load", () => { const firstSnapshot = snapshotSeries[0]!; const lastSnapshot = snapshotSeries[snapshotSeries.length - 1]!; const summary = { - handoffCount, + taskCount, extraSessionCount, - createHandoffAvgMs: Math.round(average(createHandoffLatencies)), + createTaskAvgMs: Math.round(average(createTaskLatencies)), provisionAvgMs: Math.round(average(provisionLatencies)), createSessionAvgMs: Math.round(average(createSessionLatencies)), messageRoundTripAvgMs: Math.round(average(messageRoundTripLatencies)), @@ -301,9 +298,9 @@ describe("e2e(client): workbench load", () => { console.info("[workbench-load-summary]", JSON.stringify(summary)); - expect(createHandoffLatencies.length).toBe(handoffCount); - expect(provisionLatencies.length).toBe(handoffCount); - expect(createSessionLatencies.length).toBe(handoffCount * extraSessionCount); - expect(messageRoundTripLatencies.length).toBe(handoffCount * extraSessionCount); + expect(createTaskLatencies.length).toBe(taskCount); + expect(provisionLatencies.length).toBe(taskCount); + expect(createSessionLatencies.length).toBe(taskCount * extraSessionCount); + expect(messageRoundTripLatencies.length).toBe(taskCount * extraSessionCount); }); }); diff --git a/factory/packages/client/test/keys.test.ts b/foundry/packages/client/test/keys.test.ts similarity index 62% rename from factory/packages/client/test/keys.test.ts rename to foundry/packages/client/test/keys.test.ts index c4f4c17..281d0a9 100644 --- a/factory/packages/client/test/keys.test.ts +++ b/foundry/packages/client/test/keys.test.ts @@ -1,26 +1,17 @@ import { describe, expect, it } from "vitest"; -import { - handoffKey, - handoffStatusSyncKey, - historyKey, - projectBranchSyncKey, - projectKey, - projectPrSyncKey, - sandboxInstanceKey, - workspaceKey, -} from "../src/keys.js"; +import { taskKey, taskStatusSyncKey, historyKey, projectBranchSyncKey, projectKey, projectPrSyncKey, sandboxInstanceKey, workspaceKey } from "../src/keys.js"; describe("actor keys", () => { it("prefixes every key with workspace namespace", () => { const keys = [ workspaceKey("default"), projectKey("default", "repo"), - handoffKey("default", "repo", "handoff"), + taskKey("default", "repo", "task"), sandboxInstanceKey("default", "daytona", "sbx"), historyKey("default", "repo"), projectPrSyncKey("default", "repo"), projectBranchSyncKey("default", "repo"), - handoffStatusSyncKey("default", "repo", "handoff", "sandbox-1", "session-1"), + taskStatusSyncKey("default", "repo", "task", "sandbox-1", "session-1"), ]; for (const key of keys) { diff --git a/factory/packages/client/test/view-model.test.ts b/foundry/packages/client/test/view-model.test.ts similarity index 71% rename from factory/packages/client/test/view-model.test.ts rename to foundry/packages/client/test/view-model.test.ts index 155f7df..d80b5f1 100644 --- a/factory/packages/client/test/view-model.test.ts +++ b/foundry/packages/client/test/view-model.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from "vitest"; -import type { HandoffRecord } from "@openhandoff/shared"; -import { filterHandoffs, formatRelativeAge, fuzzyMatch, summarizeHandoffs } from "../src/view-model.js"; +import type { TaskRecord } from "@sandbox-agent/foundry-shared"; +import { filterTasks, formatRelativeAge, fuzzyMatch, summarizeTasks } from "../src/view-model.js"; -const sample: HandoffRecord = { +const sample: TaskRecord = { workspaceId: "default", repoId: "repo-a", repoRemote: "https://example.com/repo-a.git", - handoffId: "handoff-1", + taskId: "task-1", branchName: "feature/test", title: "Test Title", task: "Do test", @@ -48,19 +48,19 @@ describe("search helpers", () => { }); it("filters rows across branch and title", () => { - const rows: HandoffRecord[] = [ + const rows: TaskRecord[] = [ sample, { ...sample, - handoffId: "handoff-2", + taskId: "task-2", branchName: "docs/update-intro", title: "Docs Intro Refresh", status: "idle", }, ]; - expect(filterHandoffs(rows, "doc")).toHaveLength(1); - expect(filterHandoffs(rows, "h2")).toHaveLength(1); - expect(filterHandoffs(rows, "test")).toHaveLength(2); + expect(filterTasks(rows, "doc")).toHaveLength(1); + expect(filterTasks(rows, "h2")).toHaveLength(1); + expect(filterTasks(rows, "test")).toHaveLength(2); }); }); @@ -71,13 +71,13 @@ describe("summary helpers", () => { }); it("summarizes by status and provider", () => { - const rows: HandoffRecord[] = [ + const rows: TaskRecord[] = [ sample, - { ...sample, handoffId: "handoff-2", status: "idle", providerId: "daytona" }, - { ...sample, handoffId: "handoff-3", status: "error", providerId: "daytona" }, + { ...sample, taskId: "task-2", status: "idle", providerId: "daytona" }, + { ...sample, taskId: "task-3", status: "error", providerId: "daytona" }, ]; - const summary = summarizeHandoffs(rows); + const summary = summarizeTasks(rows); expect(summary.total).toBe(3); expect(summary.byStatus.running).toBe(1); expect(summary.byStatus.idle).toBe(1); diff --git a/factory/packages/client/tsconfig.json b/foundry/packages/client/tsconfig.json similarity index 100% rename from factory/packages/client/tsconfig.json rename to foundry/packages/client/tsconfig.json diff --git a/factory/packages/frontend-errors/package.json b/foundry/packages/frontend-errors/package.json similarity index 93% rename from factory/packages/frontend-errors/package.json rename to foundry/packages/frontend-errors/package.json index c30f796..2104dbb 100644 --- a/factory/packages/frontend-errors/package.json +++ b/foundry/packages/frontend-errors/package.json @@ -1,5 +1,5 @@ { - "name": "@openhandoff/frontend-errors", + "name": "@sandbox-agent/foundry-frontend-errors", "version": "0.1.0", "private": true, "type": "module", diff --git a/factory/packages/frontend-errors/src/client.ts b/foundry/packages/frontend-errors/src/client.ts similarity index 70% rename from factory/packages/frontend-errors/src/client.ts rename to foundry/packages/frontend-errors/src/client.ts index 1945b17..3f0044c 100644 --- a/factory/packages/frontend-errors/src/client.ts +++ b/foundry/packages/frontend-errors/src/client.ts @@ -6,8 +6,8 @@ interface FrontendErrorCollectorGlobal { declare global { interface Window { - __OPENHANDOFF_FRONTEND_ERROR_COLLECTOR__?: FrontendErrorCollectorGlobal; - __OPENHANDOFF_FRONTEND_ERROR_CONTEXT__?: FrontendErrorContext; + __FOUNDRY_FRONTEND_ERROR_COLLECTOR__?: FrontendErrorCollectorGlobal; + __FOUNDRY_FRONTEND_ERROR_CONTEXT__?: FrontendErrorContext; } } @@ -17,11 +17,11 @@ export function setFrontendErrorContext(context: FrontendErrorContext): void { } const nextContext = sanitizeContext(context); - window.__OPENHANDOFF_FRONTEND_ERROR_CONTEXT__ = { - ...(window.__OPENHANDOFF_FRONTEND_ERROR_CONTEXT__ ?? {}), + window.__FOUNDRY_FRONTEND_ERROR_CONTEXT__ = { + ...(window.__FOUNDRY_FRONTEND_ERROR_CONTEXT__ ?? {}), ...nextContext, }; - window.__OPENHANDOFF_FRONTEND_ERROR_COLLECTOR__?.setContext(nextContext); + window.__FOUNDRY_FRONTEND_ERROR_COLLECTOR__?.setContext(nextContext); } function sanitizeContext(input: FrontendErrorContext): FrontendErrorContext { diff --git a/factory/packages/frontend-errors/src/index.ts b/foundry/packages/frontend-errors/src/index.ts similarity index 100% rename from factory/packages/frontend-errors/src/index.ts rename to foundry/packages/frontend-errors/src/index.ts diff --git a/factory/packages/frontend-errors/src/router.ts b/foundry/packages/frontend-errors/src/router.ts similarity index 98% rename from factory/packages/frontend-errors/src/router.ts rename to foundry/packages/frontend-errors/src/router.ts index 5981f91..8060304 100644 --- a/factory/packages/frontend-errors/src/router.ts +++ b/foundry/packages/frontend-errors/src/router.ts @@ -4,8 +4,8 @@ import { dirname, join, resolve } from "node:path"; import { Hono } from "hono"; import type { FrontendErrorContext, FrontendErrorKind, FrontendErrorLogEvent } from "./types.js"; -const DEFAULT_RELATIVE_LOG_PATH = ".openhandoff/logs/frontend-errors.ndjson"; -const DEFAULT_REPORTER = "openhandoff-frontend"; +const DEFAULT_RELATIVE_LOG_PATH = ".foundry/logs/frontend-errors.ndjson"; +const DEFAULT_REPORTER = "foundry-frontend"; const MAX_FIELD_LENGTH = 12_000; export interface FrontendErrorCollectorRouterOptions { diff --git a/factory/packages/frontend-errors/src/script.ts b/foundry/packages/frontend-errors/src/script.ts similarity index 95% rename from factory/packages/frontend-errors/src/script.ts rename to foundry/packages/frontend-errors/src/script.ts index 62bac6a..a2149b3 100644 --- a/factory/packages/frontend-errors/src/script.ts +++ b/foundry/packages/frontend-errors/src/script.ts @@ -1,6 +1,6 @@ import type { FrontendErrorCollectorScriptOptions } from "./types.js"; -const DEFAULT_REPORTER = "openhandoff-frontend"; +const DEFAULT_REPORTER = "foundry-frontend"; export function createFrontendErrorCollectorScript(options: FrontendErrorCollectorScriptOptions): string { const config = { @@ -15,13 +15,13 @@ export function createFrontendErrorCollectorScript(options: FrontendErrorCollect return; } - if (window.__OPENHANDOFF_FRONTEND_ERROR_COLLECTOR__) { + if (window.__FOUNDRY_FRONTEND_ERROR_COLLECTOR__) { return; } var config = ${JSON.stringify(config)}; - var sharedContext = window.__OPENHANDOFF_FRONTEND_ERROR_CONTEXT__ || {}; - window.__OPENHANDOFF_FRONTEND_ERROR_CONTEXT__ = sharedContext; + var sharedContext = window.__FOUNDRY_FRONTEND_ERROR_CONTEXT__ || {}; + window.__FOUNDRY_FRONTEND_ERROR_CONTEXT__ = sharedContext; function now() { return Date.now(); @@ -122,7 +122,7 @@ export function createFrontendErrorCollectorScript(options: FrontendErrorCollect }); } - window.__OPENHANDOFF_FRONTEND_ERROR_COLLECTOR__ = { + window.__FOUNDRY_FRONTEND_ERROR_COLLECTOR__ = { setContext: function (nextContext) { if (!nextContext || typeof nextContext !== "object") { return; diff --git a/factory/packages/frontend-errors/src/types.ts b/foundry/packages/frontend-errors/src/types.ts similarity index 98% rename from factory/packages/frontend-errors/src/types.ts rename to foundry/packages/frontend-errors/src/types.ts index 6b83b9d..dc7dc61 100644 --- a/factory/packages/frontend-errors/src/types.ts +++ b/foundry/packages/frontend-errors/src/types.ts @@ -3,7 +3,7 @@ export type FrontendErrorKind = "window-error" | "resource-error" | "unhandled-r export interface FrontendErrorContext { route?: string; workspaceId?: string; - handoffId?: string; + taskId?: string; [key: string]: string | number | boolean | null | undefined; } diff --git a/factory/packages/frontend-errors/src/vite.ts b/foundry/packages/frontend-errors/src/vite.ts similarity index 92% rename from factory/packages/frontend-errors/src/vite.ts rename to foundry/packages/frontend-errors/src/vite.ts index af36063..88fea63 100644 --- a/factory/packages/frontend-errors/src/vite.ts +++ b/foundry/packages/frontend-errors/src/vite.ts @@ -4,7 +4,7 @@ import type { Plugin } from "vite"; import { createFrontendErrorCollectorRouter, defaultFrontendErrorLogPath } from "./router.js"; import { createFrontendErrorCollectorScript } from "./script.js"; -const DEFAULT_MOUNT_PATH = "/__openhandoff/frontend-errors"; +const DEFAULT_MOUNT_PATH = "/__foundry/frontend-errors"; const DEFAULT_EVENT_PATH = "/events"; export interface FrontendErrorCollectorVitePluginOptions { @@ -18,7 +18,7 @@ export interface FrontendErrorCollectorVitePluginOptions { export function frontendErrorCollectorVitePlugin(options: FrontendErrorCollectorVitePluginOptions = {}): Plugin { const mountPath = normalizePath(options.mountPath ?? DEFAULT_MOUNT_PATH); const logFilePath = options.logFilePath ?? defaultFrontendErrorLogPath(process.cwd()); - const reporter = options.reporter ?? "openhandoff-vite"; + const reporter = options.reporter ?? "foundry-vite"; const endpoint = `${mountPath}${DEFAULT_EVENT_PATH}`; const router = createFrontendErrorCollectorRouter({ @@ -29,7 +29,7 @@ export function frontendErrorCollectorVitePlugin(options: FrontendErrorCollector const listener = getRequestListener(mountApp.fetch); return { - name: "openhandoff:frontend-error-collector", + name: "foundry:frontend-error-collector", apply: "serve", transformIndexHtml(html) { return { diff --git a/factory/packages/frontend-errors/test/router.test.ts b/foundry/packages/frontend-errors/test/router.test.ts similarity index 93% rename from factory/packages/frontend-errors/test/router.test.ts rename to foundry/packages/frontend-errors/test/router.test.ts index a9c5236..b246d52 100644 --- a/factory/packages/frontend-errors/test/router.test.ts +++ b/foundry/packages/frontend-errors/test/router.test.ts @@ -47,9 +47,9 @@ describe("frontend error collector router", () => { describe("frontend error collector script", () => { test("embeds configured endpoint", () => { const script = createFrontendErrorCollectorScript({ - endpoint: "/__openhandoff/frontend-errors/events", + endpoint: "/__foundry/frontend-errors/events", }); - expect(script).toContain("/__openhandoff/frontend-errors/events"); + expect(script).toContain("/__foundry/frontend-errors/events"); expect(script).toContain('window.addEventListener("error"'); }); }); diff --git a/factory/packages/frontend-errors/tsconfig.json b/foundry/packages/frontend-errors/tsconfig.json similarity index 100% rename from factory/packages/frontend-errors/tsconfig.json rename to foundry/packages/frontend-errors/tsconfig.json diff --git a/factory/packages/frontend-errors/vitest.config.ts b/foundry/packages/frontend-errors/vitest.config.ts similarity index 100% rename from factory/packages/frontend-errors/vitest.config.ts rename to foundry/packages/frontend-errors/vitest.config.ts diff --git a/factory/packages/frontend/index.html b/foundry/packages/frontend/index.html similarity index 97% rename from factory/packages/frontend/index.html rename to foundry/packages/frontend/index.html index 5b71cce..51877e4 100644 --- a/factory/packages/frontend/index.html +++ b/foundry/packages/frontend/index.html @@ -1,6 +1,7 @@ + diff --git a/factory/packages/frontend/package.json b/foundry/packages/frontend/package.json similarity index 79% rename from factory/packages/frontend/package.json rename to foundry/packages/frontend/package.json index ef01d9a..7901ef4 100644 --- a/factory/packages/frontend/package.json +++ b/foundry/packages/frontend/package.json @@ -1,5 +1,5 @@ { - "name": "@openhandoff/frontend", + "name": "@sandbox-agent/foundry-frontend", "version": "0.1.0", "private": true, "type": "module", @@ -11,9 +11,9 @@ }, "dependencies": { "@sandbox-agent/react": "workspace:*", - "@openhandoff/client": "workspace:*", - "@openhandoff/frontend-errors": "workspace:*", - "@openhandoff/shared": "workspace:*", + "@sandbox-agent/foundry-client": "workspace:*", + "@sandbox-agent/foundry-frontend-errors": "workspace:*", + "@sandbox-agent/foundry-shared": "workspace:*", "@tanstack/react-query": "^5.85.5", "@tanstack/react-router": "^1.132.23", "baseui": "^16.1.1", diff --git a/factory/packages/frontend/public/favicon.svg b/foundry/packages/frontend/public/favicon.svg similarity index 100% rename from factory/packages/frontend/public/favicon.svg rename to foundry/packages/frontend/public/favicon.svg diff --git a/foundry/packages/frontend/src/app/router.tsx b/foundry/packages/frontend/src/app/router.tsx new file mode 100644 index 0000000..1d6cd64 --- /dev/null +++ b/foundry/packages/frontend/src/app/router.tsx @@ -0,0 +1,342 @@ +import { type ReactNode, useEffect } from "react"; +import { setFrontendErrorContext } from "@sandbox-agent/foundry-frontend-errors/client"; +import type { FoundryBillingPlanId } from "@sandbox-agent/foundry-shared"; +import { Navigate, Outlet, createRootRoute, createRoute, createRouter, useRouterState } from "@tanstack/react-router"; +import { MockLayout } from "../components/mock-layout"; +import { + MockHostedCheckoutPage, + MockOrganizationBillingPage, + MockOrganizationSelectorPage, + MockOrganizationSettingsPage, + MockSignInPage, +} from "../components/mock-onboarding"; +import { defaultWorkspaceId, isMockFrontendClient } from "../lib/env"; +import { activeMockOrganization, getMockOrganizationById, isAppSnapshotBootstrapping, useMockAppClient, useMockAppSnapshot } from "../lib/mock-app"; +import { taskWorkbenchClient } from "../lib/workbench"; + +const rootRoute = createRootRoute({ + component: RootLayout, +}); + +const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/", + component: IndexRoute, +}); + +const signInRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/signin", + component: SignInRoute, +}); + +const organizationsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/organizations", + component: OrganizationsRoute, +}); + +const organizationSettingsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/organizations/$organizationId/settings", + component: OrganizationSettingsRoute, +}); + +const organizationBillingRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/organizations/$organizationId/billing", + component: OrganizationBillingRoute, +}); + +const organizationCheckoutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/organizations/$organizationId/checkout/$planId", + component: OrganizationCheckoutRoute, +}); + +const workspaceRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/workspaces/$workspaceId", + component: WorkspaceLayoutRoute, +}); + +const workspaceIndexRoute = createRoute({ + getParentRoute: () => workspaceRoute, + path: "/", + component: WorkspaceRoute, +}); + +const taskRoute = createRoute({ + getParentRoute: () => workspaceRoute, + path: "tasks/$taskId", + validateSearch: (search: Record) => ({ + sessionId: typeof search.sessionId === "string" && search.sessionId.trim().length > 0 ? search.sessionId : undefined, + }), + component: TaskRoute, +}); + +const repoRoute = createRoute({ + getParentRoute: () => workspaceRoute, + path: "repos/$repoId", + component: RepoRoute, +}); + +const routeTree = rootRoute.addChildren([ + indexRoute, + signInRoute, + organizationsRoute, + organizationSettingsRoute, + organizationBillingRoute, + organizationCheckoutRoute, + workspaceRoute.addChildren([workspaceIndexRoute, taskRoute, repoRoute]), +]); + +export const router = createRouter({ routeTree }); + +declare module "@tanstack/react-router" { + interface Register { + router: typeof router; + } +} + +function WorkspaceLayoutRoute() { + return ; +} + +function AppLoadingScreen({ label }: { label: string }) { + return ( +
+ {label} +
+ ); +} + +function IndexRoute() { + const snapshot = useMockAppSnapshot(); + if (!isMockFrontendClient && isAppSnapshotBootstrapping(snapshot)) { + return ; + } + if (snapshot.auth.status === "signed_out") { + return ; + } + + const activeOrganization = activeMockOrganization(snapshot); + if (activeOrganization) { + return ; + } + + return ; +} + +function SignInRoute() { + const snapshot = useMockAppSnapshot(); + if (!isMockFrontendClient && isAppSnapshotBootstrapping(snapshot)) { + return ; + } + if (snapshot.auth.status === "signed_in") { + return ; + } + + return ; +} + +function OrganizationsRoute() { + const snapshot = useMockAppSnapshot(); + if (!isMockFrontendClient && isAppSnapshotBootstrapping(snapshot)) { + return ; + } + if (snapshot.auth.status === "signed_out") { + return ; + } + + return ; +} + +function OrganizationSettingsRoute() { + const snapshot = useMockAppSnapshot(); + if (!isMockFrontendClient && isAppSnapshotBootstrapping(snapshot)) { + return ; + } + if (snapshot.auth.status === "signed_out") { + return ; + } + + const { organizationId } = organizationSettingsRoute.useParams(); + const organization = getMockOrganizationById(snapshot, organizationId); + if (!organization) { + return ; + } + + return ; +} + +function OrganizationBillingRoute() { + const snapshot = useMockAppSnapshot(); + if (!isMockFrontendClient && isAppSnapshotBootstrapping(snapshot)) { + return ; + } + if (snapshot.auth.status === "signed_out") { + return ; + } + + const { organizationId } = organizationBillingRoute.useParams(); + const organization = getMockOrganizationById(snapshot, organizationId); + if (!organization) { + return ; + } + + return ; +} + +function OrganizationCheckoutRoute() { + const snapshot = useMockAppSnapshot(); + if (!isMockFrontendClient && isAppSnapshotBootstrapping(snapshot)) { + return ; + } + if (snapshot.auth.status === "signed_out") { + return ; + } + + const { organizationId, planId } = organizationCheckoutRoute.useParams(); + const organization = getMockOrganizationById(snapshot, organizationId); + if (!organization) { + return ; + } + + return ; +} + +function WorkspaceRoute() { + const { workspaceId } = workspaceRoute.useParams(); + return ( + + + + ); +} + +function WorkspaceView({ + workspaceId, + selectedTaskId, + selectedSessionId, +}: { + workspaceId: string; + selectedTaskId: string | null; + selectedSessionId: string | null; +}) { + useEffect(() => { + setFrontendErrorContext({ + workspaceId, + taskId: undefined, + }); + }, [workspaceId]); + + return ; +} + +function TaskRoute() { + const { workspaceId, taskId } = taskRoute.useParams(); + const { sessionId } = taskRoute.useSearch(); + return ( + + + + ); +} + +function TaskView({ workspaceId, taskId, sessionId }: { workspaceId: string; taskId: string; sessionId: string | null }) { + useEffect(() => { + setFrontendErrorContext({ + workspaceId, + taskId, + repoId: undefined, + }); + }, [taskId, workspaceId]); + + return ; +} + +function RepoRoute() { + const { workspaceId, repoId } = repoRoute.useParams(); + return ( + + + + ); +} + +function AppWorkspaceGate({ workspaceId, children }: { workspaceId: string; children: ReactNode }) { + const client = useMockAppClient(); + const snapshot = useMockAppSnapshot(); + const organization = snapshot.organizations.find((candidate) => candidate.workspaceId === workspaceId) ?? null; + + useEffect(() => { + if (organization && snapshot.activeOrganizationId !== organization.id) { + void client.selectOrganization(organization.id); + } + }, [client, organization, snapshot.activeOrganizationId]); + + if (!isMockFrontendClient && isAppSnapshotBootstrapping(snapshot)) { + return ; + } + + if (snapshot.auth.status === "signed_out") { + return ; + } + + if (!organization) { + return isMockFrontendClient ? : ; + } + + return <>{children}; +} + +function RepoRouteInner({ workspaceId, repoId }: { workspaceId: string; repoId: string }) { + useEffect(() => { + setFrontendErrorContext({ + workspaceId, + taskId: undefined, + repoId, + }); + }, [repoId, workspaceId]); + const activeTaskId = taskWorkbenchClient.getSnapshot().tasks.find((task) => task.repoId === repoId)?.id; + if (!activeTaskId) { + return ; + } + return ; +} + +function RootLayout() { + return ( + <> + + + + ); +} + +function RouteContextSync() { + const location = useRouterState({ + select: (state) => state.location, + }); + + useEffect(() => { + setFrontendErrorContext({ + route: `${location.pathname}${location.search}${location.hash}`, + }); + }, [location.hash, location.pathname, location.search]); + + return null; +} diff --git a/factory/packages/frontend/src/app/theme.ts b/foundry/packages/frontend/src/app/theme.ts similarity index 100% rename from factory/packages/frontend/src/app/theme.ts rename to foundry/packages/frontend/src/app/theme.ts diff --git a/factory/packages/frontend/src/components/mock-layout.tsx b/foundry/packages/frontend/src/components/mock-layout.tsx similarity index 66% rename from factory/packages/frontend/src/components/mock-layout.tsx rename to foundry/packages/frontend/src/components/mock-layout.tsx index 19768dd..e43b0b3 100644 --- a/factory/packages/frontend/src/components/mock-layout.tsx +++ b/foundry/packages/frontend/src/components/mock-layout.tsx @@ -18,40 +18,38 @@ import { diffTabId, formatThinkingDuration, isDiffTab, - type Handoff, + type Task, type HistoryEvent, type LineAttachment, type Message, type ModelId, } from "./mock-layout/view-model"; -import { backendClient } from "../lib/backend"; -import { handoffWorkbenchClient } from "../lib/workbench"; +import { activeMockOrganization, useMockAppSnapshot } from "../lib/mock-app"; +import { taskWorkbenchClient } from "../lib/workbench"; -const STAR_SANDBOX_AGENT_REPO_STORAGE_KEY = "hf.onboarding.starSandboxAgentRepo"; - -function firstAgentTabId(handoff: Handoff): string | null { - return handoff.tabs[0]?.id ?? null; +function firstAgentTabId(task: Task): string | null { + return task.tabs[0]?.id ?? null; } -function sanitizeOpenDiffs(handoff: Handoff, paths: string[] | undefined): string[] { +function sanitizeOpenDiffs(task: Task, paths: string[] | undefined): string[] { if (!paths) { return []; } - return paths.filter((path) => handoff.diffs[path] != null); + return paths.filter((path) => task.diffs[path] != null); } -function sanitizeLastAgentTabId(handoff: Handoff, tabId: string | null | undefined): string | null { - if (tabId && handoff.tabs.some((tab) => tab.id === tabId)) { +function sanitizeLastAgentTabId(task: Task, tabId: string | null | undefined): string | null { + if (tabId && task.tabs.some((tab) => tab.id === tabId)) { return tabId; } - return firstAgentTabId(handoff); + return firstAgentTabId(task); } -function sanitizeActiveTabId(handoff: Handoff, tabId: string | null | undefined, openDiffs: string[], lastAgentTabId: string | null): string | null { +function sanitizeActiveTabId(task: Task, tabId: string | null | undefined, openDiffs: string[], lastAgentTabId: string | null): string | null { if (tabId) { - if (handoff.tabs.some((tab) => tab.id === tabId)) { + if (task.tabs.some((tab) => tab.id === tabId)) { return tabId; } if (isDiffTab(tabId) && openDiffs.includes(diffPath(tabId))) { @@ -63,7 +61,7 @@ function sanitizeActiveTabId(handoff: Handoff, tabId: string | null | undefined, } const TranscriptPanel = memo(function TranscriptPanel({ - handoff, + task, activeTabId, lastAgentTabId, openDiffs, @@ -72,11 +70,11 @@ const TranscriptPanel = memo(function TranscriptPanel({ onSetLastAgentTabId, onSetOpenDiffs, }: { - handoff: Handoff; + task: Task; activeTabId: string | null; lastAgentTabId: string | null; openDiffs: string[]; - onSyncRouteSession: (handoffId: string, sessionId: string | null, replace?: boolean) => void; + onSyncRouteSession: (taskId: string, sessionId: string | null, replace?: boolean) => void; onSetActiveTabId: (tabId: string | null) => void; onSetLastAgentTabId: (tabId: string | null) => void; onSetOpenDiffs: (paths: string[]) => void; @@ -93,10 +91,10 @@ const TranscriptPanel = memo(function TranscriptPanel({ const textareaRef = useRef(null); const messageRefs = useRef(new Map()); const activeDiff = activeTabId && isDiffTab(activeTabId) ? diffPath(activeTabId) : null; - const activeAgentTab = activeDiff ? null : (handoff.tabs.find((candidate) => candidate.id === activeTabId) ?? handoff.tabs[0] ?? null); - const promptTab = handoff.tabs.find((candidate) => candidate.id === lastAgentTabId) ?? handoff.tabs[0] ?? null; - const isTerminal = handoff.status === "archived"; - const historyEvents = useMemo(() => buildHistoryEvents(handoff.tabs), [handoff.tabs]); + const activeAgentTab = activeDiff ? null : (task.tabs.find((candidate) => candidate.id === activeTabId) ?? task.tabs[0] ?? null); + const promptTab = task.tabs.find((candidate) => candidate.id === lastAgentTabId) ?? task.tabs[0] ?? null; + const isTerminal = task.status === "archived"; + const historyEvents = useMemo(() => buildHistoryEvents(task.tabs), [task.tabs]); const activeMessages = useMemo(() => buildDisplayMessages(activeAgentTab), [activeAgentTab]); const draft = promptTab?.draft.text ?? ""; const attachments = promptTab?.draft.attachments ?? []; @@ -109,12 +107,12 @@ const TranscriptPanel = memo(function TranscriptPanel({ useEffect(() => { textareaRef.current?.focus(); - }, [activeTabId, handoff.id]); + }, [activeTabId, task.id]); useEffect(() => { setEditingSessionTabId(null); setEditingSessionName(""); - }, [handoff.id]); + }, [task.id]); useLayoutEffect(() => { const textarea = textareaRef.current; @@ -126,7 +124,7 @@ const TranscriptPanel = memo(function TranscriptPanel({ const nextHeight = Math.min(textarea.scrollHeight, PROMPT_TEXTAREA_MAX_HEIGHT); textarea.style.height = `${Math.max(PROMPT_TEXTAREA_MIN_HEIGHT, nextHeight)}px`; textarea.style.overflowY = textarea.scrollHeight > PROMPT_TEXTAREA_MAX_HEIGHT ? "auto" : "hidden"; - }, [draft, activeTabId, handoff.id]); + }, [draft, activeTabId, task.id]); useEffect(() => { if (!pendingHistoryTarget || activeTabId !== pendingHistoryTarget.tabId) { @@ -172,12 +170,12 @@ const TranscriptPanel = memo(function TranscriptPanel({ return; } - void handoffWorkbenchClient.setSessionUnread({ - handoffId: handoff.id, + void taskWorkbenchClient.setSessionUnread({ + taskId: task.id, tabId: activeAgentTab.id, unread: false, }); - }, [activeAgentTab?.id, activeAgentTab?.unread, handoff.id]); + }, [activeAgentTab?.id, activeAgentTab?.unread, task.id]); const startEditingField = useCallback((field: "title" | "branch", value: string) => { setEditingField(field); @@ -197,13 +195,13 @@ const TranscriptPanel = memo(function TranscriptPanel({ } if (field === "title") { - void handoffWorkbenchClient.renameHandoff({ handoffId: handoff.id, value }); + void taskWorkbenchClient.renameTask({ taskId: task.id, value }); } else { - void handoffWorkbenchClient.renameBranch({ handoffId: handoff.id, value }); + void taskWorkbenchClient.renameBranch({ taskId: task.id, value }); } setEditingField(null); }, - [editValue, handoff.id], + [editValue, task.id], ); const updateDraft = useCallback( @@ -212,14 +210,14 @@ const TranscriptPanel = memo(function TranscriptPanel({ return; } - void handoffWorkbenchClient.updateDraft({ - handoffId: handoff.id, + void taskWorkbenchClient.updateDraft({ + taskId: task.id, tabId: promptTab.id, text: nextText, attachments: nextAttachments, }); }, - [handoff.id, promptTab], + [task.id, promptTab], ); const sendMessage = useCallback(() => { @@ -230,24 +228,24 @@ const TranscriptPanel = memo(function TranscriptPanel({ onSetActiveTabId(promptTab.id); onSetLastAgentTabId(promptTab.id); - void handoffWorkbenchClient.sendMessage({ - handoffId: handoff.id, + void taskWorkbenchClient.sendMessage({ + taskId: task.id, tabId: promptTab.id, text, attachments, }); - }, [attachments, draft, handoff.id, onSetActiveTabId, onSetLastAgentTabId, promptTab]); + }, [attachments, draft, task.id, onSetActiveTabId, onSetLastAgentTabId, promptTab]); const stopAgent = useCallback(() => { if (!promptTab) { return; } - void handoffWorkbenchClient.stopAgent({ - handoffId: handoff.id, + void taskWorkbenchClient.stopAgent({ + taskId: task.id, tabId: promptTab.id, }); - }, [handoff.id, promptTab]); + }, [task.id, promptTab]); const switchTab = useCallback( (tabId: string) => { @@ -255,30 +253,30 @@ const TranscriptPanel = memo(function TranscriptPanel({ if (!isDiffTab(tabId)) { onSetLastAgentTabId(tabId); - const tab = handoff.tabs.find((candidate) => candidate.id === tabId); + const tab = task.tabs.find((candidate) => candidate.id === tabId); if (tab?.unread) { - void handoffWorkbenchClient.setSessionUnread({ - handoffId: handoff.id, + void taskWorkbenchClient.setSessionUnread({ + taskId: task.id, tabId, unread: false, }); } - onSyncRouteSession(handoff.id, tabId); + onSyncRouteSession(task.id, tabId); } }, - [handoff.id, handoff.tabs, onSetActiveTabId, onSetLastAgentTabId, onSyncRouteSession], + [task.id, task.tabs, onSetActiveTabId, onSetLastAgentTabId, onSyncRouteSession], ); const setTabUnread = useCallback( (tabId: string, unread: boolean) => { - void handoffWorkbenchClient.setSessionUnread({ handoffId: handoff.id, tabId, unread }); + void taskWorkbenchClient.setSessionUnread({ taskId: task.id, tabId, unread }); }, - [handoff.id], + [task.id], ); const startRenamingTab = useCallback( (tabId: string) => { - const targetTab = handoff.tabs.find((candidate) => candidate.id === tabId); + const targetTab = task.tabs.find((candidate) => candidate.id === tabId); if (!targetTab) { throw new Error(`Unable to rename missing session tab ${tabId}`); } @@ -286,7 +284,7 @@ const TranscriptPanel = memo(function TranscriptPanel({ setEditingSessionTabId(tabId); setEditingSessionName(targetTab.sessionName); }, - [handoff.tabs], + [task.tabs], ); const cancelTabRename = useCallback(() => { @@ -305,17 +303,17 @@ const TranscriptPanel = memo(function TranscriptPanel({ return; } - void handoffWorkbenchClient.renameSession({ - handoffId: handoff.id, + void taskWorkbenchClient.renameSession({ + taskId: task.id, tabId: editingSessionTabId, title: trimmedName, }); cancelTabRename(); - }, [cancelTabRename, editingSessionName, editingSessionTabId, handoff.id]); + }, [cancelTabRename, editingSessionName, editingSessionTabId, task.id]); const closeTab = useCallback( (tabId: string) => { - const remainingTabs = handoff.tabs.filter((candidate) => candidate.id !== tabId); + const remainingTabs = task.tabs.filter((candidate) => candidate.id !== tabId); const nextTabId = remainingTabs[0]?.id ?? null; if (activeTabId === tabId) { @@ -325,10 +323,10 @@ const TranscriptPanel = memo(function TranscriptPanel({ onSetLastAgentTabId(nextTabId); } - onSyncRouteSession(handoff.id, nextTabId); - void handoffWorkbenchClient.closeTab({ handoffId: handoff.id, tabId }); + onSyncRouteSession(task.id, nextTabId); + void taskWorkbenchClient.closeTab({ taskId: task.id, tabId }); }, - [activeTabId, handoff.id, handoff.tabs, lastAgentTabId, onSetActiveTabId, onSetLastAgentTabId, onSyncRouteSession], + [activeTabId, task.id, task.tabs, lastAgentTabId, onSetActiveTabId, onSetLastAgentTabId, onSyncRouteSession], ); const closeDiffTab = useCallback( @@ -336,34 +334,34 @@ const TranscriptPanel = memo(function TranscriptPanel({ const nextOpenDiffs = openDiffs.filter((candidate) => candidate !== path); onSetOpenDiffs(nextOpenDiffs); if (activeTabId === diffTabId(path)) { - onSetActiveTabId(nextOpenDiffs.length > 0 ? diffTabId(nextOpenDiffs[nextOpenDiffs.length - 1]!) : (lastAgentTabId ?? firstAgentTabId(handoff))); + onSetActiveTabId(nextOpenDiffs.length > 0 ? diffTabId(nextOpenDiffs[nextOpenDiffs.length - 1]!) : (lastAgentTabId ?? firstAgentTabId(task))); } }, - [activeTabId, handoff, lastAgentTabId, onSetActiveTabId, onSetOpenDiffs, openDiffs], + [activeTabId, task, lastAgentTabId, onSetActiveTabId, onSetOpenDiffs, openDiffs], ); const addTab = useCallback(() => { void (async () => { - const { tabId } = await handoffWorkbenchClient.addTab({ handoffId: handoff.id }); + const { tabId } = await taskWorkbenchClient.addTab({ taskId: task.id }); onSetLastAgentTabId(tabId); onSetActiveTabId(tabId); - onSyncRouteSession(handoff.id, tabId); + onSyncRouteSession(task.id, tabId); })(); - }, [handoff.id, onSetActiveTabId, onSetLastAgentTabId, onSyncRouteSession]); + }, [task.id, onSetActiveTabId, onSetLastAgentTabId, onSyncRouteSession]); const changeModel = useCallback( (model: ModelId) => { if (!promptTab) { - throw new Error(`Unable to change model for task ${handoff.id} without an active prompt tab`); + throw new Error(`Unable to change model for task ${task.id} without an active prompt tab`); } - void handoffWorkbenchClient.changeModel({ - handoffId: handoff.id, + void taskWorkbenchClient.changeModel({ + taskId: task.id, tabId: promptTab.id, model, }); }, - [handoff.id, promptTab], + [task.id, promptTab], ); const addAttachment = useCallback( @@ -431,7 +429,7 @@ const TranscriptPanel = memo(function TranscriptPanel({ return ( file.path === activeDiff)} - diff={handoff.diffs[activeDiff]} + file={task.fileChanges.find((file) => file.path === activeDiff)} + diff={task.diffs[activeDiff]} onAddAttachment={addAttachment} /> - ) : handoff.tabs.length === 0 ? ( + ) : task.tabs.length === 0 ? (
void; onArchive: () => void; @@ -731,7 +729,7 @@ const RightRail = memo(function RightRail({ })} > - +
); @@ -779,18 +777,93 @@ const RightRail = memo(function RightRail({ interface MockLayoutProps { workspaceId: string; - selectedHandoffId?: string | null; + selectedTaskId?: string | null; selectedSessionId?: string | null; } -export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId }: MockLayoutProps) { +function MockWorkspaceOrgBar() { + const navigate = useNavigate(); + const snapshot = useMockAppSnapshot(); + const organization = activeMockOrganization(snapshot); + + if (!organization) { + return null; + } + + const buttonStyle = { + border: "1px solid rgba(255,255,255,0.12)", + borderRadius: "999px", + padding: "8px 12px", + background: "rgba(255,255,255,0.03)", + color: "rgba(255,255,255,0.86)", + cursor: "pointer", + fontSize: "13px", + fontWeight: 600, + } satisfies React.CSSProperties; + + return ( +
+
+ {organization.settings.displayName} + {organization.settings.primaryDomain} +
+
+ + + +
+
+ ); +} + +export function MockLayout({ workspaceId, selectedTaskId, selectedSessionId }: MockLayoutProps) { const navigate = useNavigate(); const viewModel = useSyncExternalStore( - handoffWorkbenchClient.subscribe.bind(handoffWorkbenchClient), - handoffWorkbenchClient.getSnapshot.bind(handoffWorkbenchClient), - handoffWorkbenchClient.getSnapshot.bind(handoffWorkbenchClient), + taskWorkbenchClient.subscribe.bind(taskWorkbenchClient), + taskWorkbenchClient.getSnapshot.bind(taskWorkbenchClient), + taskWorkbenchClient.getSnapshot.bind(taskWorkbenchClient), ); - const handoffs = viewModel.handoffs ?? []; + const tasks = viewModel.tasks ?? []; const rawProjects = viewModel.projects ?? []; const [projectOrder, setProjectOrder] = useState(null); const projects = useMemo(() => { @@ -811,12 +884,9 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId } }, [projects], ); - const [activeTabIdByHandoff, setActiveTabIdByHandoff] = useState>({}); - const [lastAgentTabIdByHandoff, setLastAgentTabIdByHandoff] = useState>({}); - const [openDiffsByHandoff, setOpenDiffsByHandoff] = useState>({}); - const [starRepoPromptOpen, setStarRepoPromptOpen] = useState(false); - const [starRepoPending, setStarRepoPending] = useState(false); - const [starRepoError, setStarRepoError] = useState(null); + const [activeTabIdByTask, setActiveTabIdByTask] = useState>({}); + const [lastAgentTabIdByTask, setLastAgentTabIdByTask] = useState>({}); + const [openDiffsByTask, setOpenDiffsByTask] = useState>({}); const [leftWidth, setLeftWidth] = useState(() => readStoredWidth(LEFT_WIDTH_STORAGE_KEY, LEFT_SIDEBAR_DEFAULT_WIDTH)); const [rightWidth, setRightWidth] = useState(() => readStoredWidth(RIGHT_WIDTH_STORAGE_KEY, RIGHT_SIDEBAR_DEFAULT_WIDTH)); const leftWidthRef = useRef(leftWidth); @@ -851,53 +921,42 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId } startRightRef.current = rightWidthRef.current; }, []); - const activeHandoff = useMemo(() => handoffs.find((handoff) => handoff.id === selectedHandoffId) ?? handoffs[0] ?? null, [handoffs, selectedHandoffId]); + const activeTask = useMemo(() => tasks.find((task) => task.id === selectedTaskId) ?? tasks[0] ?? null, [tasks, selectedTaskId]); useEffect(() => { - try { - const status = globalThis.localStorage?.getItem(STAR_SANDBOX_AGENT_REPO_STORAGE_KEY); - if (status !== "completed" && status !== "dismissed") { - setStarRepoPromptOpen(true); - } - } catch { - setStarRepoPromptOpen(true); - } - }, []); - - useEffect(() => { - if (activeHandoff) { + if (activeTask) { return; } - const fallbackHandoffId = handoffs[0]?.id; - if (!fallbackHandoffId) { + const fallbackTaskId = tasks[0]?.id; + if (!fallbackTaskId) { return; } - const fallbackHandoff = handoffs.find((handoff) => handoff.id === fallbackHandoffId) ?? null; + const fallbackTask = tasks.find((task) => task.id === fallbackTaskId) ?? null; void navigate({ - to: "/workspaces/$workspaceId/handoffs/$handoffId", + to: "/workspaces/$workspaceId/tasks/$taskId", params: { workspaceId, - handoffId: fallbackHandoffId, + taskId: fallbackTaskId, }, - search: { sessionId: fallbackHandoff?.tabs[0]?.id ?? undefined }, + search: { sessionId: fallbackTask?.tabs[0]?.id ?? undefined }, replace: true, }); - }, [activeHandoff, handoffs, navigate, workspaceId]); + }, [activeTask, tasks, navigate, workspaceId]); - const openDiffs = activeHandoff ? sanitizeOpenDiffs(activeHandoff, openDiffsByHandoff[activeHandoff.id]) : []; - const lastAgentTabId = activeHandoff ? sanitizeLastAgentTabId(activeHandoff, lastAgentTabIdByHandoff[activeHandoff.id]) : null; - const activeTabId = activeHandoff ? sanitizeActiveTabId(activeHandoff, activeTabIdByHandoff[activeHandoff.id], openDiffs, lastAgentTabId) : null; + const openDiffs = activeTask ? sanitizeOpenDiffs(activeTask, openDiffsByTask[activeTask.id]) : []; + const lastAgentTabId = activeTask ? sanitizeLastAgentTabId(activeTask, lastAgentTabIdByTask[activeTask.id]) : null; + const activeTabId = activeTask ? sanitizeActiveTabId(activeTask, activeTabIdByTask[activeTask.id], openDiffs, lastAgentTabId) : null; const syncRouteSession = useCallback( - (handoffId: string, sessionId: string | null, replace = false) => { + (taskId: string, sessionId: string | null, replace = false) => { void navigate({ - to: "/workspaces/$workspaceId/handoffs/$handoffId", + to: "/workspaces/$workspaceId/tasks/$taskId", params: { workspaceId, - handoffId, + taskId, }, search: { sessionId: sessionId ?? undefined }, ...(replace ? { replace: true } : {}), @@ -907,118 +966,116 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId } ); useEffect(() => { - if (!activeHandoff) { + if (!activeTask) { return; } - const resolvedRouteSessionId = sanitizeLastAgentTabId(activeHandoff, selectedSessionId); + const resolvedRouteSessionId = sanitizeLastAgentTabId(activeTask, selectedSessionId); if (!resolvedRouteSessionId) { return; } if (selectedSessionId !== resolvedRouteSessionId) { - syncRouteSession(activeHandoff.id, resolvedRouteSessionId, true); + syncRouteSession(activeTask.id, resolvedRouteSessionId, true); return; } - if (lastAgentTabIdByHandoff[activeHandoff.id] === resolvedRouteSessionId) { + if (lastAgentTabIdByTask[activeTask.id] === resolvedRouteSessionId) { return; } - setLastAgentTabIdByHandoff((current) => ({ + setLastAgentTabIdByTask((current) => ({ ...current, - [activeHandoff.id]: resolvedRouteSessionId, + [activeTask.id]: resolvedRouteSessionId, })); - setActiveTabIdByHandoff((current) => { - const currentActive = current[activeHandoff.id]; + setActiveTabIdByTask((current) => { + const currentActive = current[activeTask.id]; if (currentActive && isDiffTab(currentActive)) { return current; } return { ...current, - [activeHandoff.id]: resolvedRouteSessionId, + [activeTask.id]: resolvedRouteSessionId, }; }); - }, [activeHandoff, lastAgentTabIdByHandoff, selectedSessionId, syncRouteSession]); + }, [activeTask, lastAgentTabIdByTask, selectedSessionId, syncRouteSession]); - const createHandoff = useCallback(() => { + const createTask = useCallback(() => { void (async () => { - const repoId = activeHandoff?.repoId ?? viewModel.repos[0]?.id ?? ""; + const repoId = activeTask?.repoId ?? viewModel.repos[0]?.id ?? ""; if (!repoId) { throw new Error("Cannot create a task without an available repo"); } - const task = "New task"; - const { handoffId, tabId } = await handoffWorkbenchClient.createHandoff({ + const { taskId, tabId } = await taskWorkbenchClient.createTask({ repoId, - task, - title: task, + task: "New task", model: "gpt-4o", - initialPrompt: "", + title: "New task", }); await navigate({ - to: "/workspaces/$workspaceId/handoffs/$handoffId", + to: "/workspaces/$workspaceId/tasks/$taskId", params: { workspaceId, - handoffId, + taskId, }, search: { sessionId: tabId ?? undefined }, }); })(); - }, [activeHandoff?.repoId, navigate, viewModel.repos, workspaceId]); + }, [activeTask?.repoId, navigate, viewModel.repos, workspaceId]); const openDiffTab = useCallback( (path: string) => { - if (!activeHandoff) { + if (!activeTask) { throw new Error("Cannot open a diff tab without an active task"); } - setOpenDiffsByHandoff((current) => { - const existing = sanitizeOpenDiffs(activeHandoff, current[activeHandoff.id]); + setOpenDiffsByTask((current) => { + const existing = sanitizeOpenDiffs(activeTask, current[activeTask.id]); if (existing.includes(path)) { return current; } return { ...current, - [activeHandoff.id]: [...existing, path], + [activeTask.id]: [...existing, path], }; }); - setActiveTabIdByHandoff((current) => ({ + setActiveTabIdByTask((current) => ({ ...current, - [activeHandoff.id]: diffTabId(path), + [activeTask.id]: diffTabId(path), })); }, - [activeHandoff], + [activeTask], ); - const selectHandoff = useCallback( + const selectTask = useCallback( (id: string) => { - const handoff = handoffs.find((candidate) => candidate.id === id) ?? null; + const task = tasks.find((candidate) => candidate.id === id) ?? null; void navigate({ - to: "/workspaces/$workspaceId/handoffs/$handoffId", + to: "/workspaces/$workspaceId/tasks/$taskId", params: { workspaceId, - handoffId: id, + taskId: id, }, - search: { sessionId: handoff?.tabs[0]?.id ?? undefined }, + search: { sessionId: task?.tabs[0]?.id ?? undefined }, }); }, - [handoffs, navigate, workspaceId], + [tasks, navigate, workspaceId], ); - const markHandoffUnread = useCallback((id: string) => { - void handoffWorkbenchClient.markHandoffUnread({ handoffId: id }); + const markTaskUnread = useCallback((id: string) => { + void taskWorkbenchClient.markTaskUnread({ taskId: id }); }, []); - const renameHandoff = useCallback( + const renameTask = useCallback( (id: string) => { - const currentHandoff = handoffs.find((handoff) => handoff.id === id); - if (!currentHandoff) { + const currentTask = tasks.find((task) => task.id === id); + if (!currentTask) { throw new Error(`Unable to rename missing task ${id}`); } - const nextTitle = window.prompt("Rename task", currentHandoff.title); + const nextTitle = window.prompt("Rename task", currentTask.title); if (nextTitle === null) { return; } @@ -1028,19 +1085,19 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId } return; } - void handoffWorkbenchClient.renameHandoff({ handoffId: id, value: trimmedTitle }); + void taskWorkbenchClient.renameTask({ taskId: id, value: trimmedTitle }); }, - [handoffs], + [tasks], ); const renameBranch = useCallback( (id: string) => { - const currentHandoff = handoffs.find((handoff) => handoff.id === id); - if (!currentHandoff) { + 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", currentHandoff.branch ?? ""); + const nextBranch = window.prompt("Rename branch", currentTask.branch ?? ""); if (nextBranch === null) { return; } @@ -1050,189 +1107,62 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId } return; } - void handoffWorkbenchClient.renameBranch({ handoffId: id, value: trimmedBranch }); + void taskWorkbenchClient.renameBranch({ taskId: id, value: trimmedBranch }); }, - [handoffs], + [tasks], ); - const archiveHandoff = useCallback(() => { - if (!activeHandoff) { + const archiveTask = useCallback(() => { + if (!activeTask) { throw new Error("Cannot archive without an active task"); } - void handoffWorkbenchClient.archiveHandoff({ handoffId: activeHandoff.id }); - }, [activeHandoff]); + void taskWorkbenchClient.archiveTask({ taskId: activeTask.id }); + }, [activeTask]); const publishPr = useCallback(() => { - if (!activeHandoff) { + if (!activeTask) { throw new Error("Cannot publish PR without an active task"); } - void handoffWorkbenchClient.publishPr({ handoffId: activeHandoff.id }); - }, [activeHandoff]); + void taskWorkbenchClient.publishPr({ taskId: activeTask.id }); + }, [activeTask]); const revertFile = useCallback( (path: string) => { - if (!activeHandoff) { + if (!activeTask) { throw new Error("Cannot revert a file without an active task"); } - setOpenDiffsByHandoff((current) => ({ + setOpenDiffsByTask((current) => ({ ...current, - [activeHandoff.id]: sanitizeOpenDiffs(activeHandoff, current[activeHandoff.id]).filter((candidate) => candidate !== path), + [activeTask.id]: sanitizeOpenDiffs(activeTask, current[activeTask.id]).filter((candidate) => candidate !== path), })); - setActiveTabIdByHandoff((current) => ({ + setActiveTabIdByTask((current) => ({ ...current, - [activeHandoff.id]: - current[activeHandoff.id] === diffTabId(path) - ? sanitizeLastAgentTabId(activeHandoff, lastAgentTabIdByHandoff[activeHandoff.id]) - : (current[activeHandoff.id] ?? null), + [activeTask.id]: + current[activeTask.id] === diffTabId(path) + ? sanitizeLastAgentTabId(activeTask, lastAgentTabIdByTask[activeTask.id]) + : (current[activeTask.id] ?? null), })); - void handoffWorkbenchClient.revertFile({ - handoffId: activeHandoff.id, + void taskWorkbenchClient.revertFile({ + taskId: activeTask.id, path, }); }, - [activeHandoff, lastAgentTabIdByHandoff], + [activeTask, lastAgentTabIdByTask], ); - - const dismissStarRepoPrompt = useCallback(() => { - setStarRepoError(null); - try { - globalThis.localStorage?.setItem(STAR_SANDBOX_AGENT_REPO_STORAGE_KEY, "dismissed"); - } catch { - // ignore storage failures - } - setStarRepoPromptOpen(false); - }, []); - - const starSandboxAgentRepo = useCallback(() => { - setStarRepoPending(true); - setStarRepoError(null); - void backendClient - .starSandboxAgentRepo(workspaceId) - .then(() => { - try { - globalThis.localStorage?.setItem(STAR_SANDBOX_AGENT_REPO_STORAGE_KEY, "completed"); - } catch { - // ignore storage failures - } - setStarRepoPromptOpen(false); - }) - .catch((error) => { - setStarRepoError(error instanceof Error ? error.message : String(error)); - }) - .finally(() => { - setStarRepoPending(false); - }); - }, [workspaceId]); - - const starRepoPrompt = starRepoPromptOpen ? ( -
-
-
-
- Welcome to Foundry -
-

Support Sandbox Agent

-

- Star the repo to help us grow and stay up to date with new releases. -

-
- - {starRepoError ? ( -
- {starRepoError} -
- ) : null} - -
- - -
-
-
- ) : null; - - if (!activeHandoff) { + if (!activeTask) { return ( <> +
@@ -1266,7 +1196,7 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId }

- {starRepoPrompt} ); } return ( <> +
@@ -1313,19 +1243,19 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId }
{ - setActiveTabIdByHandoff((current) => ({ ...current, [activeHandoff.id]: tabId })); + setActiveTabIdByTask((current) => ({ ...current, [activeTask.id]: tabId })); }} onSetLastAgentTabId={(tabId) => { - setLastAgentTabIdByHandoff((current) => ({ ...current, [activeHandoff.id]: tabId })); + setLastAgentTabIdByTask((current) => ({ ...current, [activeTask.id]: tabId })); }} onSetOpenDiffs={(paths) => { - setOpenDiffsByHandoff((current) => ({ ...current, [activeHandoff.id]: paths })); + setOpenDiffsByTask((current) => ({ ...current, [activeTask.id]: paths })); }} />
@@ -1333,16 +1263,15 @@ export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId }
- {starRepoPrompt} ); } diff --git a/factory/packages/frontend/src/components/mock-layout/diff-content.tsx b/foundry/packages/frontend/src/components/mock-layout/diff-content.tsx similarity index 100% rename from factory/packages/frontend/src/components/mock-layout/diff-content.tsx rename to foundry/packages/frontend/src/components/mock-layout/diff-content.tsx diff --git a/factory/packages/frontend/src/components/mock-layout/history-minimap.tsx b/foundry/packages/frontend/src/components/mock-layout/history-minimap.tsx similarity index 100% rename from factory/packages/frontend/src/components/mock-layout/history-minimap.tsx rename to foundry/packages/frontend/src/components/mock-layout/history-minimap.tsx diff --git a/factory/packages/frontend/src/components/mock-layout/message-list.tsx b/foundry/packages/frontend/src/components/mock-layout/message-list.tsx similarity index 100% rename from factory/packages/frontend/src/components/mock-layout/message-list.tsx rename to foundry/packages/frontend/src/components/mock-layout/message-list.tsx diff --git a/factory/packages/frontend/src/components/mock-layout/model-picker.tsx b/foundry/packages/frontend/src/components/mock-layout/model-picker.tsx similarity index 100% rename from factory/packages/frontend/src/components/mock-layout/model-picker.tsx rename to foundry/packages/frontend/src/components/mock-layout/model-picker.tsx diff --git a/factory/packages/frontend/src/components/mock-layout/prompt-composer.tsx b/foundry/packages/frontend/src/components/mock-layout/prompt-composer.tsx similarity index 100% rename from factory/packages/frontend/src/components/mock-layout/prompt-composer.tsx rename to foundry/packages/frontend/src/components/mock-layout/prompt-composer.tsx diff --git a/factory/packages/frontend/src/components/mock-layout/right-sidebar.tsx b/foundry/packages/frontend/src/components/mock-layout/right-sidebar.tsx similarity index 94% rename from factory/packages/frontend/src/components/mock-layout/right-sidebar.tsx rename to foundry/packages/frontend/src/components/mock-layout/right-sidebar.tsx index 59b0adf..fda7cdc 100644 --- a/factory/packages/frontend/src/components/mock-layout/right-sidebar.tsx +++ b/foundry/packages/frontend/src/components/mock-layout/right-sidebar.tsx @@ -4,7 +4,7 @@ import { LabelSmall } from "baseui/typography"; import { Archive, ArrowUpFromLine, ChevronRight, FileCode, FilePlus, FileX, FolderOpen, GitPullRequest } from "lucide-react"; import { type ContextMenuItem, ContextMenuOverlay, PanelHeaderBar, SPanel, ScrollBody, useContextMenu } from "./ui"; -import { type FileTreeNode, type Handoff, diffTabId } from "./view-model"; +import { type FileTreeNode, type Task, diffTabId } from "./view-model"; const FileTree = memo(function FileTree({ nodes, @@ -87,14 +87,14 @@ const FileTree = memo(function FileTree({ }); export const RightSidebar = memo(function RightSidebar({ - handoff, + task, activeTabId, onOpenDiff, onArchive, onRevertFile, onPublishPr, }: { - handoff: Handoff; + task: Task; activeTabId: string | null; onOpenDiff: (path: string) => void; onArchive: () => void; @@ -104,9 +104,9 @@ export const RightSidebar = memo(function RightSidebar({ const [css, theme] = useStyletron(); const [rightTab, setRightTab] = useState<"changes" | "files">("changes"); const contextMenu = useContextMenu(); - const changedPaths = useMemo(() => new Set(handoff.fileChanges.map((file) => file.path)), [handoff.fileChanges]); - const isTerminal = handoff.status === "archived"; - const pullRequestUrl = handoff.pullRequest != null ? `https://github.com/${handoff.repoName}/pull/${handoff.pullRequest.number}` : null; + const changedPaths = useMemo(() => new Set(task.fileChanges.map((file) => file.path)), [task.fileChanges]); + const isTerminal = task.status === "archived"; + const pullRequestUrl = task.pullRequest != null ? `https://github.com/${task.repoName}/pull/${task.pullRequest.number}` : null; const copyFilePath = useCallback(async (path: string) => { try { @@ -270,7 +270,7 @@ export const RightSidebar = memo(function RightSidebar({ })} > Changes - {handoff.fileChanges.length > 0 ? ( + {task.fileChanges.length > 0 ? ( - {handoff.fileChanges.length} + {task.fileChanges.length} ) : null} @@ -325,12 +325,12 @@ export const RightSidebar = memo(function RightSidebar({ {rightTab === "changes" ? (
- {handoff.fileChanges.length === 0 ? ( + {task.fileChanges.length === 0 ? (
No changes yet
) : null} - {handoff.fileChanges.map((file) => { + {task.fileChanges.map((file) => { const isActive = activeTabId === diffTabId(file.path); const TypeIcon = file.type === "A" ? FilePlus : file.type === "D" ? FileX : FileCode; const iconColor = file.type === "A" ? "#7ee787" : file.type === "D" ? "#ffa198" : theme.colors.contentTertiary; @@ -385,8 +385,8 @@ export const RightSidebar = memo(function RightSidebar({
) : (
- {handoff.fileTree.length > 0 ? ( - + {task.fileTree.length > 0 ? ( + ) : (
No files yet diff --git a/factory/packages/frontend/src/components/mock-layout/sidebar.tsx b/foundry/packages/frontend/src/components/mock-layout/sidebar.tsx similarity index 87% rename from factory/packages/frontend/src/components/mock-layout/sidebar.tsx rename to foundry/packages/frontend/src/components/mock-layout/sidebar.tsx index 4cac6a3..ae85006 100644 --- a/factory/packages/frontend/src/components/mock-layout/sidebar.tsx +++ b/foundry/packages/frontend/src/components/mock-layout/sidebar.tsx @@ -3,8 +3,8 @@ import { useStyletron } from "baseui"; import { LabelSmall, LabelXSmall } from "baseui/typography"; import { ChevronDown, ChevronUp, CloudUpload, GitPullRequestDraft, ListChecks, Plus } from "lucide-react"; -import { formatRelativeAge, type Handoff, type ProjectSection } from "./view-model"; -import { ContextMenuOverlay, HandoffIndicator, PanelHeaderBar, SPanel, ScrollBody, useContextMenu } from "./ui"; +import { formatRelativeAge, type Task, type ProjectSection } from "./view-model"; +import { ContextMenuOverlay, TaskIndicator, PanelHeaderBar, SPanel, ScrollBody, useContextMenu } from "./ui"; const PROJECT_COLORS = ["#6366f1", "#f59e0b", "#10b981", "#ef4444", "#8b5cf6", "#ec4899", "#06b6d4", "#f97316"]; @@ -28,7 +28,7 @@ export const Sidebar = memo(function Sidebar({ onSelect, onCreate, onMarkUnread, - onRenameHandoff, + onRenameTask, onRenameBranch, onReorderProjects, }: { @@ -37,7 +37,7 @@ export const Sidebar = memo(function Sidebar({ onSelect: (id: string) => void; onCreate: () => void; onMarkUnread: (id: string) => void; - onRenameHandoff: (id: string) => void; + onRenameTask: (id: string) => void; onRenameBranch: (id: string) => void; onReorderProjects: (fromIndex: number, toIndex: number) => void; }) { @@ -200,25 +200,25 @@ export const Sidebar = memo(function Sidebar({
{!isCollapsed && - project.handoffs.map((handoff) => { - const isActive = handoff.id === activeId; - const isDim = handoff.status === "archived"; - const isRunning = handoff.tabs.some((tab) => tab.status === "running"); - const hasUnread = handoff.tabs.some((tab) => tab.unread); - const isDraft = handoff.pullRequest == null || handoff.pullRequest.status === "draft"; - const totalAdded = handoff.fileChanges.reduce((sum, file) => sum + file.added, 0); - const totalRemoved = handoff.fileChanges.reduce((sum, file) => sum + file.removed, 0); + project.tasks.map((task) => { + const isActive = task.id === activeId; + const isDim = task.status === "archived"; + const isRunning = task.tabs.some((tab) => tab.status === "running"); + const hasUnread = task.tabs.some((tab) => tab.unread); + const isDraft = task.pullRequest == null || task.pullRequest.status === "draft"; + const totalAdded = task.fileChanges.reduce((sum, file) => sum + file.added, 0); + const totalRemoved = task.fileChanges.reduce((sum, file) => sum + file.removed, 0); const hasDiffs = totalAdded > 0 || totalRemoved > 0; return (
onSelect(handoff.id)} + key={task.id} + onClick={() => onSelect(task.id)} onContextMenu={(event) => contextMenu.open(event, [ - { label: "Rename task", onClick: () => onRenameHandoff(handoff.id) }, - { label: "Rename branch", onClick: () => onRenameBranch(handoff.id) }, - { label: "Mark as unread", onClick: () => onMarkUnread(handoff.id) }, + { label: "Rename task", onClick: () => onRenameTask(task.id) }, + { label: "Rename branch", onClick: () => onRenameBranch(task.id) }, + { label: "Mark as unread", onClick: () => onMarkUnread(task.id) }, ]) } className={css({ @@ -245,7 +245,7 @@ export const Sidebar = memo(function Sidebar({ flexShrink: 0, })} > - +
- {handoff.title} + {task.title} - {handoff.pullRequest != null ? ( + {task.pullRequest != null ? ( - #{handoff.pullRequest.number} + #{task.pullRequest.number} - {handoff.pullRequest.status === "draft" ? : null} + {task.pullRequest.status === "draft" ? : null} ) : ( @@ -277,7 +277,7 @@ export const Sidebar = memo(function Sidebar({
) : null} - {formatRelativeAge(handoff.updatedAtMs)} + {formatRelativeAge(task.updatedAtMs)}
diff --git a/factory/packages/frontend/src/components/mock-layout/tab-strip.tsx b/foundry/packages/frontend/src/components/mock-layout/tab-strip.tsx similarity index 95% rename from factory/packages/frontend/src/components/mock-layout/tab-strip.tsx rename to foundry/packages/frontend/src/components/mock-layout/tab-strip.tsx index cc7d904..7c6a764 100644 --- a/factory/packages/frontend/src/components/mock-layout/tab-strip.tsx +++ b/foundry/packages/frontend/src/components/mock-layout/tab-strip.tsx @@ -4,10 +4,10 @@ import { LabelXSmall } from "baseui/typography"; import { FileCode, Plus, X } from "lucide-react"; import { ContextMenuOverlay, TabAvatar, useContextMenu } from "./ui"; -import { diffTabId, fileName, type Handoff } from "./view-model"; +import { diffTabId, fileName, type Task } from "./view-model"; export const TabStrip = memo(function TabStrip({ - handoff, + task, activeTabId, openDiffs, editingSessionTabId, @@ -22,7 +22,7 @@ export const TabStrip = memo(function TabStrip({ onCloseDiffTab, onAddTab, }: { - handoff: Handoff; + task: Task; activeTabId: string | null; openDiffs: string[]; editingSessionTabId: string | null; @@ -62,7 +62,7 @@ export const TabStrip = memo(function TabStrip({ "::-webkit-scrollbar": { display: "none" }, })} > - {handoff.tabs.map((tab) => { + {task.tabs.map((tab) => { const isActive = tab.id === activeTabId; return (
onSwitchTab(tab.id)} onDoubleClick={() => onStartRenamingTab(tab.id)} onMouseDown={(event) => { - if (event.button === 1 && handoff.tabs.length > 1) { + if (event.button === 1 && task.tabs.length > 1) { event.preventDefault(); onCloseTab(tab.id); } @@ -82,7 +82,7 @@ export const TabStrip = memo(function TabStrip({ label: tab.unread ? "Mark as read" : "Mark as unread", onClick: () => onSetTabUnread(tab.id, !tab.unread), }, - ...(handoff.tabs.length > 1 ? [{ label: "Close tab", onClick: () => onCloseTab(tab.id) }] : []), + ...(task.tabs.length > 1 ? [{ label: "Close tab", onClick: () => onCloseTab(tab.id) }] : []), ]) } data-tab @@ -150,7 +150,7 @@ export const TabStrip = memo(function TabStrip({ {tab.sessionName} )} - {handoff.tabs.length > 1 ? ( + {task.tabs.length > 1 ? ( 0 ? label : `Terminal ${fallbackIndex}`; } -export function TerminalPane({ workspaceId, handoffId }: TerminalPaneProps) { +export function TerminalPane({ workspaceId, taskId }: TerminalPaneProps) { const [css] = useStyletron(); const [activeTabId, setActiveTabId] = useState(PROCESSES_TAB_ID); const [processTabs, setProcessTabs] = useState([]); @@ -78,28 +78,28 @@ export function TerminalPane({ workspaceId, handoffId }: TerminalPaneProps) { const [logsError, setLogsError] = useState(null); const [terminalClient, setTerminalClient] = useState(null); - const handoffQuery = useQuery({ - queryKey: ["mock-layout", "handoff", workspaceId, handoffId], - enabled: Boolean(handoffId), + const taskQuery = useQuery({ + queryKey: ["mock-layout", "task", workspaceId, taskId], + enabled: Boolean(taskId), staleTime: 1_000, refetchOnWindowFocus: true, refetchInterval: (query) => (query.state.data?.activeSandboxId ? false : 2_000), queryFn: async () => { - if (!handoffId) { - throw new Error("Cannot load terminal state without a handoff."); + if (!taskId) { + throw new Error("Cannot load terminal state without a task."); } - return await backendClient.getHandoff(workspaceId, handoffId); + return await backendClient.getTask(workspaceId, taskId); }, }); const activeSandbox = useMemo(() => { - const handoff = handoffQuery.data; - if (!handoff?.activeSandboxId) { + const task = taskQuery.data; + if (!task?.activeSandboxId) { return null; } - return handoff.sandboxes.find((sandbox) => sandbox.sandboxId === handoff.activeSandboxId) ?? null; - }, [handoffQuery.data]); + return task.sandboxes.find((sandbox) => sandbox.sandboxId === task.activeSandboxId) ?? null; + }, [taskQuery.data]); const connectionQuery = useQuery({ queryKey: ["mock-layout", "sandbox-agent-connection", workspaceId, activeSandbox?.providerId ?? "", activeSandbox?.sandboxId ?? ""], @@ -210,7 +210,7 @@ export function TerminalPane({ workspaceId, handoffId }: TerminalPaneProps) { setSelectedProcessId(null); setLogsText(""); setLogsError(null); - }, [handoffId]); + }, [taskId]); const processes = processesQuery.data?.processes ?? []; const selectedProcess = useMemo(() => processes.find((process) => process.id === selectedProcessId) ?? null, [processes, selectedProcessId]); @@ -432,7 +432,7 @@ export function TerminalPane({ workspaceId, handoffId }: TerminalPaneProps) {
Processes will appear when the sandbox is ready. - The active handoff does not have a sandbox runtime yet. + The active task does not have a sandbox runtime yet.
); @@ -870,17 +870,17 @@ export function TerminalPane({ workspaceId, handoffId }: TerminalPaneProps) { }; const renderBody = () => { - if (!handoffId) { + if (!taskId) { return (
- Select a handoff to inspect its processes. + Select a task to inspect its processes.
); } - if (handoffQuery.isLoading) { + if (taskQuery.isLoading) { return (
@@ -890,12 +890,12 @@ export function TerminalPane({ workspaceId, handoffId }: TerminalPaneProps) { ); } - if (handoffQuery.error) { + if (taskQuery.error) { return (
- Could not load handoff state. - {handoffQuery.error.message} + Could not load task state. + {taskQuery.error.message}
); diff --git a/factory/packages/frontend/src/components/mock-layout/transcript-header.tsx b/foundry/packages/frontend/src/components/mock-layout/transcript-header.tsx similarity index 94% rename from factory/packages/frontend/src/components/mock-layout/transcript-header.tsx rename to foundry/packages/frontend/src/components/mock-layout/transcript-header.tsx index daa56ae..ddc40bd 100644 --- a/factory/packages/frontend/src/components/mock-layout/transcript-header.tsx +++ b/foundry/packages/frontend/src/components/mock-layout/transcript-header.tsx @@ -4,10 +4,10 @@ import { LabelSmall } from "baseui/typography"; import { Clock, MailOpen } from "lucide-react"; import { PanelHeaderBar } from "./ui"; -import { type AgentTab, type Handoff } from "./view-model"; +import { type AgentTab, type Task } from "./view-model"; export const TranscriptHeader = memo(function TranscriptHeader({ - handoff, + task, activeTab, editingField, editValue, @@ -17,7 +17,7 @@ export const TranscriptHeader = memo(function TranscriptHeader({ onCancelEditingField, onSetActiveTabUnread, }: { - handoff: Handoff; + task: Task; activeTab: AgentTab | null | undefined; editingField: "title" | "branch" | null; editValue: string; @@ -65,12 +65,12 @@ export const TranscriptHeader = memo(function TranscriptHeader({ title="Rename" color={theme.colors.contentPrimary} $style={{ fontWeight: 400, whiteSpace: "nowrap", cursor: "pointer", ":hover": { textDecoration: "underline" } }} - onClick={() => onStartEditingField("title", handoff.title)} + onClick={() => onStartEditingField("title", task.title)} > - {handoff.title} + {task.title} )} - {handoff.branch ? ( + {task.branch ? ( editingField === "branch" ? ( onStartEditingField("branch", handoff.branch ?? "")} + onClick={() => onStartEditingField("branch", task.branch ?? "")} className={css({ padding: "2px 8px", borderRadius: "999px", @@ -118,7 +118,7 @@ export const TranscriptHeader = memo(function TranscriptHeader({ ":hover": { borderColor: "rgba(255, 255, 255, 0.3)" }, })} > - {handoff.branch} + {task.branch} ) ) : null} diff --git a/factory/packages/frontend/src/components/mock-layout/ui.tsx b/foundry/packages/frontend/src/components/mock-layout/ui.tsx similarity index 98% rename from factory/packages/frontend/src/components/mock-layout/ui.tsx rename to foundry/packages/frontend/src/components/mock-layout/ui.tsx index 3ebb00e..26b6b38 100644 --- a/factory/packages/frontend/src/components/mock-layout/ui.tsx +++ b/foundry/packages/frontend/src/components/mock-layout/ui.tsx @@ -111,15 +111,7 @@ export const UnreadDot = memo(function UnreadDot() { ); }); -export const HandoffIndicator = memo(function HandoffIndicator({ - isRunning, - hasUnread, - isDraft, -}: { - isRunning: boolean; - hasUnread: boolean; - isDraft: boolean; -}) { +export const TaskIndicator = memo(function TaskIndicator({ isRunning, hasUnread, isDraft }: { isRunning: boolean; hasUnread: boolean; isDraft: boolean }) { if (isRunning) return ; if (hasUnread) return ; if (isDraft) return ; diff --git a/factory/packages/frontend/src/components/mock-layout/view-model.test.ts b/foundry/packages/frontend/src/components/mock-layout/view-model.test.ts similarity index 98% rename from factory/packages/frontend/src/components/mock-layout/view-model.test.ts rename to foundry/packages/frontend/src/components/mock-layout/view-model.test.ts index 53b04b2..f3362dc 100644 --- a/factory/packages/frontend/src/components/mock-layout/view-model.test.ts +++ b/foundry/packages/frontend/src/components/mock-layout/view-model.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { WorkbenchAgentTab } from "@openhandoff/shared"; +import type { WorkbenchAgentTab } from "@sandbox-agent/foundry-shared"; import { buildDisplayMessages } from "./view-model"; function makeTab(transcript: WorkbenchAgentTab["transcript"]): WorkbenchAgentTab { diff --git a/factory/packages/frontend/src/components/mock-layout/view-model.ts b/foundry/packages/frontend/src/components/mock-layout/view-model.ts similarity index 99% rename from factory/packages/frontend/src/components/mock-layout/view-model.ts rename to foundry/packages/frontend/src/components/mock-layout/view-model.ts index 1275553..d22ea5c 100644 --- a/factory/packages/frontend/src/components/mock-layout/view-model.ts +++ b/foundry/packages/frontend/src/components/mock-layout/view-model.ts @@ -4,7 +4,7 @@ import type { WorkbenchDiffLineKind as DiffLineKind, WorkbenchFileChange as FileChange, WorkbenchFileTreeNode as FileTreeNode, - WorkbenchHandoff as Handoff, + WorkbenchTask as Task, WorkbenchHistoryEvent as HistoryEvent, WorkbenchLineAttachment as LineAttachment, WorkbenchModelGroup as ModelGroup, @@ -12,7 +12,7 @@ import type { WorkbenchParsedDiffLine as ParsedDiffLine, WorkbenchProjectSection as ProjectSection, WorkbenchTranscriptEvent as TranscriptEvent, -} from "@openhandoff/shared"; +} from "@sandbox-agent/foundry-shared"; import { extractEventText } from "../../features/sessions/model"; export type { ProjectSection }; @@ -329,7 +329,7 @@ export type { DiffLineKind, FileChange, FileTreeNode, - Handoff, + Task, HistoryEvent, LineAttachment, ModelGroup, diff --git a/foundry/packages/frontend/src/components/mock-onboarding.tsx b/foundry/packages/frontend/src/components/mock-onboarding.tsx new file mode 100644 index 0000000..1ba96f8 --- /dev/null +++ b/foundry/packages/frontend/src/components/mock-onboarding.tsx @@ -0,0 +1,969 @@ +import { useEffect, useMemo, useState } from "react"; +import { type FoundryBillingPlanId, type FoundryOrganization, type FoundryOrganizationMember, type FoundryUser } from "@sandbox-agent/foundry-shared"; +import { useNavigate } from "@tanstack/react-router"; +import { ArrowLeft, BadgeCheck, Building2, CreditCard, Github, ShieldCheck, Star, Users } from "lucide-react"; +import { activeMockUser, eligibleOrganizations, useMockAppClient, useMockAppSnapshot } from "../lib/mock-app"; +import { isMockFrontendClient } from "../lib/env"; + +const dateFormatter = new Intl.DateTimeFormat("en-US", { + month: "short", + day: "numeric", + year: "numeric", +}); + +const planCatalog: Record< + FoundryBillingPlanId, + { + label: string; + price: string; + seats: string; + summary: string; + } +> = { + free: { + label: "Free", + price: "$0", + seats: "1 seat included", + summary: "Best for a personal workspace and quick evaluations.", + }, + team: { + label: "Team", + price: "$240/mo", + seats: "5 seats included", + summary: "GitHub org onboarding, shared billing, and seat accrual on first prompt.", + }, +}; + +function appSurfaceStyle(): React.CSSProperties { + return { + minHeight: "100dvh", + display: "flex", + flexDirection: "column", + background: + "radial-gradient(circle at top left, rgba(255, 79, 0, 0.16), transparent 28%), radial-gradient(circle at top right, rgba(24, 140, 255, 0.18), transparent 32%), #050505", + color: "#ffffff", + }; +} + +function topBarStyle(): React.CSSProperties { + return { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + padding: "18px 28px", + borderBottom: "1px solid rgba(255, 255, 255, 0.1)", + background: "rgba(0, 0, 0, 0.36)", + backdropFilter: "blur(16px)", + }; +} + +function contentWrapStyle(): React.CSSProperties { + return { + width: "min(1180px, calc(100vw - 40px))", + margin: "0 auto", + padding: "28px 0 40px", + display: "flex", + flexDirection: "column", + gap: "20px", + }; +} + +function primaryButtonStyle(): React.CSSProperties { + return { + border: 0, + borderRadius: "999px", + padding: "11px 16px", + background: "#ff4f00", + color: "#ffffff", + fontWeight: 700, + cursor: "pointer", + }; +} + +function secondaryButtonStyle(): React.CSSProperties { + return { + border: "1px solid rgba(255, 255, 255, 0.16)", + borderRadius: "999px", + padding: "10px 15px", + background: "rgba(255, 255, 255, 0.03)", + color: "#ffffff", + fontWeight: 600, + cursor: "pointer", + }; +} + +function subtleButtonStyle(): React.CSSProperties { + return { + border: 0, + borderRadius: "999px", + padding: "10px 14px", + background: "rgba(255, 255, 255, 0.05)", + color: "#ffffff", + fontWeight: 600, + cursor: "pointer", + }; +} + +function cardStyle(): React.CSSProperties { + return { + background: "linear-gradient(180deg, rgba(21, 21, 24, 0.96), rgba(10, 10, 11, 0.98))", + border: "1px solid rgba(255, 255, 255, 0.1)", + borderRadius: "24px", + boxShadow: "0 18px 40px rgba(0, 0, 0, 0.36)", + }; +} + +function badgeStyle(background: string, color = "#f4f4f5"): React.CSSProperties { + return { + display: "inline-flex", + alignItems: "center", + gap: "6px", + padding: "6px 10px", + borderRadius: "999px", + background, + color, + fontSize: "12px", + fontWeight: 700, + letterSpacing: "0.01em", + }; +} + +function formatDate(value: string | null): string { + if (!value) { + return "N/A"; + } + return dateFormatter.format(new Date(value)); +} + +function workspacePath(organization: FoundryOrganization): string { + return `/workspaces/${organization.workspaceId}`; +} + +function settingsPath(organization: FoundryOrganization): string { + return `/organizations/${organization.id}/settings`; +} + +function billingPath(organization: FoundryOrganization): string { + return `/organizations/${organization.id}/billing`; +} + +function checkoutPath(organization: FoundryOrganization, planId: FoundryBillingPlanId): string { + return `/organizations/${organization.id}/checkout/${planId}`; +} + +function statusBadge(organization: FoundryOrganization) { + if (organization.kind === "personal") { + return Personal workspace; + } + return GitHub organization; +} + +function githubBadge(organization: FoundryOrganization) { + if (organization.github.installationStatus === "connected") { + return GitHub connected; + } + if (organization.github.installationStatus === "reconnect_required") { + return Reconnect required; + } + return Install GitHub App; +} + +function PageShell({ + user, + title, + eyebrow, + description, + children, + actions, + onSignOut, +}: { + user: FoundryUser | null; + title: string; + eyebrow: string; + description: string; + children: React.ReactNode; + actions?: React.ReactNode; + onSignOut?: () => void; +}) { + return ( +
+
+
+
+ SA +
+
+
{eyebrow}
+
{title}
+
+
+
+ {actions} + {user ? ( +
+
+
{user.name}
+
@{user.githubLogin}
+
+ {onSignOut ? ( + + ) : null} +
+ ) : null} +
+
+
+
{description}
+ {children} +
+
+ ); +} + +function StatCard({ label, value, caption }: { label: string; value: string; caption: string }) { + return ( +
+
{label}
+
{value}
+
{caption}
+
+ ); +} + +function MemberRow({ member }: { member: FoundryOrganizationMember }) { + return ( +
+
+
{member.name}
+
{member.email}
+
+
{member.role}
+
+ + {member.state} + +
+
+ ); +} + +export function MockSignInPage() { + const client = useMockAppClient(); + const navigate = useNavigate(); + const mockAccount = { + name: "Nathan", + email: "nathan@acme.dev", + githubLogin: "nathan", + label: "Mock account for review", + }; + + return ( +
+
+
+
+ Mock Better Auth + GitHub OAuth +
Sign in and land directly in the org onboarding funnel.
+
+ {isMockFrontendClient + ? "This mock screen stands in for a basic GitHub OAuth sign-in page. After sign-in, the user moves into the separate organization selector and then the rest of the onboarding funnel." + : "GitHub OAuth starts here. After the callback exchange completes, the app restores the signed-in session and continues into organization selection."} +
+
+
+ + GitHub sign-in +
+
+ + Org selection +
+
+ + Hosted billing +
+
+
+
+
+
Continue to Sandbox Agent
+
+ {isMockFrontendClient + ? "This mock sign-in uses a single GitHub account so the org selection step remains the place where the user chooses their workspace." + : "This starts the live GitHub OAuth flow and restores the app session when the callback returns."} +
+
+ +
+
+
+
{mockAccount.name}
+
+ @{mockAccount.githubLogin} · {mockAccount.email} +
+
+ {isMockFrontendClient ? mockAccount.label : "Live GitHub identity"} +
+
+ {isMockFrontendClient + ? "Sign-in always lands as this single mock user. Organization choice happens on the next screen." + : "In remote mode this card is replaced by the live GitHub user once the OAuth callback completes."} +
+
+
+
+
+
+ ); +} + +export function MockOrganizationSelectorPage() { + const client = useMockAppClient(); + const snapshot = useMockAppSnapshot(); + const user = activeMockUser(snapshot); + const organizations: FoundryOrganization[] = eligibleOrganizations(snapshot); + const navigate = useNavigate(); + const starterRepo = snapshot.onboarding.starterRepo; + const starterRepoTarget = organizations.find((organization) => organization.kind === "organization") ?? organizations[0] ?? null; + + return ( + { + void (async () => { + await client.signOut(); + await navigate({ to: "/signin" }); + })(); + }} + > +
+
+
+
+ +
Starter repo
+
+
+ Star {starterRepo.repoFullName} before entering the main app, or skip it and continue onboarding. This keeps the starter-repo ask + inside the funnel instead of interrupting the workspace later. +
+
+ {starterRepo.status === "starred" ? ( + Starred + ) : starterRepo.status === "skipped" ? ( + Skipped for now + ) : ( + Optional + )} +
+
+ + +
+
+
+ {organizations.map((organization) => ( +
+
+
+
{organization.settings.displayName}
+
+ {organization.settings.slug} · {organization.settings.primaryDomain} +
+
+ {statusBadge(organization)} +
+
+ {githubBadge(organization)} + + + {planCatalog[organization.billing.planId]!.label} + +
+
+ {organization.kind === "personal" + ? "Personal workspaces skip seat purchasing but still show the same onboarding and billing entry points." + : "Organization onboarding includes GitHub repo import, seat accrual on first prompt, and billing controls for the shared workspace."} +
+
+ member.state === "active").length} active`} + /> + + +
+
+ + + +
+
+ ))} +
+
+ ); +} + +export function MockOrganizationSettingsPage({ organization }: { organization: FoundryOrganization }) { + const client = useMockAppClient(); + const snapshot = useMockAppSnapshot(); + const user = activeMockUser(snapshot); + const navigate = useNavigate(); + const [displayName, setDisplayName] = useState(organization.settings.displayName); + const [slug, setSlug] = useState(organization.settings.slug); + const [primaryDomain, setPrimaryDomain] = useState(organization.settings.primaryDomain); + const seatCaption = useMemo( + () => `${organization.seatAssignments.length} of ${organization.billing.seatsIncluded} seats already accrued`, + [organization.billing.seatsIncluded, organization.seatAssignments.length], + ); + const openWorkspace = () => { + void (async () => { + await client.selectOrganization(organization.id); + await navigate({ to: workspacePath(organization) }); + })(); + }; + + useEffect(() => { + setDisplayName(organization.settings.displayName); + setSlug(organization.settings.slug); + setPrimaryDomain(organization.settings.primaryDomain); + }, [organization.id, organization.settings.displayName, organization.settings.slug, organization.settings.primaryDomain]); + + return ( + + + + + + } + onSignOut={() => { + void (async () => { + await client.signOut(); + await navigate({ to: "/signin" }); + })(); + }} + > +
+
+
+
+
+
Organization profile
+
+ {isMockFrontendClient ? "Mock org state persisted in the client package." : "Organization profile persisted in the app-shell backend."} +
+
+ {statusBadge(organization)} +
+ +
+ + +
+
+ + +
+
+ +
+
+ +
GitHub access
+
+
+ {githubBadge(organization)} + {organization.github.connectedAccount} +
+
+ {organization.github.importedRepoCount} repos imported. Last sync: {organization.github.lastSyncLabel} +
+
+ + +
+
+ +
+
+ +
Members and roles
+
+
+ {isMockFrontendClient + ? "Mock org membership feeds seat accrual and billing previews." + : "Organization membership feeds seat accrual and billing state."} +
+ {organization.members.map((member) => ( + + ))} +
+
+ +
+ + + +
+
+
+ ); +} + +export function MockOrganizationBillingPage({ organization }: { organization: FoundryOrganization }) { + const client = useMockAppClient(); + const snapshot = useMockAppSnapshot(); + const user = activeMockUser(snapshot); + const navigate = useNavigate(); + const hasStripeCustomer = organization.billing.stripeCustomerId.trim().length > 0; + const effectivePlanId: FoundryBillingPlanId = hasStripeCustomer ? organization.billing.planId : "free"; + const effectiveSeatsIncluded = hasStripeCustomer ? organization.billing.seatsIncluded : 1; + const openWorkspace = () => { + void (async () => { + await client.selectOrganization(organization.id); + await navigate({ to: workspacePath(organization) }); + })(); + }; + + return ( + + + + + } + onSignOut={() => { + void (async () => { + await client.signOut(); + await navigate({ to: "/signin" }); + })(); + }} + > +
+ + + +
+ +
+ {(Object.entries(planCatalog) as Array<[FoundryBillingPlanId, (typeof planCatalog)[FoundryBillingPlanId]]>).map(([planId, plan]) => { + const isCurrent = effectivePlanId === planId; + return ( +
+
+
+
{plan.label}
+
{plan.seats}
+
+ {isCurrent ? Current : null} +
+
{plan.price}
+
{plan.summary}
+ +
+ ); + })} +
+ +
+
+
+ +
Subscription controls
+
+
+ Stripe customer {organization.billing.stripeCustomerId || "pending"}.{" "} + {isMockFrontendClient + ? "This mock screen intentionally mirrors a hosted billing portal entry point and the in-product summary beside it." + : hasStripeCustomer + ? "Use the portal for payment method management and invoices, while in-product controls keep renewal state visible in the app shell." + : "Complete checkout first, then use the portal and renewal controls once Stripe has created the customer and subscription."} +
+
+ {hasStripeCustomer ? ( + organization.billing.status === "scheduled_cancel" ? ( + + ) : ( + + ) + ) : ( + + )} + +
+
+ +
+
+ +
Invoices
+
+
Recent hosted billing activity for review.
+ {organization.billing.invoices.length === 0 ? ( +
No invoices yet.
+ ) : ( + organization.billing.invoices.map((invoice) => ( +
+
+
{invoice.label}
+
{invoice.issuedAt}
+
+
${invoice.amountUsd}
+
+ + {invoice.status} + +
+
+ )) + )} +
+
+
+ ); +} + +export function MockHostedCheckoutPage({ organization, planId }: { organization: FoundryOrganization; planId: FoundryBillingPlanId }) { + const client = useMockAppClient(); + const snapshot = useMockAppSnapshot(); + const user = activeMockUser(snapshot); + const navigate = useNavigate(); + const plan = planCatalog[planId]!; + + return ( + void navigate({ to: billingPath(organization) })} style={secondaryButtonStyle()}> + + Back to billing + + } + onSignOut={() => { + void (async () => { + await client.signOut(); + await navigate({ to: "/signin" }); + })(); + }} + > +
+
+
Order summary
+
+ {organization.settings.displayName} is checking out on the {plan.label} plan. +
+
+ + + + +
+
+
+
Mock card details
+
+ + +
+ +
+
+
+ ); +} + +function CheckoutLine({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function inputStyle(): React.CSSProperties { + return { + width: "100%", + borderRadius: "14px", + border: "1px solid rgba(255, 255, 255, 0.12)", + background: "rgba(255, 255, 255, 0.04)", + color: "#ffffff", + padding: "12px 14px", + outline: "none", + }; +} diff --git a/factory/packages/frontend/src/components/workspace-dashboard.tsx b/foundry/packages/frontend/src/components/workspace-dashboard.tsx similarity index 91% rename from factory/packages/frontend/src/components/workspace-dashboard.tsx rename to foundry/packages/frontend/src/components/workspace-dashboard.tsx index 7abe8a5..0a5ab0d 100644 --- a/factory/packages/frontend/src/components/workspace-dashboard.tsx +++ b/foundry/packages/frontend/src/components/workspace-dashboard.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState, type ReactNode } from "react"; -import type { AgentType, HandoffRecord, HandoffSummary, RepoBranchRecord, RepoOverview, RepoStackAction } from "@openhandoff/shared"; -import { groupHandoffStatus, type SandboxSessionEventRecord } from "@openhandoff/client"; +import type { AgentType, TaskRecord, TaskSummary, RepoBranchRecord, RepoOverview, RepoStackAction } from "@sandbox-agent/foundry-shared"; +import { groupTaskStatus, type SandboxSessionEventRecord } from "@sandbox-agent/foundry-client"; import { useMutation, useQuery } from "@tanstack/react-query"; import { Link, useNavigate } from "@tanstack/react-router"; import { Button } from "baseui/button"; @@ -14,13 +14,13 @@ import { StyledDivider } from "baseui/divider"; import { styled, useStyletron } from "baseui"; import { HeadingSmall, HeadingXSmall, LabelSmall, LabelXSmall, MonoLabelSmall, ParagraphSmall } from "baseui/typography"; import { Bot, CircleAlert, FolderGit2, GitBranch, MessageSquareText, SendHorizontal, Shuffle } from "lucide-react"; -import { formatDiffStat } from "../features/handoffs/model"; +import { formatDiffStat } from "../features/tasks/model"; import { buildTranscript, resolveSessionSelection } from "../features/sessions/model"; import { backendClient } from "../lib/backend"; interface WorkspaceDashboardProps { workspaceId: string; - selectedHandoffId?: string; + selectedTaskId?: string; selectedRepoId?: string; } @@ -96,8 +96,8 @@ const AGENT_OPTIONS: SelectItem[] = [ { id: "claude", label: "claude" }, ]; -function statusKind(status: HandoffSummary["status"]): StatusTagKind { - const group = groupHandoffStatus(status); +function statusKind(status: TaskSummary["status"]): StatusTagKind { + const group = groupTaskStatus(status); if (group === "running") return "positive"; if (group === "queued") return "warning"; if (group === "error") return "negative"; @@ -136,18 +136,18 @@ function branchTestIdToken(value: string): string { } function useSessionEvents( - handoff: HandoffRecord | null, + task: TaskRecord | null, sessionId: string | null, ): ReturnType> { return useQuery({ - queryKey: ["workspace", handoff?.workspaceId ?? "", "session", handoff?.handoffId ?? "", sessionId ?? ""], - enabled: Boolean(handoff?.activeSandboxId && sessionId), + queryKey: ["workspace", task?.workspaceId ?? "", "session", task?.taskId ?? "", sessionId ?? ""], + enabled: Boolean(task?.activeSandboxId && sessionId), refetchInterval: 2_500, queryFn: async () => { - if (!handoff?.activeSandboxId || !sessionId) { + if (!task?.activeSandboxId || !sessionId) { return { items: [] }; } - return backendClient.listSandboxSessionEvents(handoff.workspaceId, handoff.providerId, handoff.activeSandboxId, { + return backendClient.listSandboxSessionEvents(task.workspaceId, task.providerId, task.activeSandboxId, { sessionId, limit: 120, }); @@ -180,7 +180,7 @@ function repoSummary(overview: RepoOverview | undefined): { let openPrs = 0; for (const row of overview.branches) { - if (row.handoffId) { + if (row.taskId) { mapped += 1; } if (row.conflictsWithMain) { @@ -219,13 +219,13 @@ function branchKind(row: RepoBranchRecord): StatusTagKind { function matchesOverviewFilter(branch: RepoBranchRecord, filter: RepoOverviewFilter): boolean { if (filter === "archived") { - return branch.handoffStatus === "archived"; + return branch.taskStatus === "archived"; } if (filter === "unmapped") { - return branch.handoffId === null; + return branch.taskId === null; } if (filter === "active") { - return branch.handoffStatus !== "archived"; + return branch.taskStatus !== "archived"; } return true; } @@ -350,7 +350,7 @@ function MetaRow({ label, value, mono = false }: { label: string; value: string; ); } -export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRepoId }: WorkspaceDashboardProps) { +export function WorkspaceDashboard({ workspaceId, selectedTaskId, selectedRepoId }: WorkspaceDashboardProps) { const [css, theme] = useStyletron(); const navigate = useNavigate(); const repoOverviewMode = typeof selectedRepoId === "string" && selectedRepoId.length > 0; @@ -363,7 +363,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep const [newBranchName, setNewBranchName] = useState(""); const [createOnBranch, setCreateOnBranch] = useState(null); const [addRepoOpen, setAddRepoOpen] = useState(false); - const [createHandoffOpen, setCreateHandoffOpen] = useState(false); + const [createTaskOpen, setCreateTaskOpen] = useState(false); const [addRepoRemote, setAddRepoRemote] = useState(""); const [addRepoError, setAddRepoError] = useState(null); const [stackActionError, setStackActionError] = useState(null); @@ -382,21 +382,21 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep }); const [createError, setCreateError] = useState(null); - const handoffsQuery = useQuery({ - queryKey: ["workspace", workspaceId, "handoffs"], - queryFn: async () => backendClient.listHandoffs(workspaceId), + const tasksQuery = useQuery({ + queryKey: ["workspace", workspaceId, "tasks"], + queryFn: async () => backendClient.listTasks(workspaceId), refetchInterval: 2_500, }); - const handoffDetailQuery = useQuery({ - queryKey: ["workspace", workspaceId, "handoff-detail", selectedHandoffId], - enabled: Boolean(selectedHandoffId && !repoOverviewMode), + const taskDetailQuery = useQuery({ + queryKey: ["workspace", workspaceId, "task-detail", selectedTaskId], + enabled: Boolean(selectedTaskId && !repoOverviewMode), refetchInterval: 2_500, queryFn: async () => { - if (!selectedHandoffId) { + if (!selectedTaskId) { throw new Error("No task selected"); } - return backendClient.getHandoff(workspaceId, selectedHandoffId); + return backendClient.getTask(workspaceId, selectedTaskId); }, }); @@ -439,9 +439,9 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep } }, [newAgentType]); - const rows = handoffsQuery.data ?? []; + const rows = tasksQuery.data ?? []; const repoGroups = useMemo(() => { - const byRepo = new Map(); + const byRepo = new Map(); for (const row of rows) { const bucket = byRepo.get(row.repoId); if (bucket) { @@ -453,13 +453,13 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep return repos .map((repo) => { - const handoffs = [...(byRepo.get(repo.repoId) ?? [])].sort((a, b) => b.updatedAt - a.updatedAt); - const latestHandoffAt = handoffs[0]?.updatedAt ?? 0; + const tasks = [...(byRepo.get(repo.repoId) ?? [])].sort((a, b) => b.updatedAt - a.updatedAt); + const latestTaskAt = tasks[0]?.updatedAt ?? 0; return { repoId: repo.repoId, repoRemote: repo.remoteUrl, - latestActivityAt: Math.max(repo.updatedAt, latestHandoffAt), - handoffs, + latestActivityAt: Math.max(repo.updatedAt, latestTaskAt), + tasks, }; }) .sort((a, b) => { @@ -470,9 +470,9 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep }); }, [repos, rows]); - const selectedSummary = useMemo(() => rows.find((row) => row.handoffId === selectedHandoffId) ?? rows[0] ?? null, [rows, selectedHandoffId]); + const selectedSummary = useMemo(() => rows.find((row) => row.taskId === selectedTaskId) ?? rows[0] ?? null, [rows, selectedTaskId]); - const selectedForSession = repoOverviewMode ? null : (handoffDetailQuery.data ?? null); + const selectedForSession = repoOverviewMode ? null : (taskDetailQuery.data ?? null); const activeSandbox = useMemo(() => { if (!selectedForSession) return null; @@ -483,23 +483,23 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep }, [selectedForSession]); useEffect(() => { - if (!repoOverviewMode && !selectedHandoffId && rows.length > 0) { + if (!repoOverviewMode && !selectedTaskId && rows.length > 0) { void navigate({ - to: "/workspaces/$workspaceId/handoffs/$handoffId", + to: "/workspaces/$workspaceId/tasks/$taskId", params: { workspaceId, - handoffId: rows[0]!.handoffId, + taskId: rows[0]!.taskId, }, search: { sessionId: undefined }, replace: true, }); } - }, [navigate, repoOverviewMode, rows, selectedHandoffId, workspaceId]); + }, [navigate, repoOverviewMode, rows, selectedTaskId, workspaceId]); useEffect(() => { setActiveSessionId(null); setDraft(""); - }, [selectedForSession?.handoffId]); + }, [selectedForSession?.taskId]); const sessionsQuery = useQuery({ queryKey: ["workspace", workspaceId, "sandbox", activeSandbox?.sandboxId ?? "", "sessions"], @@ -520,7 +520,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep () => resolveSessionSelection({ explicitSessionId: activeSessionId, - handoffSessionId: selectedForSession?.activeSessionId ?? null, + taskSessionId: selectedForSession?.activeSessionId ?? null, sessions: sessionRows, }), [activeSessionId, selectedForSession?.activeSessionId, sessionRows], @@ -530,7 +530,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep const eventsQuery = useSessionEvents(selectedForSession, resolvedSessionId); const canStartSession = Boolean(selectedForSession && activeSandbox?.sandboxId); - const startSessionFromHandoff = async (): Promise<{ id: string; status: "running" | "idle" | "error" }> => { + const startSessionFromTask = async (): Promise<{ id: string; status: "running" | "idle" | "error" }> => { if (!selectedForSession || !activeSandbox?.sandboxId) { throw new Error("No sandbox is available for this task"); } @@ -545,7 +545,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep }; const createSession = useMutation({ - mutationFn: async () => startSessionFromHandoff(), + mutationFn: async () => startSessionFromTask(), onSuccess: async (session) => { setActiveSessionId(session.id); await Promise.all([sessionsQuery.refetch(), eventsQuery.refetch()]); @@ -556,7 +556,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep if (resolvedSessionId) { return resolvedSessionId; } - const created = await startSessionFromHandoff(); + const created = await startSessionFromTask(); setActiveSessionId(created.id); await sessionsQuery.refetch(); return created.id; @@ -583,9 +583,9 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep }); const transcript = buildTranscript(eventsQuery.data?.items ?? []); - const canCreateHandoff = createRepoId.trim().length > 0 && newTask.trim().length > 0; + const canCreateTask = createRepoId.trim().length > 0 && newTask.trim().length > 0; - const createHandoff = useMutation({ + const createTask = useMutation({ mutationFn: async () => { const repoId = createRepoId.trim(); const task = newTask.trim(); @@ -596,7 +596,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep const draftTitle = newTitle.trim(); const draftBranchName = newBranchName.trim(); - return backendClient.createHandoff({ + return backendClient.createTask({ workspaceId, repoId, task, @@ -606,20 +606,20 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep onBranch: createOnBranch ?? undefined, }); }, - onSuccess: async (handoff) => { + onSuccess: async (task) => { setCreateError(null); setNewTask(""); setNewTitle(""); setNewBranchName(""); setCreateOnBranch(null); - setCreateHandoffOpen(false); - await handoffsQuery.refetch(); + setCreateTaskOpen(false); + await tasksQuery.refetch(); await repoOverviewQuery.refetch(); await navigate({ - to: "/workspaces/$workspaceId/handoffs/$handoffId", + to: "/workspaces/$workspaceId/tasks/$taskId", params: { workspaceId, - handoffId: handoff.handoffId, + taskId: task.taskId, }, search: { sessionId: undefined }, }); @@ -679,7 +679,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep setStackActionMessage(null); setStackActionError(result.message); } - await Promise.all([repoOverviewQuery.refetch(), handoffsQuery.refetch()]); + await Promise.all([repoOverviewQuery.refetch(), tasksQuery.refetch()]); }, onError: (error) => { setStackActionMessage(null); @@ -695,7 +695,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep if (!newTask.trim()) { setNewTask(`Continue work on ${branchName}`); } - setCreateHandoffOpen(true); + setCreateTaskOpen(true); }; const repoOptions = useMemo(() => repos.map((repo) => createOption({ id: repo.repoId, label: repo.remoteUrl })), [repos]); @@ -839,15 +839,13 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep - {handoffsQuery.isLoading ? ( + {tasksQuery.isLoading ? ( <> ) : null} - {!handoffsQuery.isLoading && repoGroups.length === 0 ? ( - No repos or tasks yet. Add a repo to start a workspace. - ) : null} + {!tasksQuery.isLoading && repoGroups.length === 0 ? No repos or tasks yet. Add a repo to start a workspace. : null} {repoGroups.map((group) => (
- {group.handoffs - .filter((handoff) => handoff.status !== "archived" || handoff.handoffId === selectedSummary?.handoffId) - .map((handoff) => { - const isActive = !repoOverviewMode && handoff.handoffId === selectedSummary?.handoffId; + {group.tasks + .filter((task) => task.status !== "archived" || task.taskId === selectedSummary?.taskId) + .map((task) => { + const isActive = !repoOverviewMode && task.taskId === selectedSummary?.taskId; return ( - {handoff.title ?? "Determining title..."} + {task.title ?? "Determining title..."}
- {handoff.branchName ?? "Determining branch..."} + {task.branchName ?? "Determining branch..."} - {handoff.status} + {task.status}
); @@ -958,9 +952,9 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep setCreateRepoId(group.repoId); setCreateOnBranch(null); setCreateError(null); - setCreateHandoffOpen(true); + setCreateTaskOpen(true); }} - data-testid={group.repoId === createRepoId ? "handoff-create-open" : `handoff-create-open-${group.repoId}`} + data-testid={group.repoId === createRepoId ? "task-create-open" : `task-create-open-${group.repoId}`} > Create Task @@ -1174,9 +1168,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep {formatRelativeAge(branch.updatedAt)} - - {branch.handoffId ? "task" : "unmapped"} - + {branch.taskId ? "task" : "unmapped"} {branch.trackedInStack ? stack : null}
@@ -1258,7 +1250,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep Reparent - {!branch.handoffId ? ( + {!branch.taskId ? (
@@ -1389,7 +1381,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep setActiveSessionId(next); } }} - overrides={selectTestIdOverrides("handoff-session-select")} + overrides={selectTestIdOverrides("task-session-select")} /> ) : null} @@ -1408,7 +1400,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep {transcript.length === 0 && !eventsQuery.isLoading ? ( - {groupHandoffStatus(selectedForSession.status) === "error" && selectedForSession.statusMessage + {groupTaskStatus(selectedForSession.status) === "error" && selectedForSession.statusMessage ? `Session failed: ${selectedForSession.statusMessage}` : !activeSandbox?.sandboxId ? selectedForSession.statusMessage @@ -1486,7 +1478,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep placeholder="Send a follow-up prompt to this session" rows={5} disabled={!activeSandbox?.sandboxId} - overrides={textareaTestIdOverrides("handoff-session-prompt")} + overrides={textareaTestIdOverrides("task-session-prompt")} />
- +
)} @@ -1608,7 +1597,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep gap: theme.sizing.scale300, })} > - + @@ -1652,7 +1641,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep - {groupHandoffStatus(selectedForSession.status) === "error" ? ( + {groupTaskStatus(selectedForSession.status) === "error" ? (
{ - setCreateHandoffOpen(false); + setCreateTaskOpen(false); setCreateOnBranch(null); }} overrides={modalOverrides} @@ -1764,7 +1753,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep setCreateRepoId(next); } }} - overrides={selectTestIdOverrides("handoff-create-repo")} + overrides={selectTestIdOverrides("task-create-repo")} /> {repos.length === 0 ? (
{ - setCreateHandoffOpen(false); + setCreateTaskOpen(false); setAddRepoError(null); setAddRepoOpen(true); }} @@ -1813,7 +1802,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep setNewAgentType(next); } }} - overrides={selectTestIdOverrides("handoff-create-agent")} + overrides={selectTestIdOverrides("task-create-agent")} />
@@ -1826,7 +1815,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep onChange={(event) => setNewTask(event.target.value)} placeholder="Task" rows={6} - overrides={textareaTestIdOverrides("handoff-create-task")} + overrides={textareaTestIdOverrides("task-create-task")} />
@@ -1838,7 +1827,7 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep placeholder="Title (optional)" value={newTitle} onChange={(event) => setNewTitle(event.target.value)} - overrides={inputTestIdOverrides("handoff-create-title")} + overrides={inputTestIdOverrides("task-create-title")} /> @@ -1847,19 +1836,19 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep Branch {createOnBranch ? ( - + ) : ( setNewBranchName(event.target.value)} - overrides={inputTestIdOverrides("handoff-create-branch")} + overrides={inputTestIdOverrides("task-create-branch")} /> )} {createError ? ( - + {createError} ) : null} @@ -1869,19 +1858,19 @@ export function WorkspaceDashboard({ workspaceId, selectedHandoffId, selectedRep diff --git a/factory/packages/frontend/src/features/sessions/model.test.ts b/foundry/packages/frontend/src/features/sessions/model.test.ts similarity index 91% rename from factory/packages/frontend/src/features/sessions/model.test.ts rename to foundry/packages/frontend/src/features/sessions/model.test.ts index 94b8344..2e77b3a 100644 --- a/factory/packages/frontend/src/features/sessions/model.test.ts +++ b/foundry/packages/frontend/src/features/sessions/model.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import type { SandboxSessionRecord } from "@openhandoff/client"; +import type { SandboxSessionRecord } from "@sandbox-agent/foundry-client"; import { buildTranscript, extractEventText, resolveSessionSelection } from "./model"; describe("extractEventText", () => { @@ -91,7 +91,7 @@ describe("resolveSessionSelection", () => { it("prefers explicit selection when present in session list", () => { const resolved = resolveSessionSelection({ explicitSessionId: "session-2", - handoffSessionId: "session-1", + taskSessionId: "session-1", sessions: [session("session-1"), session("session-2")], }); @@ -101,10 +101,10 @@ describe("resolveSessionSelection", () => { }); }); - it("falls back to handoff session when explicit selection is missing", () => { + it("falls back to task session when explicit selection is missing", () => { const resolved = resolveSessionSelection({ explicitSessionId: null, - handoffSessionId: "session-1", + taskSessionId: "session-1", sessions: [session("session-1")], }); @@ -117,7 +117,7 @@ describe("resolveSessionSelection", () => { it("falls back to the newest available session when configured session IDs are stale", () => { const resolved = resolveSessionSelection({ explicitSessionId: null, - handoffSessionId: "session-stale", + taskSessionId: "session-stale", sessions: [session("session-fresh")], }); @@ -130,7 +130,7 @@ describe("resolveSessionSelection", () => { it("marks stale session when no sessions are available", () => { const resolved = resolveSessionSelection({ explicitSessionId: null, - handoffSessionId: "session-stale", + taskSessionId: "session-stale", sessions: [], }); diff --git a/factory/packages/frontend/src/features/sessions/model.ts b/foundry/packages/frontend/src/features/sessions/model.ts similarity index 87% rename from factory/packages/frontend/src/features/sessions/model.ts rename to foundry/packages/frontend/src/features/sessions/model.ts index dfb59e7..9432df0 100644 --- a/factory/packages/frontend/src/features/sessions/model.ts +++ b/foundry/packages/frontend/src/features/sessions/model.ts @@ -1,5 +1,5 @@ -import type { SandboxSessionEventRecord } from "@openhandoff/client"; -import type { SandboxSessionRecord } from "@openhandoff/client"; +import type { SandboxSessionEventRecord } from "@sandbox-agent/foundry-client"; +import type { SandboxSessionRecord } from "@sandbox-agent/foundry-client"; function fromPromptArray(value: unknown): string | null { if (!Array.isArray(value)) { @@ -105,7 +105,7 @@ export function buildTranscript(events: SandboxSessionEventRecord[]): Array<{ })); } -export function resolveSessionSelection(input: { explicitSessionId: string | null; handoffSessionId: string | null; sessions: SandboxSessionRecord[] }): { +export function resolveSessionSelection(input: { explicitSessionId: string | null; taskSessionId: string | null; sessions: SandboxSessionRecord[] }): { sessionId: string | null; staleSessionId: string | null; } { @@ -116,8 +116,8 @@ export function resolveSessionSelection(input: { explicitSessionId: string | nul return { sessionId: input.explicitSessionId, staleSessionId: null }; } - if (hasSession(input.handoffSessionId)) { - return { sessionId: input.handoffSessionId, staleSessionId: null }; + if (hasSession(input.taskSessionId)) { + return { sessionId: input.taskSessionId, staleSessionId: null }; } const fallbackSessionId = input.sessions[0]?.id ?? null; @@ -129,8 +129,8 @@ export function resolveSessionSelection(input: { explicitSessionId: string | nul return { sessionId: null, staleSessionId: input.explicitSessionId }; } - if (input.handoffSessionId) { - return { sessionId: null, staleSessionId: input.handoffSessionId }; + if (input.taskSessionId) { + return { sessionId: null, staleSessionId: input.taskSessionId }; } return { sessionId: null, staleSessionId: null }; diff --git a/factory/packages/frontend/src/features/handoffs/model.test.ts b/foundry/packages/frontend/src/features/tasks/model.test.ts similarity index 58% rename from factory/packages/frontend/src/features/handoffs/model.test.ts rename to foundry/packages/frontend/src/features/tasks/model.test.ts index b6fa078..08f7b76 100644 --- a/factory/packages/frontend/src/features/handoffs/model.test.ts +++ b/foundry/packages/frontend/src/features/tasks/model.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it } from "vitest"; -import type { HandoffRecord } from "@openhandoff/shared"; -import { formatDiffStat, groupHandoffsByRepo } from "./model"; +import type { TaskRecord } from "@sandbox-agent/foundry-shared"; +import { formatDiffStat, groupTasksByRepo } from "./model"; -const base: HandoffRecord = { +const base: TaskRecord = { workspaceId: "default", repoId: "repo-a", repoRemote: "https://example.com/repo-a.git", - handoffId: "handoff-1", + taskId: "task-1", branchName: "feature/one", title: "Feature one", task: "Ship one", @@ -41,27 +41,27 @@ const base: HandoffRecord = { updatedAt: 10, }; -describe("groupHandoffsByRepo", () => { +describe("groupTasksByRepo", () => { it("groups by repo and sorts by recency", () => { - const rows: HandoffRecord[] = [ - { ...base, handoffId: "h1", repoId: "repo-a", repoRemote: "https://example.com/repo-a.git", updatedAt: 10 }, - { ...base, handoffId: "h2", repoId: "repo-a", repoRemote: "https://example.com/repo-a.git", updatedAt: 50 }, - { ...base, handoffId: "h3", repoId: "repo-b", repoRemote: "https://example.com/repo-b.git", updatedAt: 30 }, + const rows: TaskRecord[] = [ + { ...base, taskId: "h1", repoId: "repo-a", repoRemote: "https://example.com/repo-a.git", updatedAt: 10 }, + { ...base, taskId: "h2", repoId: "repo-a", repoRemote: "https://example.com/repo-a.git", updatedAt: 50 }, + { ...base, taskId: "h3", repoId: "repo-b", repoRemote: "https://example.com/repo-b.git", updatedAt: 30 }, ]; - const groups = groupHandoffsByRepo(rows); + const groups = groupTasksByRepo(rows); expect(groups).toHaveLength(2); expect(groups[0]?.repoId).toBe("repo-a"); - expect(groups[0]?.handoffs[0]?.handoffId).toBe("h2"); + expect(groups[0]?.tasks[0]?.taskId).toBe("h2"); }); - it("sorts repo groups by latest handoff activity first", () => { - const rows: HandoffRecord[] = [ - { ...base, handoffId: "h1", repoId: "repo-z", repoRemote: "https://example.com/repo-z.git", updatedAt: 200 }, - { ...base, handoffId: "h2", repoId: "repo-a", repoRemote: "https://example.com/repo-a.git", updatedAt: 100 }, + it("sorts repo groups by latest task activity first", () => { + const rows: TaskRecord[] = [ + { ...base, taskId: "h1", repoId: "repo-z", repoRemote: "https://example.com/repo-z.git", updatedAt: 200 }, + { ...base, taskId: "h2", repoId: "repo-a", repoRemote: "https://example.com/repo-a.git", updatedAt: 100 }, ]; - const groups = groupHandoffsByRepo(rows); + const groups = groupTasksByRepo(rows); expect(groups[0]?.repoId).toBe("repo-z"); expect(groups[1]?.repoId).toBe("repo-a"); }); diff --git a/factory/packages/frontend/src/features/handoffs/model.ts b/foundry/packages/frontend/src/features/tasks/model.ts similarity index 54% rename from factory/packages/frontend/src/features/handoffs/model.ts rename to foundry/packages/frontend/src/features/tasks/model.ts index 977d50d..539d3b9 100644 --- a/factory/packages/frontend/src/features/handoffs/model.ts +++ b/foundry/packages/frontend/src/features/tasks/model.ts @@ -1,36 +1,36 @@ -import type { HandoffRecord } from "@openhandoff/shared"; +import type { TaskRecord } from "@sandbox-agent/foundry-shared"; export interface RepoGroup { repoId: string; repoRemote: string; - handoffs: HandoffRecord[]; + tasks: TaskRecord[]; } -export function groupHandoffsByRepo(handoffs: HandoffRecord[]): RepoGroup[] { +export function groupTasksByRepo(tasks: TaskRecord[]): RepoGroup[] { const groups = new Map(); - for (const handoff of handoffs) { - const group = groups.get(handoff.repoId); + for (const task of tasks) { + const group = groups.get(task.repoId); if (group) { - group.handoffs.push(handoff); + group.tasks.push(task); continue; } - groups.set(handoff.repoId, { - repoId: handoff.repoId, - repoRemote: handoff.repoRemote, - handoffs: [handoff], + groups.set(task.repoId, { + repoId: task.repoId, + repoRemote: task.repoRemote, + tasks: [task], }); } return Array.from(groups.values()) .map((group) => ({ ...group, - handoffs: [...group.handoffs].sort((a, b) => b.updatedAt - a.updatedAt), + tasks: [...group.tasks].sort((a, b) => b.updatedAt - a.updatedAt), })) .sort((a, b) => { - const aLatest = a.handoffs[0]?.updatedAt ?? 0; - const bLatest = b.handoffs[0]?.updatedAt ?? 0; + const aLatest = a.tasks[0]?.updatedAt ?? 0; + const bLatest = b.tasks[0]?.updatedAt ?? 0; if (aLatest !== bLatest) { return bLatest - aLatest; } diff --git a/foundry/packages/frontend/src/foundry-client-view-model.d.ts b/foundry/packages/frontend/src/foundry-client-view-model.d.ts new file mode 100644 index 0000000..42a4012 --- /dev/null +++ b/foundry/packages/frontend/src/foundry-client-view-model.d.ts @@ -0,0 +1,7 @@ +declare module "@sandbox-agent/foundry-client/view-model" { + export { + TASK_STATUS_GROUPS, + groupTaskStatus, + } from "@sandbox-agent/foundry-client"; + export type { TaskStatusGroup } from "@sandbox-agent/foundry-client"; +} diff --git a/factory/packages/frontend/src/lib/backend.ts b/foundry/packages/frontend/src/lib/backend.ts similarity index 75% rename from factory/packages/frontend/src/lib/backend.ts rename to foundry/packages/frontend/src/lib/backend.ts index e718c82..158e701 100644 --- a/factory/packages/frontend/src/lib/backend.ts +++ b/foundry/packages/frontend/src/lib/backend.ts @@ -1,4 +1,4 @@ -import { createBackendClient } from "@openhandoff/client"; +import { createBackendClient } from "@sandbox-agent/foundry-client"; import { backendEndpoint, defaultWorkspaceId, frontendClientMode } from "./env"; export const backendClient = createBackendClient({ diff --git a/factory/packages/frontend/src/lib/env.ts b/foundry/packages/frontend/src/lib/env.ts similarity index 75% rename from factory/packages/frontend/src/lib/env.ts rename to foundry/packages/frontend/src/lib/env.ts index 3c9e1b4..63e80ea 100644 --- a/factory/packages/frontend/src/lib/env.ts +++ b/foundry/packages/frontend/src/lib/env.ts @@ -6,7 +6,7 @@ function resolveDefaultBackendEndpoint(): string { } type FrontendImportMetaEnv = ImportMetaEnv & { - OPENHANDOFF_FRONTEND_CLIENT_MODE?: string; + FOUNDRY_FRONTEND_CLIENT_MODE?: string; }; const frontendEnv = import.meta.env as FrontendImportMetaEnv; @@ -16,14 +16,14 @@ export const backendEndpoint = import.meta.env.VITE_HF_BACKEND_ENDPOINT?.trim() export const defaultWorkspaceId = import.meta.env.VITE_HF_WORKSPACE?.trim() || "default"; function resolveFrontendClientMode(): "mock" | "remote" { - const raw = frontendEnv.OPENHANDOFF_FRONTEND_CLIENT_MODE?.trim().toLowerCase(); + const raw = frontendEnv.FOUNDRY_FRONTEND_CLIENT_MODE?.trim().toLowerCase(); if (raw === "mock") { return "mock"; } if (raw === "remote" || raw === "" || raw === undefined) { return "remote"; } - throw new Error(`Unsupported OPENHANDOFF_FRONTEND_CLIENT_MODE value "${frontendEnv.OPENHANDOFF_FRONTEND_CLIENT_MODE}". Expected "mock" or "remote".`); + throw new Error(`Unsupported FOUNDRY_FRONTEND_CLIENT_MODE value "${frontendEnv.FOUNDRY_FRONTEND_CLIENT_MODE}". Expected "mock" or "remote".`); } export const frontendClientMode = resolveFrontendClientMode(); diff --git a/foundry/packages/frontend/src/lib/mock-app.ts b/foundry/packages/frontend/src/lib/mock-app.ts new file mode 100644 index 0000000..c0fefa9 --- /dev/null +++ b/foundry/packages/frontend/src/lib/mock-app.ts @@ -0,0 +1,68 @@ +import { useSyncExternalStore } from "react"; +import { + createFoundryAppClient, + currentFoundryOrganization, + currentFoundryUser, + eligibleFoundryOrganizations, + type FoundryAppClient, +} from "@sandbox-agent/foundry-client"; +import type { FoundryAppSnapshot, FoundryOrganization } from "@sandbox-agent/foundry-shared"; +import { backendClient } from "./backend"; +import { frontendClientMode } from "./env"; + +const REMOTE_APP_SESSION_STORAGE_KEY = "sandbox-agent-foundry:remote-app-session"; + +const appClient: FoundryAppClient = createFoundryAppClient({ + mode: frontendClientMode, + backend: frontendClientMode === "remote" ? backendClient : undefined, +}); + +export function useMockAppSnapshot(): FoundryAppSnapshot { + return useSyncExternalStore(appClient.subscribe.bind(appClient), appClient.getSnapshot.bind(appClient), appClient.getSnapshot.bind(appClient)); +} + +export function useMockAppClient(): FoundryAppClient { + return appClient; +} + +export const activeMockUser = currentFoundryUser; +export const activeMockOrganization = currentFoundryOrganization; +export const eligibleOrganizations = eligibleFoundryOrganizations; + +// Track whether the remote client has delivered its first real snapshot. +// Before the first fetch completes the snapshot is the default empty signed_out state, +// so we show a loading screen. Once the fetch returns we know the truth. +let firstSnapshotDelivered = false; + +// The remote client notifies listeners after refresh(), which sets `firstSnapshotDelivered`. +const origSubscribe = appClient.subscribe.bind(appClient); +appClient.subscribe = (listener: () => void): (() => void) => { + const wrappedListener = () => { + firstSnapshotDelivered = true; + listener(); + }; + return origSubscribe(wrappedListener); +}; + +export function isAppSnapshotBootstrapping(snapshot: FoundryAppSnapshot): boolean { + if (frontendClientMode !== "remote" || typeof window === "undefined") { + return false; + } + + const hasStoredSession = window.localStorage.getItem(REMOTE_APP_SESSION_STORAGE_KEY)?.trim().length; + if (!hasStoredSession) { + return false; + } + + // If the backend has already responded and we're still signed_out, the session is stale. + if (firstSnapshotDelivered) { + return false; + } + + // Still waiting for the initial fetch — show the loading screen. + return snapshot.auth.status === "signed_out" && snapshot.users.length === 0 && snapshot.organizations.length === 0; +} + +export function getMockOrganizationById(snapshot: FoundryAppSnapshot, organizationId: string): FoundryOrganization | null { + return snapshot.organizations.find((organization) => organization.id === organizationId) ?? null; +} diff --git a/factory/packages/frontend/src/lib/workbench.ts b/foundry/packages/frontend/src/lib/workbench.ts similarity index 59% rename from factory/packages/frontend/src/lib/workbench.ts rename to foundry/packages/frontend/src/lib/workbench.ts index 8ea0e23..dee6776 100644 --- a/factory/packages/frontend/src/lib/workbench.ts +++ b/foundry/packages/frontend/src/lib/workbench.ts @@ -1,8 +1,8 @@ -import { createHandoffWorkbenchClient } from "@openhandoff/client"; +import { createTaskWorkbenchClient } from "@sandbox-agent/foundry-client"; import { backendClient } from "./backend"; import { defaultWorkspaceId, frontendClientMode } from "./env"; -export const handoffWorkbenchClient = createHandoffWorkbenchClient({ +export const taskWorkbenchClient = createTaskWorkbenchClient({ mode: frontendClientMode, backend: backendClient, workspaceId: defaultWorkspaceId, diff --git a/factory/packages/frontend/src/main.tsx b/foundry/packages/frontend/src/main.tsx similarity index 100% rename from factory/packages/frontend/src/main.tsx rename to foundry/packages/frontend/src/main.tsx diff --git a/foundry/packages/frontend/src/sandbox-agent-react.d.ts b/foundry/packages/frontend/src/sandbox-agent-react.d.ts new file mode 100644 index 0000000..c587db7 --- /dev/null +++ b/foundry/packages/frontend/src/sandbox-agent-react.d.ts @@ -0,0 +1,102 @@ +declare module "@sandbox-agent/react" { + import type { CSSProperties, KeyboardEvent, ReactNode, Ref, RefObject, TextareaHTMLAttributes } from "react"; + import type { SandboxAgent } from "sandbox-agent"; + + export type PermissionReply = "once" | "always" | "reject"; + + export type PermissionOption = { + optionId: string; + name: string; + kind: string; + }; + + export type TranscriptEntry = { + id: string; + eventId?: string; + kind: "message" | "tool" | "meta" | "reasoning" | "permission"; + time: string; + role?: "user" | "assistant"; + text?: string; + }; + + export interface AgentTranscriptClassNames { + root: string; + message: string; + messageContent: string; + messageText: string; + thinkingRow: string; + thinkingIndicator: string; + } + + export interface PermissionOptionRenderContext { + entry: TranscriptEntry; + option: PermissionOption; + label: string; + reply: PermissionReply; + selected: boolean; + dimmed: boolean; + resolved: boolean; + } + + export interface AgentTranscriptProps { + entries: TranscriptEntry[]; + className?: string; + classNames?: Partial; + endRef?: RefObject; + sessionError?: string | null; + eventError?: string | null; + isThinking?: boolean; + agentId?: string; + onEventClick?: (eventId: string) => void; + onPermissionReply?: (permissionId: string, reply: PermissionReply) => void; + renderMessageText?: (entry: TranscriptEntry) => ReactNode; + renderInlinePendingIndicator?: () => ReactNode; + renderThinkingState?: (context: { agentId?: string }) => ReactNode; + renderPermissionOptionContent?: (context: PermissionOptionRenderContext) => ReactNode; + } + + export const AgentTranscript: (props: AgentTranscriptProps) => ReactNode; + + export interface ChatComposerClassNames { + root: string; + form: string; + input: string; + submit: string; + submitContent: string; + } + + export interface ChatComposerProps { + message: string; + onMessageChange: (value: string) => void; + onSubmit: () => void; + onKeyDown?: (event: KeyboardEvent) => void; + placeholder?: string; + disabled?: boolean; + submitDisabled?: boolean; + allowEmptySubmit?: boolean; + submitLabel?: string; + className?: string; + classNames?: Partial; + inputRef?: Ref; + rows?: number; + textareaProps?: Omit, "className" | "disabled" | "onChange" | "onKeyDown" | "placeholder" | "rows" | "value">; + renderSubmitContent?: () => ReactNode; + renderFooter?: () => ReactNode; + } + + export const ChatComposer: (props: ChatComposerProps) => ReactNode; + + export interface ProcessTerminalClient extends SandboxAgent {} + + export interface ProcessTerminalProps { + client: SandboxAgent | null; + processId: string; + height?: string | number; + showStatusBar?: boolean; + style?: CSSProperties; + terminalStyle?: CSSProperties; + onExit?: () => void; + } + + export const ProcessTerminal: (props: ProcessTerminalProps) => ReactNode; +} diff --git a/factory/packages/frontend/src/styles.css b/foundry/packages/frontend/src/styles.css similarity index 100% rename from factory/packages/frontend/src/styles.css rename to foundry/packages/frontend/src/styles.css diff --git a/factory/packages/frontend/tsconfig.json b/foundry/packages/frontend/tsconfig.json similarity index 100% rename from factory/packages/frontend/tsconfig.json rename to foundry/packages/frontend/tsconfig.json diff --git a/factory/packages/frontend/vite.config.ts b/foundry/packages/frontend/vite.config.ts similarity index 58% rename from factory/packages/frontend/vite.config.ts rename to foundry/packages/frontend/vite.config.ts index 482b0fc..d176346 100644 --- a/factory/packages/frontend/vite.config.ts +++ b/foundry/packages/frontend/vite.config.ts @@ -1,15 +1,21 @@ import { defineConfig } from "vite"; +import { resolve } from "node:path"; import react from "@vitejs/plugin-react"; -import { frontendErrorCollectorVitePlugin } from "@openhandoff/frontend-errors/vite"; +import { frontendErrorCollectorVitePlugin } from "@sandbox-agent/foundry-frontend-errors/vite"; const backendProxyTarget = process.env.HF_BACKEND_HTTP?.trim() || "http://127.0.0.1:7741"; const cacheDir = process.env.HF_VITE_CACHE_DIR?.trim() || undefined; export default defineConfig({ define: { - "import.meta.env.OPENHANDOFF_FRONTEND_CLIENT_MODE": JSON.stringify(process.env.OPENHANDOFF_FRONTEND_CLIENT_MODE?.trim() || "remote"), + "import.meta.env.FOUNDRY_FRONTEND_CLIENT_MODE": JSON.stringify(process.env.FOUNDRY_FRONTEND_CLIENT_MODE?.trim() || "remote"), }, plugins: [react(), frontendErrorCollectorVitePlugin()], cacheDir, + resolve: { + alias: { + "@sandbox-agent/react": resolve(__dirname, "../../../sdks/react/dist/index.js"), + }, + }, server: { port: 4173, proxy: { diff --git a/factory/packages/frontend/vitest.config.ts b/foundry/packages/frontend/vitest.config.ts similarity index 100% rename from factory/packages/frontend/vitest.config.ts rename to foundry/packages/frontend/vitest.config.ts diff --git a/factory/packages/shared/package.json b/foundry/packages/shared/package.json similarity index 88% rename from factory/packages/shared/package.json rename to foundry/packages/shared/package.json index b458a38..1b4f3ee 100644 --- a/factory/packages/shared/package.json +++ b/foundry/packages/shared/package.json @@ -1,5 +1,5 @@ { - "name": "@openhandoff/shared", + "name": "@sandbox-agent/foundry-shared", "version": "0.1.0", "private": true, "type": "module", diff --git a/foundry/packages/shared/src/app-shell.ts b/foundry/packages/shared/src/app-shell.ts new file mode 100644 index 0000000..8e757c5 --- /dev/null +++ b/foundry/packages/shared/src/app-shell.ts @@ -0,0 +1,98 @@ +export type FoundryBillingPlanId = "free" | "team"; +export type FoundryBillingStatus = "active" | "trialing" | "past_due" | "scheduled_cancel"; +export type FoundryGithubInstallationStatus = "connected" | "install_required" | "reconnect_required"; +export type FoundryGithubSyncStatus = "pending" | "syncing" | "synced" | "error"; +export type FoundryOrganizationKind = "personal" | "organization"; +export type FoundryStarterRepoStatus = "pending" | "starred" | "skipped"; + +export interface FoundryUser { + id: string; + name: string; + email: string; + githubLogin: string; + roleLabel: string; + eligibleOrganizationIds: string[]; +} + +export interface FoundryOrganizationMember { + id: string; + name: string; + email: string; + role: "owner" | "admin" | "member"; + state: "active" | "invited"; +} + +export interface FoundryInvoice { + id: string; + label: string; + issuedAt: string; + amountUsd: number; + status: "paid" | "open"; +} + +export interface FoundryBillingState { + planId: FoundryBillingPlanId; + status: FoundryBillingStatus; + seatsIncluded: number; + trialEndsAt: string | null; + renewalAt: string | null; + stripeCustomerId: string; + paymentMethodLabel: string; + invoices: FoundryInvoice[]; +} + +export interface FoundryGithubState { + connectedAccount: string; + installationStatus: FoundryGithubInstallationStatus; + syncStatus: FoundryGithubSyncStatus; + importedRepoCount: number; + lastSyncLabel: string; + lastSyncAt: number | null; +} + +export interface FoundryOrganizationSettings { + displayName: string; + slug: string; + primaryDomain: string; + seatAccrualMode: "first_prompt"; + defaultModel: "claude-sonnet-4" | "claude-opus-4" | "gpt-4o" | "o3"; + autoImportRepos: boolean; +} + +export interface FoundryOrganization { + id: string; + workspaceId: string; + kind: FoundryOrganizationKind; + settings: FoundryOrganizationSettings; + github: FoundryGithubState; + billing: FoundryBillingState; + members: FoundryOrganizationMember[]; + seatAssignments: string[]; + repoCatalog: string[]; +} + +export interface FoundryAppSnapshot { + auth: { + status: "signed_out" | "signed_in"; + currentUserId: string | null; + }; + activeOrganizationId: string | null; + onboarding: { + starterRepo: { + repoFullName: string; + repoUrl: string; + status: FoundryStarterRepoStatus; + starredAt: number | null; + skippedAt: number | null; + }; + }; + users: FoundryUser[]; + organizations: FoundryOrganization[]; +} + +export interface UpdateFoundryOrganizationProfileInput { + organizationId: string; + displayName: string; + slug: string; + primaryDomain: string; +} diff --git a/factory/packages/shared/src/config.ts b/foundry/packages/shared/src/config.ts similarity index 93% rename from factory/packages/shared/src/config.ts rename to foundry/packages/shared/src/config.ts index 710c531..8fd31df 100644 --- a/factory/packages/shared/src/config.ts +++ b/foundry/packages/shared/src/config.ts @@ -24,7 +24,7 @@ export const ConfigSchema = z.object({ .object({ host: z.string().default("127.0.0.1"), port: z.number().int().min(1).max(65535).default(7741), - dbPath: z.string().default("~/.local/share/openhandoff/handoff.db"), + dbPath: z.string().default("~/.local/share/foundry/task.db"), opencode_poll_interval: z.number().default(2), github_poll_interval: z.number().default(30), backup_interval_secs: z.number().default(3600), @@ -33,7 +33,7 @@ export const ConfigSchema = z.object({ .default({ host: "127.0.0.1", port: 7741, - dbPath: "~/.local/share/openhandoff/handoff.db", + dbPath: "~/.local/share/foundry/task.db", opencode_poll_interval: 2, github_poll_interval: 30, backup_interval_secs: 3600, diff --git a/factory/packages/shared/src/contracts.ts b/foundry/packages/shared/src/contracts.ts similarity index 86% rename from factory/packages/shared/src/contracts.ts rename to foundry/packages/shared/src/contracts.ts index 9722c3b..695a10f 100644 --- a/factory/packages/shared/src/contracts.ts +++ b/foundry/packages/shared/src/contracts.ts @@ -19,7 +19,7 @@ export type RepoId = z.infer; export const RepoRemoteSchema = z.string().min(1).max(2048); export type RepoRemote = z.infer; -export const HandoffStatusSchema = z.enum([ +export const TaskStatusSchema = z.enum([ "init_bootstrap_db", "init_enqueue_provision", "init_ensure_name", @@ -42,7 +42,7 @@ export const HandoffStatusSchema = z.enum([ "killed", "error", ]); -export type HandoffStatus = z.infer; +export type TaskStatus = z.infer; export const RepoRecordSchema = z.object({ workspaceId: WorkspaceIdSchema, @@ -59,29 +59,28 @@ export const AddRepoInputSchema = z.object({ }); export type AddRepoInput = z.infer; -export const CreateHandoffInputSchema = z.object({ +export const CreateTaskInputSchema = z.object({ workspaceId: WorkspaceIdSchema, repoId: RepoIdSchema, task: z.string().min(1), explicitTitle: z.string().trim().min(1).optional(), explicitBranchName: z.string().trim().min(1).optional(), - initialPrompt: z.string().optional(), providerId: ProviderIdSchema.optional(), agentType: AgentTypeSchema.optional(), onBranch: z.string().trim().min(1).optional(), }); -export type CreateHandoffInput = z.infer; +export type CreateTaskInput = z.infer; -export const HandoffRecordSchema = z.object({ +export const TaskRecordSchema = z.object({ workspaceId: WorkspaceIdSchema, repoId: z.string().min(1), repoRemote: RepoRemoteSchema, - handoffId: z.string().min(1), + taskId: z.string().min(1), branchName: z.string().min(1).nullable(), title: z.string().min(1).nullable(), task: z.string().min(1), providerId: ProviderIdSchema, - status: HandoffStatusSchema, + status: TaskStatusSchema, statusMessage: z.string().nullable(), activeSandboxId: z.string().nullable(), activeSessionId: z.string().nullable(), @@ -110,38 +109,38 @@ export const HandoffRecordSchema = z.object({ createdAt: z.number().int(), updatedAt: z.number().int(), }); -export type HandoffRecord = z.infer; +export type TaskRecord = z.infer; -export const HandoffSummarySchema = z.object({ +export const TaskSummarySchema = z.object({ workspaceId: WorkspaceIdSchema, repoId: z.string().min(1), - handoffId: z.string().min(1), + taskId: z.string().min(1), branchName: z.string().min(1).nullable(), title: z.string().min(1).nullable(), - status: HandoffStatusSchema, + status: TaskStatusSchema, updatedAt: z.number().int(), }); -export type HandoffSummary = z.infer; +export type TaskSummary = z.infer; -export const HandoffActionInputSchema = z.object({ +export const TaskActionInputSchema = z.object({ workspaceId: WorkspaceIdSchema, - handoffId: z.string().min(1), + taskId: z.string().min(1), }); -export type HandoffActionInput = z.infer; +export type TaskActionInput = z.infer; export const SwitchResultSchema = z.object({ workspaceId: WorkspaceIdSchema, - handoffId: z.string().min(1), + taskId: z.string().min(1), providerId: ProviderIdSchema, switchTarget: z.string().min(1), }); export type SwitchResult = z.infer; -export const ListHandoffsInputSchema = z.object({ +export const ListTasksInputSchema = z.object({ workspaceId: WorkspaceIdSchema, repoId: RepoIdSchema.optional(), }); -export type ListHandoffsInput = z.infer; +export type ListTasksInput = z.infer; export const RepoBranchRecordSchema = z.object({ branchName: z.string().min(1), @@ -151,9 +150,9 @@ export const RepoBranchRecordSchema = z.object({ diffStat: z.string().nullable(), hasUnpushed: z.boolean(), conflictsWithMain: z.boolean(), - handoffId: z.string().nullable(), - handoffTitle: z.string().nullable(), - handoffStatus: HandoffStatusSchema.nullable(), + taskId: z.string().nullable(), + taskTitle: z.string().nullable(), + taskStatus: TaskStatusSchema.nullable(), prNumber: z.number().int().nullable(), prState: z.string().nullable(), prUrl: z.string().nullable(), @@ -217,7 +216,7 @@ export const HistoryQueryInputSchema = z.object({ workspaceId: WorkspaceIdSchema, limit: z.number().int().positive().max(500).optional(), branch: z.string().min(1).optional(), - handoffId: z.string().min(1).optional(), + taskId: z.string().min(1).optional(), }); export type HistoryQueryInput = z.infer; @@ -225,7 +224,7 @@ export const HistoryEventSchema = z.object({ id: z.number().int(), workspaceId: WorkspaceIdSchema, repoId: z.string().nullable(), - handoffId: z.string().nullable(), + taskId: z.string().nullable(), branchName: z.string().nullable(), kind: z.string().min(1), payloadJson: z.string().min(1), @@ -242,7 +241,7 @@ export type PruneInput = z.infer; export const KillInputSchema = z.object({ workspaceId: WorkspaceIdSchema, - handoffId: z.string().min(1), + taskId: z.string().min(1), deleteBranch: z.boolean(), abandon: z.boolean(), }); diff --git a/factory/packages/shared/src/index.ts b/foundry/packages/shared/src/index.ts similarity index 79% rename from factory/packages/shared/src/index.ts rename to foundry/packages/shared/src/index.ts index 4634e8b..a8002e0 100644 --- a/factory/packages/shared/src/index.ts +++ b/foundry/packages/shared/src/index.ts @@ -1,3 +1,4 @@ +export * from "./app-shell.js"; export * from "./contracts.js"; export * from "./config.js"; export * from "./workbench.js"; diff --git a/factory/packages/shared/src/workbench.ts b/foundry/packages/shared/src/workbench.ts similarity index 71% rename from factory/packages/shared/src/workbench.ts rename to foundry/packages/shared/src/workbench.ts index 683c372..82c458b 100644 --- a/factory/packages/shared/src/workbench.ts +++ b/foundry/packages/shared/src/workbench.ts @@ -1,4 +1,4 @@ -export type WorkbenchHandoffStatus = "running" | "idle" | "new" | "archived"; +export type WorkbenchTaskStatus = "running" | "idle" | "new" | "archived"; export type WorkbenchAgentKind = "Claude" | "Codex" | "Cursor"; export type WorkbenchModelId = "claude-sonnet-4" | "claude-opus-4" | "gpt-4o" | "o3"; @@ -76,11 +76,11 @@ export interface WorkbenchPullRequestSummary { status: "draft" | "ready"; } -export interface WorkbenchHandoff { +export interface WorkbenchTask { id: string; repoId: string; title: string; - status: WorkbenchHandoffStatus; + status: WorkbenchTaskStatus; repoName: string; updatedAtMs: number; branch: string | null; @@ -100,14 +100,14 @@ export interface WorkbenchProjectSection { id: string; label: string; updatedAtMs: number; - handoffs: WorkbenchHandoff[]; + tasks: WorkbenchTask[]; } -export interface HandoffWorkbenchSnapshot { +export interface TaskWorkbenchSnapshot { workspaceId: string; repos: WorkbenchRepo[]; projects: WorkbenchProjectSection[]; - handoffs: WorkbenchHandoff[]; + tasks: WorkbenchTask[]; } export interface WorkbenchModelOption { @@ -120,63 +120,62 @@ export interface WorkbenchModelGroup { models: WorkbenchModelOption[]; } -export interface HandoffWorkbenchSelectInput { - handoffId: string; +export interface TaskWorkbenchSelectInput { + taskId: string; } -export interface HandoffWorkbenchCreateHandoffInput { +export interface TaskWorkbenchCreateTaskInput { repoId: string; task: string; title?: string; branch?: string; model?: WorkbenchModelId; - initialPrompt?: string; } -export interface HandoffWorkbenchRenameInput { - handoffId: string; +export interface TaskWorkbenchRenameInput { + taskId: string; value: string; } -export interface HandoffWorkbenchSendMessageInput { - handoffId: string; +export interface TaskWorkbenchSendMessageInput { + taskId: string; tabId: string; text: string; attachments: WorkbenchLineAttachment[]; } -export interface HandoffWorkbenchTabInput { - handoffId: string; +export interface TaskWorkbenchTabInput { + taskId: string; tabId: string; } -export interface HandoffWorkbenchRenameSessionInput extends HandoffWorkbenchTabInput { +export interface TaskWorkbenchRenameSessionInput extends TaskWorkbenchTabInput { title: string; } -export interface HandoffWorkbenchChangeModelInput extends HandoffWorkbenchTabInput { +export interface TaskWorkbenchChangeModelInput extends TaskWorkbenchTabInput { model: WorkbenchModelId; } -export interface HandoffWorkbenchUpdateDraftInput extends HandoffWorkbenchTabInput { +export interface TaskWorkbenchUpdateDraftInput extends TaskWorkbenchTabInput { text: string; attachments: WorkbenchLineAttachment[]; } -export interface HandoffWorkbenchSetSessionUnreadInput extends HandoffWorkbenchTabInput { +export interface TaskWorkbenchSetSessionUnreadInput extends TaskWorkbenchTabInput { unread: boolean; } -export interface HandoffWorkbenchDiffInput { - handoffId: string; +export interface TaskWorkbenchDiffInput { + taskId: string; path: string; } -export interface HandoffWorkbenchCreateHandoffResponse { - handoffId: string; +export interface TaskWorkbenchCreateTaskResponse { + taskId: string; tabId?: string; } -export interface HandoffWorkbenchAddTabResponse { +export interface TaskWorkbenchAddTabResponse { tabId: string; } diff --git a/factory/packages/shared/src/workspace.ts b/foundry/packages/shared/src/workspace.ts similarity index 100% rename from factory/packages/shared/src/workspace.ts rename to foundry/packages/shared/src/workspace.ts diff --git a/factory/packages/shared/test/workspace.test.ts b/foundry/packages/shared/test/workspace.test.ts similarity index 94% rename from factory/packages/shared/test/workspace.test.ts rename to foundry/packages/shared/test/workspace.test.ts index 54224b6..ab596ac 100644 --- a/factory/packages/shared/test/workspace.test.ts +++ b/foundry/packages/shared/test/workspace.test.ts @@ -8,7 +8,7 @@ const cfg: AppConfig = ConfigSchema.parse({ backend: { host: "127.0.0.1", port: 7741, - dbPath: "~/.local/share/openhandoff/handoff.db", + dbPath: "~/.local/share/foundry/task.db", opencode_poll_interval: 2, github_poll_interval: 30, backup_interval_secs: 3600, diff --git a/factory/packages/shared/tsconfig.json b/foundry/packages/shared/tsconfig.json similarity index 100% rename from factory/packages/shared/tsconfig.json rename to foundry/packages/shared/tsconfig.json diff --git a/factory/railway.toml b/foundry/railway.toml similarity index 100% rename from factory/railway.toml rename to foundry/railway.toml diff --git a/factory/research/friction/general.mdx b/foundry/research/friction/general.mdx similarity index 85% rename from factory/research/friction/general.mdx rename to foundry/research/friction/general.mdx index e2a3d13..45944b5 100644 --- a/factory/research/friction/general.mdx +++ b/foundry/research/friction/general.mdx @@ -4,34 +4,34 @@ ### What I Was Working On -Verifying the BaseUI frontend against the real `rivet-dev/handoff-testing` repo, creating live PR-backed handoffs, and driving the flow through the browser. +Verifying the BaseUI frontend against the real `rivet-dev/task-testing` repo, creating live PR-backed tasks, and driving the flow through the browser. ### Friction / Issue Three separate issues stacked together during live verification: -1. A half-created handoff actor remained in project indexes after earlier runtime failures. The actor state existed, but its durable handoff row did not, so repo overview polling spammed `Handoff not found` and kept trying to load an orphaned handoff. +1. A half-created task actor remained in project indexes after earlier runtime failures. The actor state existed, but its durable task row did not, so repo overview polling spammed `Task not found` and kept trying to load an orphaned task. 2. Rebuilding the backend container outside `just dev` dropped injected GitHub auth, which made repo overview fall back to `Open PRs 0` until `GITHUB_TOKEN`/`GH_TOKEN` were passed back into `docker compose`. -3. In the create-handoff modal, the BaseUI-controlled form looked populated in the browser, but submit gating/click behavior was unreliable under browser automation, making it hard to distinguish frontend state bugs from backend failures. +3. In the create-task modal, the BaseUI-controlled form looked populated in the browser, but submit gating/click behavior was unreliable under browser automation, making it hard to distinguish frontend state bugs from backend failures. ### Attempted Fix / Workaround -1. Updated project-actor stale handoff pruning to treat `Handoff not found:` the same as actor-not-found and rebuilt the backend image. -2. Recovered the orphaned handoff by forcing an initialize attempt, which surfaced a missing `body?.providerId` guard in the handoff init workflow and led to pruning the stale project index row. +1. Updated project-actor stale task pruning to treat `Task not found:` the same as actor-not-found and rebuilt the backend image. +2. Recovered the orphaned task by forcing an initialize attempt, which surfaced a missing `body?.providerId` guard in the task init workflow and led to pruning the stale project index row. 3. Recreated the backend with `GITHUB_TOKEN="$(gh auth token)" GH_TOKEN="$(gh auth token)" docker compose ... up -d --build backend` so PR sync could see live GitHub data again. -4. Used `agent-browser` plus screenshots to separate working paths (repo overview + PR visibility) from the remaining broken path (modal submit / handoff creation UI). +4. Used `agent-browser` plus screenshots to separate working paths (repo overview + PR visibility) from the remaining broken path (modal submit / task creation UI). ### Outcome -- Live repo overview now shows the real `handoff-testing` PRs again. -- The stale handoff actor no longer blocks repo overview polling. -- The remaining blocker is narrowed to the frontend create-handoff interaction path, plus missing agent API credentials for exercising real agent messaging end to end. +- Live repo overview now shows the real `task-testing` PRs again. +- The stale task actor no longer blocks repo overview polling. +- The remaining blocker is narrowed to the frontend create-task interaction path, plus missing agent API credentials for exercising real agent messaging end to end. ## 2026-03-06 - uncommitted ### What I Was Working On -Exercising the live selected-handoff UI end to end, including session creation, prompt send, and agent response rendering. +Exercising the live selected-task UI end to end, including session creation, prompt send, and agent response rendering. ### Friction / Issue @@ -46,7 +46,7 @@ The Docker dev backend container was starting on Bun `1.2.23` and accepting TCP ### Outcome - Dev-runtime debugging is narrowed from "backend/UI path is broken" to a concrete Docker Bun version issue. -- After rebuild, the next verification step is the real selected-handoff transcript flow with agent messaging. +- After rebuild, the next verification step is the real selected-task transcript flow with agent messaging. ## 2026-02-17 - uncommitted @@ -97,11 +97,11 @@ The `gs` binary on this environment resolves to Ghostscript (`/usr/bin/gs`), not ### What I Was Working On -Fixing Daytona `hf create` failures where `handoff.attach` would exhaust retries with `Handoff not found`. +Fixing Daytona `hf create` failures where `task.attach` would exhaust retries with `Task not found`. ### Friction / Issue -OpenHandoff was using RivetKit's KV-backed durable SQLite VFS via `rivetkit/db/drizzle`, which opens the SQLite DB keyed by `ctx.actorId`. Since actor instances can be rescheduled (new `actorId`) between requests, DB writes from initialization were not visible to later actions (e.g. `attach`), causing “Handoff not found” and action timeouts. +Foundry was using RivetKit's KV-backed durable SQLite VFS via `rivetkit/db/drizzle`, which opens the SQLite DB keyed by `ctx.actorId`. Since actor instances can be rescheduled (new `actorId`) between requests, DB writes from initialization were not visible to later actions (e.g. `attach`), causing “Task not found” errors and action timeouts. Separately, importing `bun:sqlite` directly broke: @@ -194,7 +194,7 @@ Backend integration tests depend on native `better-sqlite3` bindings, which were ### Attempted Fix / Workaround -1. Attempted `pnpm --filter @openhandoff/backend rebuild better-sqlite3`. +1. Attempted `pnpm --filter @sandbox-agent/foundry-backend rebuild better-sqlite3`. 2. Added runtime capability detection in DB-backed backend tests. 3. Marked DB-backed tests with `it.skipIf(!hasBetterSqliteBinding)` so tests run when native bindings exist and skip cleanly otherwise. @@ -280,7 +280,7 @@ Running backend tests with the integration flag enabled triggered unrelated acto ### Attempted Fix / Workaround -1. Switched to package-targeted test runs for deterministic coverage (`@openhandoff/backend` + `@openhandoff/frontend`). +1. Switched to package-targeted test runs for deterministic coverage (`@sandbox-agent/foundry-backend` + `@sandbox-agent/foundry-frontend`). 2. Relied on required workspace validation (`pnpm -w typecheck`, `pnpm -w build`, `pnpm -w test`) plus targeted stack test files. 3. Stopped the runaway integration run and recorded this friction for follow-up. @@ -335,7 +335,7 @@ Docker Desktop recovered, but the frontend container failed immediately with `Ca ### What I Was Working On -Verifying the selected-handoff UI flow end to end in the browser: create repo, create handoff, select the handoff, start an agent session, and send a follow-up message. +Verifying the selected-task UI flow end to end in the browser: create repo, create task, select the task, start an agent session, and send a follow-up message. ### Friction / Issue @@ -354,6 +354,6 @@ Local dev hit three stacked runtime issues during live UI verification: ### Outcome -- The browser flow now reaches the real selected-handoff transcript screen. -- Handoff creation and initial session creation work in the UI against the local provider. +- The browser flow now reaches the real selected-task transcript screen. +- Task creation and initial session creation work in the UI against the local provider. - A remaining upstream auth/runtime blocker still prevents a clean verified assistant text response in the final follow-up-message step, so that part of the end-to-end flow is not yet reliable enough to claim complete. diff --git a/factory/research/friction/rivet.mdx b/foundry/research/friction/rivet.mdx similarity index 84% rename from factory/research/friction/rivet.mdx rename to foundry/research/friction/rivet.mdx index ce06f0e..878cc26 100644 --- a/factory/research/friction/rivet.mdx +++ b/foundry/research/friction/rivet.mdx @@ -4,16 +4,16 @@ ### What I Was Working On -Debugging handoffs stuck in `init_create_sandbox` and diagnosing why failures were not obvious in the UI. +Debugging tasks stuck in `init_create_sandbox` and diagnosing why failures were not obvious in the UI. ### Friction / Issue -1. Workflow failure detection is opaque during long-running provisioning steps: the handoff can remain in a status (for example `init_create_sandbox`) without clear indication of whether it is still progressing, stalled, or failed-but-unsurfaced. +1. Workflow failure detection is opaque during long-running provisioning steps: the task can remain in a status (for example `init_create_sandbox`) without clear indication of whether it is still progressing, stalled, or failed-but-unsurfaced. 2. Frontend monitoring of current workflow state is too coarse for diagnosis: users can see a status label but not enough live step-level context (last progress timestamp, in-flight substep, provider command phase, or timeout boundary) to understand what is happening. ### Attempted Fix / Workaround -1. Correlated handoff status/history with backend logs and provider-side sandbox state to determine where execution actually stopped. +1. Correlated task status/history with backend logs and provider-side sandbox state to determine where execution actually stopped. 2. Manually probed provider behavior outside the workflow to separate Daytona resource creation from provider post-create initialization. ### Outcome @@ -25,7 +25,7 @@ Debugging handoffs stuck in `init_create_sandbox` and diagnosing why failures we ### What I Was Working On -Root-causing handoffs stuck in `init_create_session` / missing transcripts and archive actions hanging during codex Daytona E2E. +Root-causing tasks stuck in `init_create_session` / missing transcripts and archive actions hanging during codex Daytona E2E. ### Friction / Issue @@ -44,7 +44,7 @@ Root-causing handoffs stuck in `init_create_session` / missing transcripts and a ### Outcome -- New handoffs now resolve session/event reads against the correct actor identity, restoring transcript continuity. +- New tasks now resolve session/event reads against the correct actor identity, restoring transcript continuity. - Archive no longer hangs user-facing action completion on slow provider teardown. - Patterns are now documented in `AGENTS.md`/`PRD.md` to prevent reintroducing the same class of bug. - Follow-up: update the RivetKit skill guidance to explicitly teach `get` vs `create` workflow intent (and avoid default `getOrCreate` in non-provisioning paths). @@ -53,23 +53,23 @@ Root-causing handoffs stuck in `init_create_session` / missing transcripts and a ### What I Was Working On -Hardening handoff initialization around sandbox-agent session bootstrap failures (`init_create_session`) and replay safety for already-running workflows. +Hardening task initialization around sandbox-agent session bootstrap failures (`init_create_session`) and replay safety for already-running workflows. ### Friction / Issue -1. New handoffs repeatedly failed with ACP 504 timeouts during `createSession`, leaving handoffs in `error` without a session/transcript. -2. Existing handoffs created before workflow step refactors emitted repeated `HistoryDivergedError` (`init-failed` / `init-enqueue-provision`) after backend restarts. +1. New tasks repeatedly failed with ACP 504 timeouts during `createSession`, leaving tasks in `error` without a session/transcript. +2. Existing tasks created before workflow step refactors emitted repeated `HistoryDivergedError` (`init-failed` / `init-enqueue-provision`) after backend restarts. ### Attempted Fix / Workaround 1. Added transient retry/backoff in `sandbox-instance.createSession` (timeout/502/503/504/gateway-class failures), with explicit terminal error detail after retries are exhausted. -2. Increased handoff workflow `init-create-session` step timeout to allow retry envelope. +2. Increased task workflow `init-create-session` step timeout to allow retry envelope. 3. Added workflow migration guards via `ctx.removed()` for legacy step names and moved failure handling to `init-failed-v2`. -4. Added integration test coverage for retry success and retry exhaustion, plus client E2E assertion that a created handoff must produce session events (transcript bootstrap) before proceeding. +4. Added integration test coverage for retry success and retry exhaustion, plus client E2E assertion that a created task must produce session events (transcript bootstrap) before proceeding. ### Outcome -- New handoffs now fail fast with explicit, surfaced error text (`createSession failed after N attempts: ...`) instead of opaque init hangs. +- New tasks now fail fast with explicit, surfaced error text (`createSession failed after N attempts: ...`) instead of opaque init hangs. - Recent backend logs stopped emitting new `HistoryDivergedError` for the migrated legacy step names. - Upstream ACP timeout behavior still occurs in this environment and remains the blocking issue for successful session creation. @@ -77,22 +77,22 @@ Hardening handoff initialization around sandbox-agent session bootstrap failures ### What I Was Working On -Diagnosing stuck handoffs (`init_create_sandbox`) after switching to a linked RivetKit worktree and restarting the backend. +Diagnosing stuck tasks (`init_create_sandbox`) after switching to a linked RivetKit worktree and restarting the backend. ### Friction / Issue 1. File-system driver actor-state writes still attempted to serialize legacy `kvStorage`, which can exceed Bare's buffer limit and trigger `Failed to save actor state: BareError: (byte:0) too large buffer`. -2. Project snapshots swallowed missing handoff actors and only logged warnings, so stale `handoff_index` rows persisted and appeared as stuck/ghost handoffs in the UI. +2. Project snapshots swallowed missing task actors and only logged warnings, so stale `task_index` rows persisted and appeared as stuck/ghost tasks in the UI. ### Attempted Fix / Workaround 1. In RivetKit file-system driver writes, force persisted `kvStorage` to `[]` (runtime KV is SQLite-only) so oversized legacy payloads are never re-serialized. -2. In backend project actor flows (`hydrate`, `snapshot`, `repo overview`, branch registration, PR-close archive), detect `Actor not found` and prune stale `handoff_index` rows immediately. +2. In backend project actor flows (`hydrate`, `snapshot`, `repo overview`, branch registration, PR-close archive), detect `Actor not found` and prune stale `task_index` rows immediately. ### Outcome - Prevents repeated serialization crashes caused by legacy oversized state blobs. -- Missing handoff actors are now self-healed from project indexes instead of repeatedly surfacing as silent warnings. +- Missing task actors are now self-healed from project indexes instead of repeatedly surfacing as silent warnings. ## 2026-02-12 - uncommitted @@ -112,7 +112,7 @@ Running `compose.dev.yaml` end-to-end (backend + frontend) and driving the brows ### Outcome -- The compose stack can be driven via `agent-browser` to create a handoff successfully. +- The compose stack can be driven via `agent-browser` to create a task successfully. - Sandbox sessions still require a reachable sandbox-agent endpoint (worktree provider defaults to `http://127.0.0.1:4097`, which is container-local in Docker). ## 2026-02-12 - uncommitted @@ -129,7 +129,7 @@ SQLite usage in actors needs a clear separation from “simple state” to avoid Adopt a hard rule of thumb: -- **Use `c.state` (basic KV-backed state)** for simple actor-local values: small scalars and identifiers (e.g. `{ handoffId }`), flags, counters, last-run timestamps, current status strings. +- **Use `c.state` (basic KV-backed state)** for simple actor-local values: small scalars and identifiers (e.g. `{ taskId }`), flags, counters, last-run timestamps, current status strings. - **Use SQLite (Drizzle) for anything else**: multi-row datasets, history/event logs, query/filter needs, consistency across multiple records, data you expect to inspect/debug outside the actor. ### Outcome @@ -140,7 +140,7 @@ Captured the guidance here so future actor work doesn’t mix the two models arb ### What I Was Working On -Standardizing SQLite + Drizzle setup for RivetKit actors (migration-per-actor) to match the `rivet/examples/sandbox` pattern while keeping the OpenHandoff repo TypeScript-only. +Standardizing SQLite + Drizzle setup for RivetKit actors (migration-per-actor) to match the `rivet/examples/sandbox` pattern while keeping the Foundry repo TypeScript-only. ### Friction / Issue @@ -163,7 +163,7 @@ Adopt these concrete repo conventions: - Schema rule (critical): - SQLite is **per actor instance**, not a shared DB across all instances. -- Do not “namespace” rows with `workspaceId`/`repoId`/`handoffId` columns when those identifiers already live in the actor key/state. +- Do not “namespace” rows with `workspaceId`/`repoId`/`taskId` columns when those identifiers already live in the actor key/state. - Prefer single-row tables for single-instance storage (e.g. `id=1`) when appropriate. - Migration generation flow (Bun + DrizzleKit): @@ -174,7 +174,7 @@ Adopt these concrete repo conventions: - Per-actor migration tracking tables: - Even if all actors share one SQLite file, each actor must use its own migration table, e.g. - - `__openhandoff_migrations_` + - `__foundry_migrations_` - `migrationNamespace` should be stable and sanitized to `[a-z0-9_]`. - Provider wiring pattern inside an actor: @@ -195,7 +195,7 @@ Captured the exact folder layout + script workflow so future actor DB work can f ### What I Was Working On -Diagnosing `StepExhaustedError` surfacing as `unknown error` during step replay (affecting OpenHandoff Daytona `hf create`). +Diagnosing `StepExhaustedError` surfacing as `unknown error` during step replay (affecting Foundry Daytona `hf create`). ### Friction / Issue @@ -203,7 +203,7 @@ The workflow engine treated “step completed” as `stepData.output !== undefin ### Attempted Fix / Workaround -- None in OpenHandoff; this is a workflow-engine correctness bug. +- None in Foundry; this is a workflow-engine correctness bug. ### Outcome @@ -213,23 +213,23 @@ The workflow engine treated “step completed” as `stepData.output !== undefin ### What I Was Working On -Verifying Daytona-backed handoff/session flows for the new frontend and sandbox-instance session API. +Verifying Daytona-backed task/session flows for the new frontend and sandbox-instance session API. ### Friction / Issue -Handoff workflow steps intermittently entered failed state with `StepExhaustedError` and `unknown error` during initialization replay (`init-start-sandbox-instance`, then `init-write-db`), which caused `handoff.get` to time out and cascaded into `project snapshot timed out` / `workspace list_handoffs timed out`. +Task workflow steps intermittently entered failed state with `StepExhaustedError` and `unknown error` during initialization replay (`init-start-sandbox-instance`, then `init-write-db`), which caused `task.get` to time out and cascaded into `project snapshot timed out` / `workspace list_tasks timed out`. ### Attempted Fix / Workaround 1. Hardened `sandbox-instance` queue actions to return structured `{ ok, data?, error? }` responses instead of crashing the actor run loop. 2. Increased `sandboxInstance.ensure` queue timeout and validated queue responses in action wrappers. -3. Made `handoff` initialization step `init-start-sandbox-instance` non-fatal and captured step errors into runtime status. +3. Made `task` initialization step `init-start-sandbox-instance` non-fatal and captured step errors into runtime status. 4. Guarded `sandboxInstance.getOrCreate` inside the same non-fatal `try` block to prevent direct step failures. ### Outcome - Browser/frontend implementation and backend build/tests are green. -- Daytona workflow initialization still has an unresolved Rivet workflow replay failure path that can poison handoff state after creation. +- Daytona workflow initialization still has an unresolved Rivet workflow replay failure path that can poison task state after creation. - Follow-up needed in actor workflow error instrumentation/replay semantics before Daytona E2E can be marked stable. ## 2026-02-08 - f2f2a02 @@ -394,10 +394,10 @@ Even with queue-timeout ticks, packing multiple independent timer cadences into ### Final Pattern 1. **Parent actors are command-only loops with no timeout.** -- `WorkspaceActor`, `ProjectActor`, `HandoffActor`, and `HistoryActor` wait on queue messages only. +- `WorkspaceActor`, `ProjectActor`, `TaskActor`, and `HistoryActor` wait on queue messages only. 2. **Periodic work moves to dedicated child sync actors.** -- Each child actor has exactly one timeout cadence (e.g. PR sync, branch sync, handoff status sync). +- Each child actor has exactly one timeout cadence (e.g. PR sync, branch sync, task status sync). - Child actors are read-only pollers and send results back to the parent actor. 3. **Single-writer focus per actor design.** @@ -412,8 +412,8 @@ Even with queue-timeout ticks, packing multiple independent timer cadences into - `ProjectActor` (no timeout): handles commands + applies `project.pr_sync.result` / `project.branch_sync.result` writes. - `ProjectPrSyncActor` (timeout 30s): polls PR data, sends result message. - `ProjectBranchSyncActor` (timeout 5s): polls branch data, sends result message. -- `HandoffActor` (no timeout): handles lifecycle + applies `handoff.status_sync.result` writes. -- `HandoffStatusSyncActor` (timeout 2s): polls session/sandbox status, sends result message. +- `TaskActor` (no timeout): handles lifecycle + applies `task.status_sync.result` writes. +- `TaskStatusSyncActor` (timeout 2s): polls session/sandbox status, sends result message. ### Outcome @@ -472,7 +472,7 @@ Removing custom backend REST endpoints and migrating CLI/TUI calls to direct `ri ### Friction / Issue -We had implemented a `/v1/*` HTTP shim (`/v1/handoffs`, `/v1/workspaces/use`, etc.) between clients and actors, which duplicated actor APIs and introduced an unnecessary transport layer. +We had implemented a `/v1/*` HTTP shim (`/v1/tasks`, `/v1/workspaces/use`, etc.) between clients and actors, which duplicated actor APIs and introduced an unnecessary transport layer. ### Attempted Fix / Workaround @@ -545,21 +545,21 @@ Removing `*Actor` suffix from all actor export names and registry keys. ### Friction / Issue -RivetKit's `setup({ use: { ... } })` uses property names as actor identifiers in `client.` calls. All 8 actors were exported as `workspaceActor`, `projectActor`, `handoffActor`, etc., which meant client code used verbose `client.workspaceActor.getOrCreate(...)` instead of `client.workspace.getOrCreate(...)`. +RivetKit's `setup({ use: { ... } })` uses property names as actor identifiers in `client.` calls. All 8 actors were exported as `workspaceActor`, `projectActor`, `taskActor`, etc., which meant client code used verbose `client.workspaceActor.getOrCreate(...)` instead of `client.workspace.getOrCreate(...)`. -The `Actor` suffix is redundant — everything in the registry is an actor by definition. It also leaked into type names (`WorkspaceActorHandle`, `ProjectActorInput`, `HistoryActorInput`) and local function names (`workspaceActorKey`, `handoffActorKey`). +The `Actor` suffix is redundant — everything in the registry is an actor by definition. It also leaked into type names (`WorkspaceActorHandle`, `ProjectActorInput`, `HistoryActorInput`) and local function names (`workspaceActorKey`, `taskActorKey`). ### Attempted Fix / Workaround -1. Renamed all 8 actor exports: `workspaceActor` → `workspace`, `projectActor` → `project`, `handoffActor` → `handoff`, `sandboxInstanceActor` → `sandboxInstance`, `historyActor` → `history`, `projectPrSyncActor` → `projectPrSync`, `projectBranchSyncActor` → `projectBranchSync`, `handoffStatusSyncActor` → `handoffStatusSync`. +1. Renamed all 8 actor exports: `workspaceActor` → `workspace`, `projectActor` → `project`, `taskActor` → `task`, `sandboxInstanceActor` → `sandboxInstance`, `historyActor` → `history`, `projectPrSyncActor` → `projectPrSync`, `projectBranchSyncActor` → `projectBranchSync`, `taskStatusSyncActor` → `taskStatusSync`. 2. Updated registry keys in `actors/index.ts`. 3. Renamed all `client.Actor` references across 14 files (actor definitions, backend entry, CLI client, tests). -4. Renamed associated types (`ProjectActorInput` → `ProjectInput`, `HistoryActorInput` → `HistoryInput`, `WorkspaceActorHandle` → `WorkspaceHandle`, `HandoffActorHandle` → `HandoffHandle`). +4. Renamed associated types (`ProjectActorInput` → `ProjectInput`, `HistoryActorInput` → `HistoryInput`, `WorkspaceActorHandle` → `WorkspaceHandle`, `TaskActorHandle` → `TaskHandle`). ### Outcome - Actor names are now concise and match their semantic role. -- Client code reads naturally: `client.workspace.getOrCreate(...)`, `client.handoff.get(...)`. +- Client code reads naturally: `client.workspace.getOrCreate(...)`, `client.task.get(...)`. - No runtime behavior change — registry property names drive actor routing. ## 2026-02-09 - uncommitted @@ -580,8 +580,8 @@ Concrete examples from our codebase: | Actor | Pattern | Why | |-------|---------|-----| | `workspace` | Plain run | Every handler is a DB query or single actor delegation | -| `project` | Plain run | Handlers are DB upserts or delegate to handoff actor | -| `handoff` | **Needs workflow** | `initialize` is a 7-step pipeline (createSandbox → ensureAgent → createSession → DB writes → start child actors); post-idle is a 5-step pipeline (commit → push → PR → cache → notify) | +| `project` | Plain run | Handlers are DB upserts or delegate to task actor | +| `task` | **Needs workflow** | `initialize` is a 7-step pipeline (createSandbox → ensureAgent → createSession → DB writes → start child actors); post-idle is a 5-step pipeline (commit → push → PR → cache → notify) | | `history` | Plain run | Single DB insert per message | | `sandboxInstance` | Plain run | Single-table CRUD per message | | `*Sync` actors (3) | Plain run | Infinite timeout-driven polling loops, not finite sequences | @@ -596,7 +596,7 @@ RivetKit docs should articulate this heuristic explicitly: ### Outcome -- Identified `handoff` actor as the only actor needing workflow migration (both `initialize` and post-idle pipelines). +- Identified `task` actor as the only actor needing workflow migration (both `initialize` and post-idle pipelines). - All other actors stay as plain `run` loops. - This heuristic should be documented in RivetKit's actor design patterns guide. @@ -604,11 +604,11 @@ RivetKit docs should articulate this heuristic explicitly: ### What I Was Working On -Understanding queue message scoping when planning workflow migration for the handoff actor. +Understanding queue message scoping when planning workflow migration for the task actor. ### Friction / Issue -It's not clear from RivetKit docs/API that queue message names are scoped per actor instance, not global. When you call `c.queue.next(["handoff.command.initialize", ...])`, those names only match messages sent to *this specific actor instance* — not a global bus. But the dotted naming convention (e.g. `handoff.command.initialize`) suggests a global namespace/routing scheme, which is misleading. +It's not clear from RivetKit docs/API that queue message names are scoped per actor instance, not global. When you call `c.queue.next(["task.command.initialize", ...])`, those names only match messages sent to *this specific actor instance* — not a global bus. But the dotted naming convention (e.g. `task.command.initialize`) suggests a global namespace/routing scheme, which is misleading. This matters when reasoning about workflow `listen()` behavior: you might assume you need globally unique names or worry about cross-actor message collisions, when in reality each actor instance has its own isolated queue namespace. @@ -628,19 +628,19 @@ RivetKit docs should clarify: ### What I Was Working On -Migrating handoff actor to durable workflows. AI-generated queue names used dotted convention. +Migrating task actor to durable workflows. AI-generated queue names used dotted convention. ### Friction / Issue -When generating actor queue names, the AI (and our own codebase) defaulted to dotted names like `handoff.command.initialize`, `project.pr_sync.result`, `handoff.status_sync.control.start`. These work fine in plain `run` loops, but create friction when interacting with the workflow system because `workflowQueueName()` prefixes them with `__workflow:`, producing names like `__workflow:handoff.command.initialize`. +When generating actor queue names, the AI (and our own codebase) defaulted to dotted names like `task.command.initialize`, `project.pr_sync.result`, `task.status_sync.control.start`. These work fine in plain `run` loops, but create friction when interacting with the workflow system because `workflowQueueName()` prefixes them with `__workflow:`, producing names like `__workflow:task.command.initialize`. -Queue names should always be **camelCase** (e.g. `initializeHandoff`, `statusSyncResult`, `attachHandoff`). Dotted names are misleading — they imply hierarchy or routing semantics that don't exist (queues are flat, per-actor-instance strings). They also look like object property paths, which causes confusion when used as dynamic property keys on queue handles (`actor.queue["handoff.command.initialize"]`). +Queue names should always be **camelCase** (e.g. `initializeTask`, `statusSyncResult`, `attachTask`). Dotted names are misleading — they imply hierarchy or routing semantics that don't exist (queues are flat, per-actor-instance strings). They also look like object property paths, which causes confusion when used as dynamic property keys on queue handles (`actor.queue["task.command.initialize"]`). ### Decision / Guidance RivetKit docs and examples should establish: -1. **Queue names must be camelCase** — e.g. `initialize`, `attach`, `statusSyncResult`, not `handoff.command.initialize`. +1. **Queue names must be camelCase** — e.g. `initialize`, `attach`, `statusSyncResult`, not `task.command.initialize`. 2. **No dots in queue names** — dots suggest hierarchy that doesn't exist and conflict with JS property access patterns. 3. **AI code generation guidance** should explicitly call this out, since LLMs tend to generate dotted names when given actor/queue context. diff --git a/factory/research/friction/sandbox-agent.mdx b/foundry/research/friction/sandbox-agent.mdx similarity index 83% rename from factory/research/friction/sandbox-agent.mdx rename to foundry/research/friction/sandbox-agent.mdx index 647c826..aa4a29d 100644 --- a/factory/research/friction/sandbox-agent.mdx +++ b/foundry/research/friction/sandbox-agent.mdx @@ -4,7 +4,7 @@ ### What I Was Working On -Stabilizing Daytona-backed Codex handoff initialization (`init_create_session`) and diagnosing repeated sandbox-agent `session/new` failures. +Stabilizing Daytona-backed Codex task initialization (`init_create_session`) and diagnosing repeated sandbox-agent `session/new` failures. ### Friction / Issue @@ -30,7 +30,7 @@ Two issues compounded each other: ### What I Was Working On -Wiring handoff initialization to create/poll sandbox-agent sessions through provider-resolved endpoints. +Wiring task initialization to create/poll sandbox-agent sessions through provider-resolved endpoints. ### Friction / Issue @@ -38,13 +38,13 @@ Local test runs cannot assume a live sandbox-agent backend, so session bootstrap ### Attempted Fix / Workaround -1. Wrapped session creation in guarded error handling during handoff initialization. -2. Persisted handoff state as `queued` when session creation fails, while keeping sandbox metadata written. +1. Wrapped session creation in guarded error handling during task initialization. +2. Persisted task state as `queued` when session creation fails, while keeping sandbox metadata written. 3. Continued status tracking through runtime messages when a session is available. ### Outcome -- Handoff creation remains deterministic without hard dependency on a running sandbox-agent process. +- Task creation remains deterministic without hard dependency on a running sandbox-agent process. - Behavior is testable in CI/local environments that do not run sandbox-agent. ## 2026-02-12 - uncommitted @@ -60,7 +60,7 @@ Upgrading backend integration from legacy sandbox-agent session endpoints to `sa ### Attempted Fix / Workaround 1. Switched backend integration to `sandbox-agent` SDK (`SandboxAgent.connect`, `createSession`, `getSession`, `getEvents`). -2. Added status inference from SDK state/events for compatibility with existing handoff status sync actor. +2. Added status inference from SDK state/events for compatibility with existing task status sync actor. 3. Upgraded Daytona provider to install/start `sandbox-agent 0.2.0` in sandboxes and expose a preview endpoint for SDK calls. ### Outcome diff --git a/factory/research/friction/sandboxes.mdx b/foundry/research/friction/sandboxes.mdx similarity index 96% rename from factory/research/friction/sandboxes.mdx rename to foundry/research/friction/sandboxes.mdx index 39c96e4..e30e85b 100644 --- a/factory/research/friction/sandboxes.mdx +++ b/foundry/research/friction/sandboxes.mdx @@ -14,7 +14,7 @@ Provider interface intentionally keeps `DestroySandboxRequest` minimal (`workspa 1. Kept provider API stable and provider-agnostic. 2. Implemented safe best-effort destroy in `worktree` provider and avoided hard failures when repo context is unavailable. -3. Preserved status updates in handoff runtime/events so kill/archive state remains consistent. +3. Preserved status updates in task runtime/events so kill/archive state remains consistent. ### Outcome diff --git a/factory/research/roadmap.md b/foundry/research/roadmap.md similarity index 100% rename from factory/research/roadmap.md rename to foundry/research/roadmap.md diff --git a/factory/research/specs/frontend.md b/foundry/research/specs/frontend.md similarity index 80% rename from factory/research/specs/frontend.md rename to foundry/research/specs/frontend.md index 936ce95..2eb4ce5 100644 --- a/factory/research/specs/frontend.md +++ b/foundry/research/specs/frontend.md @@ -23,18 +23,18 @@ be thorough and careful with your impelmentaiton. this is going to be the ground - left sidebar is similar to the hf switch ui: - list each repo - - under each repo, show all of the handoffs - - you should see all handoffs for the entire workspace here grouped by repo + - under each repo, show all of the tasks + - you should see all tasks for the entire workspace here grouped by repo - the main content area shows the current workspace - there is a main agent session for the main agent thatn's making the change, so show this by default - build a ui for interacting with sessions - see ~/sandbox-agent/frontend/packages/inspector/ for reference ui - right sidebar - - show all information about the current handoff + - show all information about the current task ## testing - use agent-browser cli to veirfy that all of this functionality works - - create handoff - - can see the handoff in the sidear - - clik on handoff to see the agent transcript + - create task + - can see the task in the sidear + - clik on task to see the agent transcript diff --git a/factory/research/specs/rivetkit-opentui-migration-plan.md b/foundry/research/specs/rivetkit-opentui-migration-plan.md similarity index 88% rename from factory/research/specs/rivetkit-opentui-migration-plan.md rename to foundry/research/specs/rivetkit-opentui-migration-plan.md index 6805c4a..d078c9a 100644 --- a/factory/research/specs/rivetkit-opentui-migration-plan.md +++ b/foundry/research/specs/rivetkit-opentui-migration-plan.md @@ -16,9 +16,9 @@ Date: 2026-02-08 9. Every actor key is prefixed by workspace. 10. `--workspace` is optional; commands resolve workspace via flag -> config default -> `default`. 11. RivetKit local dependency wiring is `link:`-based. -12. Keep the existing config file path (`~/.config/openhandoff/config.toml`) and evolve keys in place. +12. Keep the existing config file path (`~/.config/foundry/config.toml`) and evolve keys in place. 13. `.agents` and skill files are in scope for migration updates. -14. Parent orchestration actors (`workspace`, `project`, `handoff`) use command-only loops with no timeout. +14. Parent orchestration actors (`workspace`, `project`, `task`) use command-only loops with no timeout. 15. Periodic syncing/polling runs in dedicated child actors, each with a single timeout cadence. 16. For each actor, define the main loop and exactly what data it mutates; keep single-writer ownership strict. @@ -32,7 +32,7 @@ We will replace the existing Rust backend/CLI/TUI with TypeScript services and U - TUI: TypeScript + OpenTUI - State: SQLite + Drizzle (actor-owned writes) -The core architecture changes from "worktree-per-handoff" to "provider-selected sandbox-per-handoff." Local worktrees remain supported through a `worktree` provider. +The core architecture changes from "worktree-per-task" to "provider-selected sandbox-per-task." Local worktrees remain supported through a `worktree` provider. ## Breaking Changes (Intentional) @@ -61,12 +61,12 @@ packages/ actors/ workspace.ts project.ts - handoff.ts + task.ts sandbox-instance.ts history.ts project-pr-sync.ts project-branch-sync.ts - handoff-status-sync.ts + task-status-sync.ts keys.ts events.ts registry.ts @@ -119,11 +119,11 @@ Backend actor files and responsibilities: 2. `packages/backend/src/actors/project.ts` - `ProjectActor` implementation. - Branch snapshot refresh, PR cache orchestration, stream publication. -- Routes handoff actions to `HandoffActor`. +- Routes task actions to `TaskActor`. -3. `packages/backend/src/actors/handoff.ts` -- `HandoffActor` implementation. -- Handoff lifecycle, session/sandbox orchestration, post-idle automation. +3. `packages/backend/src/actors/task.ts` +- `TaskActor` implementation. +- Task lifecycle, session/sandbox orchestration, post-idle automation. 4. `packages/backend/src/actors/sandbox-instance.ts` - `SandboxInstanceActor` implementation. @@ -153,9 +153,9 @@ Backend actor files and responsibilities: - Read-only branch snapshot polling loop (single timeout cadence). - Sends sync results back to `ProjectActor`. -12. `packages/backend/src/actors/handoff-status-sync.ts` +12. `packages/backend/src/actors/task-status-sync.ts` - Read-only session/sandbox status polling loop (single timeout cadence). -- Sends status updates back to `HandoffActor`. +- Sends status updates back to `TaskActor`. ## RivetKit Source Policy (Local Only) @@ -198,12 +198,12 @@ RivetKit registry actor keys are workspace-prefixed: 2. `ProjectActor` - Key: `["ws", workspaceId, "project", repoId]` - Owns repo snapshot cache and PR cache refresh orchestration. -- Routes branch/handoff commands to handoff actors. +- Routes branch/task commands to task actors. - Streams project updates to CLI/TUI subscribers. -3. `HandoffActor` -- Key: `["ws", workspaceId, "project", repoId, "handoff", handoffId]` -- Owns handoff metadata/runtime state. +3. `TaskActor` +- Key: `["ws", workspaceId, "project", repoId, "task", taskId]` +- Owns task metadata/runtime state. - Creates/resumes sandbox + session through provider adapter. - Handles attach/push/sync/merge/archive/kill and post-idle automation. @@ -225,9 +225,9 @@ RivetKit registry actor keys are workspace-prefixed: - Polls branch/worktree state on interval and emits results to `ProjectActor`. - Does not write DB directly. -8. `HandoffStatusSyncActor` (child poller) -- Key: `["ws", workspaceId, "project", repoId, "handoff", handoffId, "status-sync"]` -- Polls agent/session/sandbox health on interval and emits results to `HandoffActor`. +8. `TaskStatusSyncActor` (child poller) +- Key: `["ws", workspaceId, "project", repoId, "task", taskId, "status-sync"]` +- Polls agent/session/sandbox health on interval and emits results to `TaskActor`. - Does not write DB directly. Ownership rule: each table/row has one actor writer. @@ -242,8 +242,8 @@ Always define actor run-loop + mutated state together: 2. `ProjectActor` - Mutates: `repos`, `branches`, `pr_cache` (applies child poller results). -3. `HandoffActor` -- Mutates: `handoffs`, `handoff_runtime` (applies child poller results). +3. `TaskActor` +- Mutates: `tasks`, `task_runtime` (applies child poller results). 4. `SandboxInstanceActor` - Mutates: `sandbox_instances`. @@ -251,7 +251,7 @@ Always define actor run-loop + mutated state together: 5. `HistoryActor` - Mutates: `events`. -6. Child sync actors (`project-pr-sync`, `project-branch-sync`, `handoff-status-sync`) +6. Child sync actors (`project-pr-sync`, `project-branch-sync`, `task-status-sync`) - Mutates: none (read-only pollers; publish result messages only). ## Run Loop Patterns (Required) @@ -280,13 +280,13 @@ run: async (c) => { }; ``` -### `HandoffActor` (no timeout) +### `TaskActor` (no timeout) ```ts run: async (c) => { while (true) { - const msg = await c.queue.next("handoff.command"); - await handleHandoffCommand(c, msg); // includes applying status results to handoff_runtime + const msg = await c.queue.next("task.command"); + await handleTaskCommand(c, msg); // includes applying status results to task_runtime } }; ``` @@ -349,16 +349,16 @@ run: async (c) => { }; ``` -### `HandoffStatusSyncActor` (single timeout cadence) +### `TaskStatusSyncActor` (single timeout cadence) ```ts run: async (c) => { const intervalMs = 2_000; while (true) { - const msg = await c.queue.next("handoff.status_sync.command", { timeout: intervalMs }); + const msg = await c.queue.next("task.status_sync.command", { timeout: intervalMs }); if (!msg) { const result = await pollSessionAndSandboxStatus(); - await sendToHandoff({ name: "handoff.status_sync.result", result }); + await sendToTask({ name: "task.status_sync.result", result }); continue; } await handleStatusSyncControl(c, msg); @@ -368,7 +368,7 @@ run: async (c) => { ## Sandbox Provider Interface -Provider contract lives under `packages/backend/src/providers/provider-api` and is consumed by workspace/project/handoff actors. +Provider contract lives under `packages/backend/src/providers/provider-api` and is consumed by workspace/project/task actors. ```ts interface SandboxProvider { @@ -402,7 +402,7 @@ Initial providers: 1. `hf create ... --workspace --provider ` 2. `hf switch --workspace [target]` -3. `hf attach --workspace [handoff]` +3. `hf attach --workspace [task]` 4. `hf list --workspace ` 5. `hf kill|archive|merge|push|sync --workspace ...` 6. `hf workspace use ` to set default workspace @@ -421,8 +421,8 @@ Tables (workspace-scoped): 2. `workspace_provider_profiles` 3. `repos` (`workspace_id`, `repo_id`, ...) 4. `branches` (`workspace_id`, `repo_id`, ...) -5. `handoffs` (`workspace_id`, `handoff_id`, `provider_id`, ...) -6. `handoff_runtime` (`workspace_id`, `handoff_id`, `sandbox_id`, `session_id`, ...) +5. `tasks` (`workspace_id`, `task_id`, `provider_id`, ...) +6. `task_runtime` (`workspace_id`, `task_id`, `sandbox_id`, `session_id`, ...) 7. `sandbox_instances` (`workspace_id`, `provider_id`, `sandbox_id`, ...) 8. `pr_cache` (`workspace_id`, `repo_id`, ...) 9. `events` (`workspace_id`, `repo_id`, ...) @@ -487,10 +487,10 @@ Exit criteria: Exit criteria: - `create/list/switch/attach/push/sync/kill` pass on worktree provider. -## Phase 4: Workspace/Handoff Lifecycle +## Phase 4: Workspace/Task Lifecycle 1. Implement workspace coordinator flows. -2. Implement HandoffActor full lifecycle + post-idle automation. +2. Implement TaskActor full lifecycle + post-idle automation. 3. Implement history events and PR/CI/review change tracking. Exit criteria: @@ -545,7 +545,7 @@ Exit criteria: 4. Reliability tests - sandbox-agent restarts - transient provider failures -- backend restart with in-flight handoffs +- backend restart with in-flight tasks ## Open Questions To Resolve Before Implementation diff --git a/factory/scripts/build-test-image.sh b/foundry/scripts/build-test-image.sh similarity index 100% rename from factory/scripts/build-test-image.sh rename to foundry/scripts/build-test-image.sh diff --git a/factory/tsconfig.base.json b/foundry/tsconfig.base.json similarity index 53% rename from factory/tsconfig.base.json rename to foundry/tsconfig.base.json index dd43cfe..9812077 100644 --- a/factory/tsconfig.base.json +++ b/foundry/tsconfig.base.json @@ -13,11 +13,11 @@ "declaration": true, "sourceMap": true, "paths": { - "@openhandoff/client": ["packages/client/src/index.ts"], - "@openhandoff/shared": ["packages/shared/src/index.ts"], - "@openhandoff/backend": ["packages/backend/src/index.ts"], - "@openhandoff/frontend-errors": ["packages/frontend-errors/src/index.ts"], - "@openhandoff/frontend-errors/*": ["packages/frontend-errors/src/*"] + "@sandbox-agent/foundry-client": ["packages/client/src/index.ts"], + "@sandbox-agent/foundry-shared": ["packages/shared/src/index.ts"], + "@sandbox-agent/foundry-backend": ["packages/backend/src/index.ts"], + "@sandbox-agent/foundry-frontend-errors": ["packages/frontend-errors/src/index.ts"], + "@sandbox-agent/foundry-frontend-errors/*": ["packages/frontend-errors/src/*"] }, "noUncheckedIndexedAccess": true, "noImplicitOverride": true diff --git a/justfile b/justfile index 3a7a19d..87729d6 100644 --- a/justfile +++ b/justfile @@ -93,101 +93,101 @@ install-release: cargo install --path gigacode # ============================================================================= -# Factory +# Foundry # ============================================================================= -[group('factory')] -factory-deps: +[group('foundry')] +foundry-deps: pnpm install -[group('factory')] -factory-install: +[group('foundry')] +foundry-install: pnpm install pnpm -w build -[group('factory')] -factory-typecheck: +[group('foundry')] +foundry-typecheck: pnpm -w typecheck -[group('factory')] -factory-build: +[group('foundry')] +foundry-build: pnpm -w build -[group('factory')] -factory-test: +[group('foundry')] +foundry-test: pnpm -w test -[group('factory')] -factory-check: +[group('foundry')] +foundry-check: pnpm -w typecheck pnpm -w build pnpm -w test -[group('factory')] -factory-dev: +[group('foundry')] +foundry-dev: pnpm install - mkdir -p factory/.openhandoff/logs - HF_DOCKER_UID="$(id -u)" HF_DOCKER_GID="$(id -g)" docker compose -f factory/compose.dev.yaml up --build --force-recreate -d + mkdir -p foundry/.foundry/logs + HF_DOCKER_UID="$(id -u)" HF_DOCKER_GID="$(id -g)" docker compose -f foundry/compose.dev.yaml up --build --force-recreate -d -[group('factory')] -factory-preview: +[group('foundry')] +foundry-preview: pnpm install - mkdir -p factory/.openhandoff/logs - HF_DOCKER_UID="$(id -u)" HF_DOCKER_GID="$(id -g)" docker compose -f factory/compose.preview.yaml up --build --force-recreate -d + mkdir -p foundry/.foundry/logs + HF_DOCKER_UID="$(id -u)" HF_DOCKER_GID="$(id -g)" docker compose -f foundry/compose.preview.yaml up --build --force-recreate -d -[group('factory')] -factory-frontend-dev host='127.0.0.1' port='4173' backend='http://127.0.0.1:7741/api/rivet': +[group('foundry')] +foundry-frontend-dev host='127.0.0.1' port='4173' backend='http://127.0.0.1:7741/api/rivet': pnpm install - VITE_HF_BACKEND_ENDPOINT="{{backend}}" pnpm --filter @openhandoff/frontend dev -- --host {{host}} --port {{port}} + VITE_HF_BACKEND_ENDPOINT="{{backend}}" pnpm --filter @sandbox-agent/foundry-frontend dev -- --host {{host}} --port {{port}} -[group('factory')] -factory-dev-mock host='127.0.0.1' port='4173': +[group('foundry')] +foundry-dev-mock host='127.0.0.1' port='4173': pnpm install - OPENHANDOFF_FRONTEND_CLIENT_MODE=mock pnpm --filter @openhandoff/frontend dev -- --host {{host}} --port {{port}} + FOUNDRY_FRONTEND_CLIENT_MODE=mock pnpm --filter @sandbox-agent/foundry-frontend dev -- --host {{host}} --port {{port}} -[group('factory')] -factory-dev-turbo: - pnpm exec turbo run dev --parallel --filter=@openhandoff/* +[group('foundry')] +foundry-dev-turbo: + pnpm exec turbo run dev --parallel --filter=@sandbox-agent/foundry-* -[group('factory')] -factory-dev-down: - docker compose -f factory/compose.dev.yaml down +[group('foundry')] +foundry-dev-down: + docker compose -f foundry/compose.dev.yaml down -[group('factory')] -factory-dev-logs: - docker compose -f factory/compose.dev.yaml logs -f --tail=200 +[group('foundry')] +foundry-dev-logs: + docker compose -f foundry/compose.dev.yaml logs -f --tail=200 -[group('factory')] -factory-preview-down: - docker compose -f factory/compose.preview.yaml down +[group('foundry')] +foundry-preview-down: + docker compose -f foundry/compose.preview.yaml down -[group('factory')] -factory-preview-logs: - docker compose -f factory/compose.preview.yaml logs -f --tail=200 +[group('foundry')] +foundry-preview-logs: + docker compose -f foundry/compose.preview.yaml logs -f --tail=200 -[group('factory')] -factory-format: - prettier --write factory +[group('foundry')] +foundry-format: + prettier --write foundry -[group('factory')] -factory-backend-start host='127.0.0.1' port='7741': +[group('foundry')] +foundry-backend-start host='127.0.0.1' port='7741': pnpm install - pnpm --filter @openhandoff/backend build - pnpm --filter @openhandoff/backend start -- --host {{host}} --port {{port}} + pnpm --filter @sandbox-agent/foundry-backend build + pnpm --filter @sandbox-agent/foundry-backend start -- --host {{host}} --port {{port}} -[group('factory')] -factory-hf *ARGS: +[group('foundry')] +foundry-hf *ARGS: @echo "CLI package is disabled in this repo; use frontend workflows instead." >&2 @exit 1 -[group('factory')] -factory-docker-build tag='openhandoff:local': - docker build -f factory/Dockerfile -t {{tag}} . +[group('foundry')] +foundry-docker-build tag='foundry:local': + docker build -f foundry/Dockerfile -t {{tag}} . -[group('factory')] -factory-railway-up: +[group('foundry')] +foundry-railway-up: npx -y @railway/cli up --detach -[group('factory')] -factory-railway-status: +[group('foundry')] +foundry-railway-status: npx -y @railway/cli status --json diff --git a/package.json b/package.json index 6a911f8..41a699f 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "build": "turbo run build", "dev": "turbo run dev --parallel", "generate": "turbo run generate", - "prepare": "lefthook install", + "prepare": "git rev-parse --git-dir >/dev/null 2>&1 && lefthook install || true", "typecheck": "turbo run typecheck" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5979cde..2b002d6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -450,7 +450,7 @@ importers: specifier: ^3.0.0 version: 3.2.4(@types/debug@4.1.12)(@types/node@25.4.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) - factory/packages/backend: + foundry/packages/backend: dependencies: '@daytonaio/sdk': specifier: 0.141.0 @@ -464,7 +464,7 @@ importers: '@iarna/toml': specifier: ^2.2.5 version: 2.2.5 - '@openhandoff/shared': + '@sandbox-agent/foundry-shared': specifier: workspace:* version: link:../shared '@sandbox-agent/persist-rivet': @@ -502,9 +502,9 @@ importers: specifier: ^8.5.0 version: 8.5.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) - factory/packages/client: + foundry/packages/client: dependencies: - '@openhandoff/shared': + '@sandbox-agent/foundry-shared': specifier: workspace:* version: link:../shared rivetkit: @@ -518,15 +518,15 @@ importers: specifier: ^8.5.0 version: 8.5.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) - factory/packages/frontend: + foundry/packages/frontend: dependencies: - '@openhandoff/client': + '@sandbox-agent/foundry-client': specifier: workspace:* version: link:../client - '@openhandoff/frontend-errors': + '@sandbox-agent/foundry-frontend-errors': specifier: workspace:* version: link:../frontend-errors - '@openhandoff/shared': + '@sandbox-agent/foundry-shared': specifier: workspace:* version: link:../shared '@sandbox-agent/react': @@ -582,7 +582,7 @@ importers: specifier: ^7.1.3 version: 7.3.1(@types/node@25.4.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) - factory/packages/frontend-errors: + foundry/packages/frontend-errors: dependencies: '@hono/node-server': specifier: ^1.19.9 @@ -598,7 +598,7 @@ importers: specifier: ^7.1.3 version: 7.3.1(@types/node@25.4.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) - factory/packages/shared: + foundry/packages/shared: dependencies: zod: specifier: ^4.1.5 @@ -13641,7 +13641,7 @@ snapshots: debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 6.4.1(@types/node@22.19.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@22.19.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - '@types/node' - jiti @@ -13662,7 +13662,7 @@ snapshots: debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 6.4.1(@types/node@24.10.9)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@24.10.9)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - '@types/node' - jiti @@ -13683,7 +13683,7 @@ snapshots: debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 6.4.1(@types/node@25.4.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.4.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - '@types/node' - jiti @@ -13725,7 +13725,7 @@ snapshots: '@types/node': 25.4.0 fsevents: 2.3.3 - vite@6.4.1(@types/node@22.19.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2): + vite@6.4.1(@types/node@25.4.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.3) @@ -13733,6 +13733,21 @@ snapshots: postcss: 8.5.6 rollup: 4.56.0 tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 25.4.0 + fsevents: 2.3.3 + jiti: 1.21.7 + tsx: 4.21.0 + yaml: 2.8.2 + + vite@7.3.1(@types/node@22.19.7)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2): + dependencies: + esbuild: 0.27.3 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.56.0 + tinyglobby: 0.2.15 optionalDependencies: '@types/node': 22.19.7 fsevents: 2.3.3 @@ -13740,9 +13755,9 @@ snapshots: tsx: 4.21.0 yaml: 2.8.2 - vite@6.4.1(@types/node@24.10.9)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2): + vite@7.3.1(@types/node@24.10.9)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2): dependencies: - esbuild: 0.25.12 + esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 postcss: 8.5.6 @@ -13755,21 +13770,6 @@ snapshots: tsx: 4.21.0 yaml: 2.8.2 - vite@6.4.1(@types/node@25.4.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2): - dependencies: - esbuild: 0.25.12 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - postcss: 8.5.6 - rollup: 4.56.0 - tinyglobby: 0.2.15 - optionalDependencies: - '@types/node': 25.4.0 - fsevents: 2.3.3 - jiti: 1.21.7 - tsx: 4.21.0 - yaml: 2.8.2 - vite@7.3.1(@types/node@25.4.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.27.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b6dbf60..229465a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,7 +1,7 @@ packages: - "frontend/packages/*" - - "factory/packages/*" - - "!factory/packages/cli" + - "foundry/packages/*" + - "!foundry/packages/cli" - "sdks/*" - "sdks/cli" - "sdks/cli/platforms/*" diff --git a/research/agents/amp.md b/research/agents/amp.md index c9bced7..d05a4b7 100644 --- a/research/agents/amp.md +++ b/research/agents/amp.md @@ -251,7 +251,7 @@ Amp also uses additional models for specific subtasks: | Librarian subagent | Claude Sonnet 4.5 | External code research | | Image/PDF analysis | Gemini 3 Flash | Multimodal input processing | | Content generation | Gemini 3 Pro Image (Painter) | Image generation | -| Handoff (context) | Gemini 2.5 Flash | Context management | +| Task (context) | Gemini 2.5 Flash | Context management | | Thread categorization | Gemini 2.5 Flash-Lite | Thread organization | | Title generation | Claude Haiku 4.5 | Thread title generation |