From dc5c5c40f9e34f6a85a498220773abff31ea9964 Mon Sep 17 00:00:00 2001 From: Bailey Dixon Date: Thu, 15 Oct 2026 10:27:39 -0400 Subject: [PATCH 1/7] feat: checkpoint local navigation and ownership model (AXI-206) --- DEVLOG.md | 60 +++ docs/guide/instance-admin.md | 3 + docs/guide/navigation-and-agent-setup.md | 143 +++++ prisma/backfill-multiws.ts | 20 +- .../migration.sql | 44 ++ prisma/schema.prisma | 51 +- prisma/seed.ts | 4 + src/app/(app)/admin/users/page.tsx | 4 +- src/app/(app)/agents/[id]/page.tsx | 13 +- src/app/(app)/agents/page.tsx | 14 +- src/app/(app)/mission-control/page.tsx | 22 + src/app/(app)/page.tsx | 19 +- src/app/(app)/settings/access/page.tsx | 20 +- .../agents/[id]/agent-detail-content.tsx | 306 +++++++---- .../settings/agents/agent-setup-dialog.tsx | 201 +++++++ .../(app)/settings/agents/agents-content.tsx | 333 +++++++----- src/app/(app)/settings/agents/page.tsx | 3 +- .../transfer-agent-ownership-dialog.tsx | 211 ++++++++ src/app/(app)/settings/layout.tsx | 4 +- src/app/(app)/settings/page.tsx | 14 +- src/app/(app)/settings/runtimes/page.tsx | 24 +- src/app/(app)/settings/workspaces/page.tsx | 443 +--------------- .../(app)/w/[slug]/settings/agents/page.tsx | 16 +- .../agents/request-profile-dialog.tsx | 108 ++-- .../w/[slug]/settings/runtimes/[id]/page.tsx | 32 +- .../(app)/w/[slug]/settings/runtimes/page.tsx | 15 +- src/app/(app)/workspaces/page.tsx | 19 + src/components/admin-shell/admin-agents.tsx | 14 +- src/components/admin-shell/admin-runtimes.tsx | 185 +++++-- src/components/admin-shell/admin-shell.tsx | 10 +- .../admin-shell/personal-agent-policy.tsx | 95 ++++ .../global-shell/global-operations-model.ts | 12 +- src/components/global-shell/global-shell.tsx | 26 +- .../global-shell/mission-control-home.tsx | 12 +- src/components/global-shell/personal-home.tsx | 168 ++++++ .../global-shell/workspaces-content.tsx | 404 ++++++++++++++ src/components/settings/runtime-lifecycle.tsx | 395 ++++++++++++++ src/components/settings/settings-nav.ts | 14 +- src/components/settings/settings-rail.tsx | 14 +- src/components/sidebar.tsx | 4 +- src/components/ui/modal/center-modal.tsx | 2 +- src/components/workspace-switcher.tsx | 6 +- src/lib/agent-ownership-ui.ts | 35 ++ src/lib/agent-setup.ts | 45 ++ src/lib/runtime-ownership.ts | 28 + .../agent-binding-mission-control.test.ts | 81 +++ .../__tests__/agent-profile-ownership.test.ts | 414 +++++++++++++++ .../routers/__tests__/agent-profile.test.ts | 249 +++++++++ .../routers/__tests__/agent-transport.test.ts | 2 + src/server/routers/__tests__/global.test.ts | 61 +++ src/server/routers/__tests__/helpers.ts | 6 + .../__tests__/runtime-admin-gating.test.ts | 1 + .../__tests__/runtime-github-app.test.ts | 1 + .../__tests__/runtime-lifecycle.test.ts | 426 +++++++++++++++ .../routers/__tests__/runtime-secrets.test.ts | 1 + src/server/routers/agent-binding.ts | 391 +++++++------- src/server/routers/agent-profile.ts | 493 ++++++++++++------ src/server/routers/agent.ts | 20 +- src/server/routers/global.ts | 117 ++++- src/server/routers/instance-admin.ts | 7 +- src/server/routers/runtime.ts | 453 ++++++++++------ src/server/routers/workspace.ts | 33 +- .../__tests__/hermes-connector-retry.test.ts | 1 + .../managed-runtime-lifecycle.test.ts | 1 + src/server/services/__tests__/mcp.test.ts | 6 + .../services/__tests__/orchestration.test.ts | 2 + .../resource-ownership-schema.test.ts | 129 +++++ .../services/__tests__/run-dispatcher.test.ts | 10 + .../__tests__/runtime-mcp-ownership.test.ts | 127 +++++ src/server/services/__tests__/storage.test.ts | 30 ++ .../__tests__/workspace-deletion.test.ts | 269 ++++++++++ src/server/services/agent-profile-access.ts | 84 +++ src/server/services/agent-profile-policy.ts | 110 ++++ src/server/services/mcp.ts | 195 ++++--- src/server/services/runtime-lifecycle.ts | 428 +++++++++++++++ src/server/services/workspace-deletion.ts | 51 ++ tests/unit/agent-ownership-ui.test.ts | 37 ++ tests/unit/agent-setup.test.ts | 94 ++++ .../global-mission-control-operations.test.ts | 14 +- tests/unit/personal-home.test.ts | 76 +++ tests/unit/runtime-lifecycle-ui.test.ts | 27 + tests/unit/runtime-ownership.test.ts | 56 ++ 82 files changed, 6588 insertions(+), 1530 deletions(-) create mode 100644 docs/guide/navigation-and-agent-setup.md create mode 100644 prisma/migrations/20261015111500_resource_ownership_policy/migration.sql create mode 100644 src/app/(app)/mission-control/page.tsx create mode 100644 src/app/(app)/settings/agents/agent-setup-dialog.tsx create mode 100644 src/app/(app)/settings/agents/transfer-agent-ownership-dialog.tsx create mode 100644 src/app/(app)/workspaces/page.tsx create mode 100644 src/components/admin-shell/personal-agent-policy.tsx create mode 100644 src/components/global-shell/personal-home.tsx create mode 100644 src/components/global-shell/workspaces-content.tsx create mode 100644 src/components/settings/runtime-lifecycle.tsx create mode 100644 src/lib/agent-ownership-ui.ts create mode 100644 src/lib/agent-setup.ts create mode 100644 src/lib/runtime-ownership.ts create mode 100644 src/server/routers/__tests__/agent-profile-ownership.test.ts create mode 100644 src/server/routers/__tests__/runtime-lifecycle.test.ts create mode 100644 src/server/services/__tests__/resource-ownership-schema.test.ts create mode 100644 src/server/services/__tests__/runtime-mcp-ownership.test.ts create mode 100644 src/server/services/__tests__/workspace-deletion.test.ts create mode 100644 src/server/services/agent-profile-access.ts create mode 100644 src/server/services/agent-profile-policy.ts create mode 100644 src/server/services/runtime-lifecycle.ts create mode 100644 src/server/services/workspace-deletion.ts create mode 100644 tests/unit/agent-ownership-ui.test.ts create mode 100644 tests/unit/agent-setup.test.ts create mode 100644 tests/unit/personal-home.test.ts create mode 100644 tests/unit/runtime-lifecycle-ui.test.ts create mode 100644 tests/unit/runtime-ownership.test.ts diff --git a/DEVLOG.md b/DEVLOG.md index 73a6cc02..4eae02af 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2,6 +2,66 @@ > Append-only session log. Read at session start. Update at session end. +## 2026-10-15 — AXI-206 explicit ownership and runtime lifecycle + +- Added PERSONAL/SERVICE ownership for agent definitions and runtimes, separate + immutable creator provenance, explicit UNASSIGNED legacy runtimes, owner-shape + checks and service-key uniqueness. Personal owner deletion is restricted; + deleting a service resource's creator does not delete the service identity. +- Added audited personal creation policy (disabled / approval required / + self-service), defaulting to approval required. Self-service creates only a + private personal identity; workspace access and credentials remain separate. +- Added admin ownership transfer with optimistic snapshots and runtime consent, + plus runtime impact/prepare/transfer/removal workflows. Active work, unsafe + configuration and historical tenant references are protected; external hosts + are never stopped by removal. Archived registrations remain discoverable. +- Unified browser/MCP management checks and locked attachment, binding, + diagnostic and child-config mutations against stale ownership/home races. + An issuing human does not lend authority to a service key. New runtime + attachment requires explicit adoption of UNASSIGNED registrations; existing + legacy bindings remain operational pending deliberate operator action. +- Applied migration only to guarded local test DB and a separate AXI-206 + preview database copied from existing local data. Earlier previews and + production were not migrated. Verified real Postgres/Redis tests for policy, + transfer races, ownership constraints, scope isolation and compatibility; + focused UI tests and TypeScript/lint also passed. Shared-dispatcher suites + require sequential execution to avoid cross-suite pending-run interference. +- Browser-verified ordinary member self-service creation, admin service-agent + creation and transfer, and runtime service ownership/home relocation followed + by removal of a disposable unused registration. Mobile confirmation has no + horizontal overflow. Fixed modal foreground inheritance from admin chrome and + removed a stale impact-query refetch after successful registration deletion. +- No commit/push/PR/release/deploy. Full release gates remain deferred. Documented + the coordinated migration/code rollout required before future production use. + +## 2026-10-14 — AXI-206 navigation and agent setup local iteration + +- Split personal Home/My Work from Mission Control operations; added a global + Workspaces directory with a compatibility redirect from account settings. + Clarified My account, Administration and workspace scope labels. +- Added intent-first external MCP versus managed-execution setup with workspace + and acting-identity continuity. Agent pages distinguish owner, catalog + availability, workspace access, configured runtime and valid client keys. + New profiles default owner-only; new keys default Standard scopes. Existing + records and the instance-admin approval requirement remain unchanged. +- Unified agent availability gates and removed ownership-based cross-workspace + run/client visibility. Project restrictions apply to recent activity. Public + profile/binding results redact dispatch secrets, including mutation results. +- Guarded permanent workspace deletion while runtime registrations exist, + including archived/unbound hosts, with real database locking. Bucket cleanup + retains the existing slug reservation until deletion; its pre-existing + non-atomic storage/database limitation is documented. +- Verified 20 agent-access integration tests, 18 runtime/storage integration + tests, 18 focused UI/setup/operations unit tests, TypeScript and focused lint. + Real-browser walkthroughs on localhost:3002 covered Home, agent setup and + credential-form context, workspace redirect and operations; desktop and + 390px mobile Home/Agents layouts have no horizontal overflow. No real external + runtime was dispatched or credential issued during browser checks. +- Local iteration only: no commit, push, PR, release, schema migration or deploy. + Service-owned identities, explicit runtime relocation/removal and configurable + personal-agent creation remain follow-up model work. Full release gates are + deferred until the operator accepts the local iteration. + ## 2026-09-01 — v0.35.0 release preparation - Squash-merged AXI-186 / PR #102 after exact-head lint, typecheck, unit, diff --git a/docs/guide/instance-admin.md b/docs/guide/instance-admin.md index e88112ed..d1d2b7c2 100644 --- a/docs/guide/instance-admin.md +++ b/docs/guide/instance-admin.md @@ -1,5 +1,8 @@ # Instance Admin +For the distinction between Home, Mission Control, Agents, My account and +Administration, see [Navigation and agent setup](./navigation-and-agent-setup.md). + `/admin` is the instance-operator surface — the controls that span _every_ tenant on a Forge instance, rather than one workspace. It renders in its own graphite shell (distinct from the warm-paper workspace shell) diff --git a/docs/guide/navigation-and-agent-setup.md b/docs/guide/navigation-and-agent-setup.md new file mode 100644 index 00000000..cde26966 --- /dev/null +++ b/docs/guide/navigation-and-agent-setup.md @@ -0,0 +1,143 @@ +# Navigation and agent setup + +Forge separates everyday work, operations, account preferences, and administration. + +| Destination | Purpose | +| --- | --- | +| Home (`/`) | Your accessible open assignments, workspace shortcuts, Inbox and Activity. | +| Workspaces (`/workspaces`) | Workspaces you belong to; creation and authorized management links. | +| Mission Control (`/mission-control`) | Cross-workspace agent operations, delivery, runtime health and intervention links. | +| Agents (`/agents`) | Agent definitions, ownership, workspace access and connection setup. | +| My account (`/settings`) | Personal identity, sign-in, preferences and owned resources. | +| Administration (`/admin`) | Instance-wide people, workspaces, authentication, governance and infrastructure. | + +Workspace routes remain under `/w/`. Their members, integrations and +workflow settings apply to that workspace. `/settings/workspaces` redirects +to the directory for existing bookmarks. Mission Control's former operational +dashboard remains available at `/mission-control`. + +## Connect an agent + +Begin in **Agents** and choose the execution intent: + +- **Connect an external client**: your desktop app, CLI or MCP client executes + the work. Choose the workspace and either your personal identity or an agent. + Personal credentials act as the current user. Agent credentials identify the + agent and record the issuing operator. No managed runtime is required. +- **Let Forge run an agent**: choose the workspace and agent, then configure + its managed execution host. Workspace owner/admin authority is required for + managed setup; instance administration alone is not workspace membership. + +An existing agent with workspace access can continue directly to its scoped +MCP credential setup. Otherwise its detail page explains how an authorized +workspace administrator adds access. New agent definitions retain the existing +instance policy under Administration → Agent governance. The compatibility +default remains approval required; changing policy does not approve existing +requests, share identities, or grant workspace membership. + +## Ownership, availability and authorization + +These are separate facts: + +- **Owner** manages the agent definition. Personal identities belong to one + active human; service-owned identities are managed by instance administrators + independently of the person who created them. Creation provenance grants no + management authority and remains separate when ownership changes. +- **Catalog availability** determines whether other workspace administrators + may select the agent. Instance availability does not make the instance its + owner and grants no workspace membership or project visibility. +- **Workspace access** is the agent's explicit workspace association and policy. +- **Permissions** remain limited by workspace roles, project authorization, + service-principal grants and credential scopes/narrowing as applicable. +- **Connection** identifies a client or transport. A configured runtime is not + proof that it executed an MCP operation. A valid credential is not live presence. + +Profile owners do not gain access to other workspaces' runs or client metadata +through ownership. Explicit instance administrators retain administrative +visibility. Disabled, archived and pending-approval agents are unavailable +through either workspace or global setup. Dispatch secrets are not returned +by profile/catalog read surfaces; only their configured presence is exposed. + +## Runtime lifecycle safety + +Runtime ownership can extend beyond the workspace where it was registered. +The existing schema still retains that registration-home relationship. +Permanent deletion of a workspace with any registered runtime is blocked, +including archived, disabled, unbound or legacy ownerless runtimes. Archiving +the workspace remains available. Runtime archival does not waive this guard. + +The guard and deletion execute under a PostgreSQL workspace-row lock so a +concurrent runtime registration cannot slip between the check and deletion. +Object storage cleanup starts after these checks while the workspace still +reserves its slug, before the database row is deleted. This prevents cleanup +from deleting a new workspace's bucket after slug reuse. Storage and database +deletion remain non-atomic: a later database failure cannot restore removed +objects, and failed best-effort storage cleanup can leave orphaned objects. + +## Ownership management + +Administrators can create service-owned agents directly or transfer an existing +identity between personal and service ownership. Transfers use a reviewed owner +and version snapshot, validate active personal targets, and preserve sharing, +workspace access, client credentials, history and creator attribution. An +incompatible private runtime must be explicitly cleared before ownership changes. +Service profile keys are unique instance-wide; personal keys remain unique per +owner. A service identity is not a human login account. + +Personal creation policy supports: + +- **Disabled**: users cannot create or request personal identities; admins may. +- **Approval required**: requests remain pending until an admin approves them. +- **Self-service**: users can create their own approved, private identities. + +Self-service never grants service ownership, sharing, elevated runtime access, +workspace adoption or additional credential scopes. Existing identities and +pending requests are unchanged when the policy changes. + +## Runtime transfer and removal + +Administration → Runtimes shows archived registrations on demand and provides +an impact preview for **Archive & disable**, **Transfer ownership / home**, and +**Remove registration**. All three require exact-name confirmation and a fresh +snapshot. Child secret/repository/configuration edits invalidate that snapshot. + +Transfer/removal require the registration to be archived and disabled first. +This prevents future Forge use; it does not stop the external host or complete +its existing work. Active/unresolved runs, delivery, connections, sessions, +diagnostics and agent/profile attachments are blockers. + +Ownership-only transfers may preserve terminal history in the original home. +Moving the registration home additionally requires no historical tenant-scoped +references. Credentials, repositories, GitHub App links, nonempty adapter config, +or endpoint credentials/query parameters must be reviewed and cleared in the +original home before a transfer. Nothing is silently moved across tenants. +Use a new registration when existing history prevents a home move. + +Removal is for unused registrations only. The preview identifies any stored +secret/repository configuration that will be deleted; it never deletes or stops +the external runtime host. Historical registrations stay archived to preserve +evidence. New attachment of an **Unassigned** runtime requires explicit admin +adoption; existing legacy bindings are preserved until deliberately changed. + +MCP management respects the same ownership rules. Service API keys do not +inherit their issuer's instance-admin role. Ownerless service registrations +remain unassigned until an administrator adopts them. + +## Migration and release caution + +The ownership migration preserves existing human owners and does not invent +historical creators. Ownerless runtimes become explicitly UNASSIGNED. Personal +ownership foreign keys prevent hard-deleting an owner until resources are +transferred or removed; service resources survive creator deletion. + +The new ownership CHECK constraints require updated runtime writers to supply +their owner type. A future production rollout must quiesce old web/worker +writers before applying this migration, then start the matching new application +and worker together. An old binary must not continue writing to the new schema. +Local validation does not authorize this production migration or deployment. + +## Local iteration boundary + +AXI-206 is a local development iteration. It does not replace the separate +multi-user People & Access, Teams, directory or SCIM program. No existing grants +are migrated or widened, and no production migration or release is implied. diff --git a/prisma/backfill-multiws.ts b/prisma/backfill-multiws.ts index a2559724..a9b4ef96 100644 --- a/prisma/backfill-multiws.ts +++ b/prisma/backfill-multiws.ts @@ -12,7 +12,8 @@ * each existing Agent binding to it (Agent.profileId). The owner is * the workspace's OWNER (fallback: ADMIN, then any member). The * profile's definitional columns are copied from the binding. - * 3. Backfills Runtime.ownerId from the workspace owner where null. + * 3. Reports unassigned runtimes for explicit ownership review. Service + * resources deliberately have no human owner and must remain unchanged. * * Until this runs, Agent.profileId is null and the app treats each * binding's own columns as its definition (transitional). Running it is @@ -44,7 +45,6 @@ async function main() { let promoted = 0; let profilesCreated = 0; let bindingsLinked = 0; - let runtimesOwned = 0; // 1. Bootstrap instance admin. const adminEmail = process.env.ADMIN_EMAIL?.toLowerCase(); @@ -113,19 +113,17 @@ async function main() { bindingsLinked++; } - // 3. Runtime ownership. - const runtimes = await db.runtime.findMany({ where: { ownerId: null }, select: { id: true, workspaceId: true } }); - for (const rt of runtimes) { - const ownerId = await workspaceOwnerUserId(rt.workspaceId); - if (ownerId) { - await db.runtime.update({ where: { id: rt.id }, data: { ownerId } }); - runtimesOwned++; - } + // 3. Registration location is not ownership. Leave historical unassigned + // runtimes for an administrator to transfer explicitly; never adopt SERVICE + // resources merely because their ownerId is intentionally null. + const unassignedRuntimes = await db.runtime.count({ where: { ownerType: "UNASSIGNED" } }); + if (unassignedRuntimes > 0) { + console.warn(`${unassignedRuntimes} unassigned runtimes need explicit ownership review in Administration.`); } console.log( `backfill-multiws done — promoted ${promoted} admin, created ${profilesCreated} profiles, ` + - `linked ${bindingsLinked} bindings, owned ${runtimesOwned} runtimes.`, + `linked ${bindingsLinked} bindings; ${unassignedRuntimes} runtimes await ownership review.`, ); } diff --git a/prisma/migrations/20261015111500_resource_ownership_policy/migration.sql b/prisma/migrations/20261015111500_resource_ownership_policy/migration.sql new file mode 100644 index 00000000..87a13ff6 --- /dev/null +++ b/prisma/migrations/20261015111500_resource_ownership_policy/migration.sql @@ -0,0 +1,44 @@ +CREATE TYPE "ResourceOwnerType" AS ENUM ('PERSONAL', 'SERVICE', 'UNASSIGNED'); +CREATE TYPE "PersonalAgentCreation" AS ENUM ('DISABLED', 'APPROVAL_REQUIRED', 'SELF_SERVICE'); + +ALTER TABLE "AgentProfile" + ADD COLUMN "ownerType" "ResourceOwnerType" NOT NULL DEFAULT 'PERSONAL', + ADD COLUMN "createdById" TEXT, + ALTER COLUMN "ownerId" DROP NOT NULL; +ALTER TABLE "Runtime" + ADD COLUMN "ownerType" "ResourceOwnerType" NOT NULL DEFAULT 'UNASSIGNED', + ADD COLUMN "createdById" TEXT; + +-- Keep existing human ownership. Do not invent a service owner or historical creator. +UPDATE "Runtime" SET "ownerType" = 'PERSONAL' WHERE "ownerId" IS NOT NULL; + +ALTER TABLE "AgentProfile" DROP CONSTRAINT "AgentProfile_ownerId_fkey"; +ALTER TABLE "AgentProfile" ADD CONSTRAINT "AgentProfile_ownerId_fkey" + FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "Runtime" DROP CONSTRAINT "Runtime_ownerId_fkey"; +ALTER TABLE "Runtime" ADD CONSTRAINT "Runtime_ownerId_fkey" + FOREIGN KEY ("ownerId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "AgentProfile" ADD CONSTRAINT "AgentProfile_createdById_fkey" + FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; +ALTER TABLE "Runtime" ADD CONSTRAINT "Runtime_createdById_fkey" + FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +ALTER TABLE "AgentProfile" ADD CONSTRAINT "AgentProfile_owner_shape_check" CHECK ( + ("ownerType" = 'PERSONAL' AND "ownerId" IS NOT NULL) OR + ("ownerType" = 'SERVICE' AND "ownerId" IS NULL) +); +ALTER TABLE "Runtime" ADD CONSTRAINT "Runtime_owner_shape_check" CHECK ( + ("ownerType" = 'PERSONAL' AND "ownerId" IS NOT NULL) OR + ("ownerType" IN ('SERVICE', 'UNASSIGNED') AND "ownerId" IS NULL) +); +-- SQL partial uniqueness covers service identities with no human owner. +CREATE UNIQUE INDEX "AgentProfile_service_profileKey_key" ON "AgentProfile"("profileKey") WHERE "ownerType" = 'SERVICE'; + +CREATE TABLE "InstanceAgentPolicy" ( + "id" TEXT NOT NULL DEFAULT 'default', + "personalAgentCreation" "PersonalAgentCreation" NOT NULL DEFAULT 'APPROVAL_REQUIRED', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "InstanceAgentPolicy_pkey" PRIMARY KEY ("id"), + CONSTRAINT "InstanceAgentPolicy_singleton_check" CHECK ("id" = 'default') +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 78c0cb81..67a312a4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -32,6 +32,19 @@ enum Role { GUEST } +/// Ownership is independent of catalog sharing and workspace authorization. +enum ResourceOwnerType { + PERSONAL + SERVICE + UNASSIGNED +} + +enum PersonalAgentCreation { + DISABLED + APPROVAL_REQUIRED + SELF_SERVICE +} + /// Default audience for a project. RESTRICTED projects require an explicit /// ProjectAccess row for non-administrators. enum ProjectVisibility { @@ -959,7 +972,8 @@ model User { memberships Membership[] invitationsSent WorkspaceInvitation[] @relation("InvitationSender") invitationsAccepted WorkspaceInvitation[] @relation("InvitationAcceptor") - agentProfiles AgentProfile[] + agentProfiles AgentProfile[] @relation("AgentProfileOwner") + createdAgentProfiles AgentProfile[] @relation("AgentProfileCreator") requestedAgentProfiles AgentProfile[] @relation("AgentProfileRequester") connections Connection[] externalResourceLinks ExternalResourceLink[] @relation("ExternalResourceLinkCreator") @@ -989,7 +1003,8 @@ model User { timeEntries TimeEntry[] chatThreads ChatThread[] chatThreadReads ChatThreadRead[] - runtimes Runtime[] + runtimes Runtime[] @relation("RuntimeOwner") + createdRuntimes Runtime[] @relation("RuntimeCreator") controlRequestedRuns AgentRun[] @relation("AgentRunControlRequester") clearedAgentRuns AgentRun[] @relation("AgentRunClearer") pins Pin[] @@ -1086,6 +1101,14 @@ model InstanceAuthPolicy { breakGlassUser User? @relation("InstanceBreakGlassUser", fields: [breakGlassUserId], references: [id], onDelete: Restrict) } +/// Singleton policy; default preserves the existing approval-required behavior. +model InstanceAgentPolicy { + id String @id @default("default") + personalAgentCreation PersonalAgentCreation @default(APPROVAL_REQUIRED) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + /// Optional local sign-in method attached to the same canonical User used by /// every OAuth/OIDC identity. model LocalCredential { @@ -2569,12 +2592,12 @@ model Runtime { /// Stored only for ops display; the underlying ApiKey row is the /// source of truth. ownerKeyPrefix String? - /// User who registered this runtime. The global owner — global runtime - /// views (Mission Control, /settings/runtimes) key off this rather than - /// `workspaceId`, so a host appears once across all the owner's - /// workspaces. `workspaceId` is retained (where the daemon first - /// registered) for back-compat; treat `ownerId` as the global identity. + /// Human owner, independent of the registration-home workspace. SERVICE + /// runtimes are instance-managed; UNASSIGNED preserves legacy ambiguity. + ownerType ResourceOwnerType @default(UNASSIGNED) ownerId String? + /// Creation provenance only; never a permission grant. Legacy rows stay null. + createdById String? /// Instance admin can mark a runtime instance-shared so it appears in /// every workspace's runtime picker (not just the owner's). Mirrors /// {@link AgentProfile.instanceShared}. @@ -2604,7 +2627,8 @@ model Runtime { updatedAt DateTime @updatedAt workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) - owner User? @relation(fields: [ownerId], references: [id], onDelete: SetNull) + owner User? @relation("RuntimeOwner", fields: [ownerId], references: [id], onDelete: Restrict) + createdBy User? @relation("RuntimeCreator", fields: [createdById], references: [id], onDelete: SetNull) agents Agent[] @relation("AgentRuntime") agentProfiles AgentProfile[] @relation("AgentProfileRuntime") secrets RuntimeSecret[] @@ -3205,8 +3229,12 @@ model ExternalWebhookEvent { /// `profileId` is null (un-backfilled rows). model AgentProfile { id String @id @default(cuid()) - /// User who owns / defined this profile. - ownerId String + /// PERSONAL profiles have a human owner; SERVICE profiles are managed by + /// instance administrators and survive the issuing human's departure. + ownerType ResourceOwnerType @default(PERSONAL) + ownerId String? + /// Creator provenance only. Unknown historical creators remain null. + createdById String? /// Stable handle, unique per owner. Maps to a Hermes profile dir name. profileKey String name String @@ -3244,7 +3272,8 @@ model AgentProfile { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) + owner User? @relation("AgentProfileOwner", fields: [ownerId], references: [id], onDelete: Restrict) + createdBy User? @relation("AgentProfileCreator", fields: [createdById], references: [id], onDelete: SetNull) requestedBy User? @relation("AgentProfileRequester", fields: [requestedById], references: [id], onDelete: SetNull) runtime Runtime? @relation("AgentProfileRuntime", fields: [runtimeId], references: [id], onDelete: SetNull) bindings Agent[] @relation("AgentProfileBinding") diff --git a/prisma/seed.ts b/prisma/seed.ts index 539b7094..8b85fca0 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -338,7 +338,9 @@ async function main() { kind: RuntimeKind.REMOTE_HTTP, adapterKey: "mock-runs", endpoint: "mock://e2e", + ownerType: "PERSONAL", ownerId: owner.id, + createdById: owner.id, heartbeatAt: e2eNow, connectedAt: e2eNow, }, @@ -378,7 +380,9 @@ async function main() { adapterKey: "codex-app-server", endpoint: "ws://127.0.0.1:4505", config: {}, + ownerType: "PERSONAL", ownerId: owner.id, + createdById: owner.id, }, }); } diff --git a/src/app/(app)/admin/users/page.tsx b/src/app/(app)/admin/users/page.tsx index 68d00ce6..d6669c7d 100644 --- a/src/app/(app)/admin/users/page.tsx +++ b/src/app/(app)/admin/users/page.tsx @@ -9,9 +9,9 @@ export default function AdminUsersPage() { return ( diff --git a/src/app/(app)/agents/[id]/page.tsx b/src/app/(app)/agents/[id]/page.tsx index 81f70943..6347345a 100644 --- a/src/app/(app)/agents/[id]/page.tsx +++ b/src/app/(app)/agents/[id]/page.tsx @@ -3,13 +3,15 @@ import { GlobalShell } from "@/components/global-shell/global-shell"; import { getGlobalShellData } from "@/components/global-shell/data"; import { AgentDetailContent } from "@/app/(app)/settings/agents/[id]/agent-detail-content"; -/** Mission Control profile detail and binding control surface. */ +/** Agent identity, workspace access, and connection detail. */ export default async function AgentFleetDetailPage({ params, + searchParams, }: { params: Promise<{ id: string }>; + searchParams: Promise<{ workspace?: string; setup?: string }>; }) { - const [data, { id }] = await Promise.all([getGlobalShellData(), params]); + const [data, { id }, query] = await Promise.all([getGlobalShellData(), params, searchParams]); if (!data) redirect("/signin"); return ( @@ -18,8 +20,7 @@ export default async function AgentFleetDetailPage({ workspaces={data.workspaces} activePath="/agents" crumbs={[ - { label: "Forge", href: "/" }, - { label: "Mission Control", href: "/" }, + { label: "Home", href: "/" }, { label: "Agents", href: "/agents" }, { label: "Profile" }, ]} @@ -30,6 +31,10 @@ export default async function AgentFleetDetailPage({ id={id} isInstanceAdmin={data.user.instanceRole === "INSTANCE_ADMIN"} workspaces={data.workspaces} + initialWorkspaceId={query.workspace} + setupMode={ + query.setup === "external" || query.setup === "managed" ? query.setup : undefined + } /> ); diff --git a/src/app/(app)/agents/page.tsx b/src/app/(app)/agents/page.tsx index 5736c603..0889b8ed 100644 --- a/src/app/(app)/agents/page.tsx +++ b/src/app/(app)/agents/page.tsx @@ -3,7 +3,7 @@ import { GlobalShell } from "@/components/global-shell/global-shell"; import { getGlobalShellData } from "@/components/global-shell/data"; import { AgentsContent } from "@/app/(app)/settings/agents/agents-content"; -/** Mission Control fleet: define, bind, and operate global agent identities. */ +/** Agent identity, access, and connection setup. */ export default async function AgentFleetPage({ searchParams, }: { @@ -17,14 +17,10 @@ export default async function AgentFleetPage({ user={data.user} workspaces={data.workspaces} activePath="/agents" - crumbs={[ - { label: "Forge", href: "/" }, - { label: "Mission Control", href: "/" }, - { label: "Agents" }, - ]} - title="Agent fleet" - subtitle="Define identities, connect execution, bind workspaces, and monitor live use." - eyebrow="Mission Control" + crumbs={[{ label: "Home", href: "/" }, { label: "Agents" }]} + title="Agents" + subtitle="Manage identities, workspace access, and how agents connect or execute." + eyebrow="Agents" scope="control" contentClass="flex flex-col overflow-hidden" > diff --git a/src/app/(app)/mission-control/page.tsx b/src/app/(app)/mission-control/page.tsx new file mode 100644 index 00000000..1d01844d --- /dev/null +++ b/src/app/(app)/mission-control/page.tsx @@ -0,0 +1,22 @@ +import { redirect } from "next/navigation"; +import { GlobalShell } from "@/components/global-shell/global-shell"; +import { getGlobalShellData } from "@/components/global-shell/data"; +import { MissionControlHome } from "@/components/global-shell/mission-control-home"; + +export default async function MissionControlPage() { + const data = await getGlobalShellData(); + if (!data) redirect("/signin"); + + return ( + + + + ); +} diff --git a/src/app/(app)/page.tsx b/src/app/(app)/page.tsx index e8fefef5..e8680f36 100644 --- a/src/app/(app)/page.tsx +++ b/src/app/(app)/page.tsx @@ -1,15 +1,13 @@ import { redirect } from "next/navigation"; import { GlobalShell } from "@/components/global-shell/global-shell"; import { getGlobalShellData } from "@/components/global-shell/data"; -import { MissionControlHome } from "@/components/global-shell/mission-control-home"; +import { PersonalHome } from "@/components/global-shell/personal-home"; /** - * Mission Control — the cross-workspace home. Renders inside the global - * ("concourse") shell. Shell chrome (user + workspaces for the rail) is - * resolved server-side; the aggregations stream client-side from the - * `global.*` tRPC routers. Part of the multi-workspace restructure. + * Personal home across accessible workspaces. Operational health has its + * own /mission-control route so runtime setup does not gate everyday work. */ -export default async function MissionControlPage() { +export default async function HomePage() { const data = await getGlobalShellData(); if (!data) redirect("/signin"); @@ -18,13 +16,12 @@ export default async function MissionControlPage() { user={data.user} workspaces={data.workspaces} activePath="/" - crumbs={[{ label: "Forge", href: "/" }, { label: "Mission Control" }]} - title="Mission Control" - subtitle="Across all workspaces" - eyebrow="Home" + crumbs={[{ label: "Home" }]} + title="My work" + subtitle="Your assignments and workspaces, together." big > - + ); } diff --git a/src/app/(app)/settings/access/page.tsx b/src/app/(app)/settings/access/page.tsx index c7fb272f..977944e2 100644 --- a/src/app/(app)/settings/access/page.tsx +++ b/src/app/(app)/settings/access/page.tsx @@ -91,8 +91,8 @@ const PROVIDERS: Array<{ { id: "codex", label: "Codex", - eyebrow: "CLI/IDE", - description: "Codex CLI or IDE extension using streamable HTTP MCP config.", + eyebrow: "desktop/CLI/IDE", + description: "Codex Desktop, CLI, or IDE extension connected to Forge over MCP.", Icon: Terminal, }, { @@ -152,8 +152,8 @@ export default function AccessPage() { const [step, setStep] = useState(0); const [provider, setProvider] = useState("hermes"); const [name, setName] = useState(""); - const [scopes, setScopes] = useState(FULL_ACCESS); - const [preset, setPreset] = useState("full"); + const [scopes, setScopes] = useState(DEFAULT_SCOPES); + const [preset, setPreset] = useState("default"); const [expiresInDays, setExpiresInDays] = useState(""); const [linkedAgentId, setLinkedAgentId] = useState(""); @@ -222,8 +222,8 @@ export default function AccessPage() { function openCreate() { setProvider("hermes"); setName(""); - setScopes(FULL_ACCESS); - setPreset("full"); + setScopes(DEFAULT_SCOPES); + setPreset("default"); setExpiresInDays(""); setLinkedAgentId(""); setStep(0); @@ -377,8 +377,8 @@ export default function AccessPage() { const nextProvider = isMcpProvider(providerParam) ? providerParam : "hermes"; setProvider(nextProvider); setName(`${PROVIDER_LABEL.get(nextProvider) ?? "Agent"} Forge MCP`); - setScopes(FULL_ACCESS); - setPreset("full"); + setScopes(DEFAULT_SCOPES); + setPreset("default"); setExpiresInDays(""); setLinkedAgentId(requestedAgentId ?? ""); setStep(0); @@ -1363,8 +1363,8 @@ function Intro({ baseUrl }: { baseUrl: string }) {
This page is the credential source of truth: create, inspect, rotate, revoke, and delete - every MCP client here. Primary execution runtimes stay with the identity in Mission Control - → Agents. + every MCP client here. These clients execute externally and do not require a managed + runtime. Agent identity and optional managed execution are configured in Agents.
); diff --git a/src/app/(app)/settings/agents/[id]/agent-detail-content.tsx b/src/app/(app)/settings/agents/[id]/agent-detail-content.tsx index 17f3b095..d6ade9b0 100644 --- a/src/app/(app)/settings/agents/[id]/agent-detail-content.tsx +++ b/src/app/(app)/settings/agents/[id]/agent-detail-content.tsx @@ -27,6 +27,8 @@ import { trpc } from "@/lib/trpc"; import { relativeTime } from "@/lib/utils"; import { workspaceChipColor } from "@/components/global-shell/global-shell"; import { AgentAvatar } from "@/components/agents/agent-avatar"; +import { agentOwnerLabel } from "@/lib/agent-ownership-ui"; +import { TransferAgentOwnershipDialog } from "../transfer-agent-ownership-dialog"; /** * Client body for the agent profile detail page. Reads @@ -107,20 +109,28 @@ function ReadinessCard({ label: string; value: string; detail: string; - ready: boolean; + ready: boolean | null; }) { const StateIcon = ready ? CheckCircle2 : TriangleAlert; return (
{label} - + {ready !== null && ( + + )}
{value}

{detail}

@@ -140,10 +150,14 @@ export function AgentDetailContent({ id, isInstanceAdmin, workspaces, + initialWorkspaceId, + setupMode, }: { id: string; isInstanceAdmin: boolean; workspaces: ProfileWorkspace[]; + initialWorkspaceId?: string; + setupMode?: "external" | "managed"; }) { const router = useRouter(); const utils = trpc.useUtils(); @@ -157,15 +171,30 @@ export function AgentDetailContent({ runtimeId: string; capabilities: string; } | null>(null); - const [bindWorkspaceId, setBindWorkspaceId] = useState(""); + const [bindWorkspaceId, setBindWorkspaceId] = useState( + workspaces.some( + (workspace) => + workspace.id === initialWorkspaceId && + (workspace.role === "OWNER" || workspace.role === "ADMIN"), + ) + ? initialWorkspaceId! + : "", + ); const [unbindTarget, setUnbindTarget] = useState<{ agentId: string; workspaceId: string; workspaceName: string; } | null>(null); const [dangerOpen, setDangerOpen] = useState(false); + const [transferOpen, setTransferOpen] = useState(false); const { data: a, isLoading, error } = trpc.agents.profiles.get.useQuery({ id }); - const { data: runtimes } = trpc.global.runtimes.useQuery(); + const { data: runtimes } = trpc.global.runtimeOptions.useQuery( + { + ownerType: a?.ownerType === "SERVICE" ? "SERVICE" : "PERSONAL", + ownerId: a?.ownerType === "PERSONAL" ? (a.ownerId ?? undefined) : undefined, + }, + { enabled: !!a?.canEdit }, + ); const updateProfile = trpc.agents.profiles.update.useMutation({ onSuccess: async () => { toast.success("Agent profile saved"); @@ -178,7 +207,7 @@ export function AgentDetailContent({ }); const bindProfile = trpc.agents.bindings.bindFromMissionControl.useMutation({ onSuccess: async () => { - toast.success("Agent bound to the workspace."); + toast.success("Agent added to the workspace."); setBindWorkspaceId(""); await Promise.all([ utils.agents.profiles.get.invalidate({ id }), @@ -189,7 +218,7 @@ export function AgentDetailContent({ }); const unbindProfile = trpc.agents.bindings.unbindFromMissionControl.useMutation({ onSuccess: async () => { - toast.success("Agent unbound. Runs, chats, and history are preserved."); + toast.success("Workspace access removed. Runs, chats, and history are preserved."); setUnbindTarget(null); await Promise.all([ utils.agents.profiles.get.invalidate({ id }), @@ -210,7 +239,7 @@ export function AgentDetailContent({ toast.success( result.action === "deleted" ? `Deleted ${result.name}.` - : `Archived ${result.name}; ${result.boundAgents} workspace binding${result.boundAgents === 1 ? "" : "s"} still reference it.`, + : `Archived ${result.name}; ${result.boundAgents} workspace${result.boundAgents === 1 ? "" : "s"} retain its history.`, ); router.push("/agents"); }, @@ -268,14 +297,17 @@ export function AgentDetailContent({ (client) => !client.revokedAt && (!client.expiresAt || new Date(client.expiresAt).getTime() > Date.now()), ); - const executionReady = !!a.runtime; const profile = a; const boundWorkspaceIds = new Set(a.bindings.map((binding) => binding.workspace.id)); const bindableWorkspaces = workspaces.filter( (workspace) => + a.availability.state === "AVAILABLE" && + (isInstanceAdmin || a.ownedByMe || a.instanceShared) && (workspace.role === "OWNER" || workspace.role === "ADMIN") && !boundWorkspaceIds.has(workspace.id), ); + const setupWorkspace = workspaces.find((workspace) => workspace.id === initialWorkspaceId); + const setupBinding = a.bindings.find((binding) => binding.workspace.id === initialWorkspaceId); function beginDefinitionEdit() { setDefinitionDraft({ @@ -338,42 +370,126 @@ export function AgentDetailContent({ All agents - Global profile + Agent identity
+ {setupMode && setupWorkspace && ( +
+

+ {setupMode === "external" ? "Connect an MCP client" : "Configure managed execution"}{" "} + · {setupWorkspace.name} +

+

+ {!setupBinding + ? "Add workspace access below to continue. This requires a workspace owner or admin." + : setupMode === "external" + ? "Issue a scoped client credential for this workspace. The client executes externally; the runtime configuration below does not describe its execution." + : "Review the identity’s managed runtime, then configure routing and approvals for this workspace. A configured host alone does not prove that a run executed."} +

+
+ {!setupBinding ? ( + + Review workspace access → + + ) : setupMode === "external" && setupBinding.canManage ? ( + + Add MCP client → + + ) : ( + + Workspace agent policy → + + )} + {setupMode === "managed" && ( + <> + + Review runtime assignment → + + + Manage workspace runtimes → + + + )} +
+
+ )} +
+ + {agentOwnerLabel(a)} + {isInstanceAdmin && ( + + )} + + + {a.instanceShared + ? "Instance catalog" + : a.ownerType === "SERVICE" + ? "Instance-admin setup only" + : "Owner-only catalog"}{" "} + · {a.availability.state.toLowerCase().replaceAll("_", " ")} + + + {a.createdBy?.name ?? "Historical creator not recorded"} +

+ Creation provenance is preserved independently of ownership. +

+
+

+ Catalog availability allows an administrator to add an identity. Actual access is + granted per workspace below; ownership does not grant access to another workspace’s + data. +

+
0} + ready={null} /> 0} + value={`${activeClients.length} valid credentials`} + detail="Valid keys do not prove that a client or delivery session is currently active." + ready={null} />
{/* Definition */} +
- + options={Object.values(AgentProvider).map((value) => ({ + value, + label: value.toLowerCase(), + }))} + className="h-9 w-full" + /> - + options={[ + { value: "DEFAULT", label: "Default" }, + ...Object.values(RunEngine).map((value) => ({ + value, + label: value.toLowerCase(), + })), + ]} + className="h-9 w-full" + /> - + allowNone + noneLabel="Not configured" + placeholder="Not configured" + options={[ + ...(a.runtime && + !(runtimes ?? []).some((runtime) => runtime.id === a.runtime!.id) + ? [{ value: a.runtime.id, label: a.runtime.name, secondary: "Assigned" }] + : []), + ...(runtimes ?? []).map((runtime) => ({ + value: runtime.id, + label: runtime.name, + secondary: runtime.kind.toLowerCase().replaceAll("_", " "), + })), + ]} + className="h-9 w-full" + /> )} - + {a.runtime ? ( @@ -526,14 +643,20 @@ export function AgentDetailContent({ ) : ( - unassigned + + Not configured · not required for MCP + )} - + {a.instanceShared ? ( - Shared with the whole instance + Available for workspace admins to add ) : ( - Private to you + + {a.ownerType === "SERVICE" + ? "Instance-admin setup only" + : "Owner or instance-admin setup only"} + )}
@@ -559,14 +682,14 @@ export function AgentDetailContent({
{a.bindings.length === 0 ? ( ) : ( a.bindings.map((binding) => { @@ -585,7 +708,7 @@ export function AgentDetailContent({ {binding.workspace.name} - {bindingActive.length} active + {bindingActive.length} valid credentials
@@ -628,14 +751,14 @@ export function AgentDetailContent({
Template markdown
- {a.bindings.length} active binding{a.bindings.length === 1 ? "" : "s"} + {a.bindings.length} enabled workspace{a.bindings.length === 1 ? "" : "s"}
{a.canEdit && ( @@ -706,14 +829,15 @@ export function AgentDetailContent({
{/* Workspace bindings */} +
0 ? (
setBindWorkspaceId(value ?? "")} options={bindableWorkspaces.map((workspace) => ({ @@ -734,7 +858,7 @@ export function AgentDetailContent({ bindProfile.mutate({ workspaceId: bindWorkspaceId, profileId: a.id }) } > - Bind + Add access
) : undefined @@ -752,7 +876,7 @@ export function AgentDetailContent({ {a.bindings.length === 0 ? ( ) : ( @@ -829,7 +953,7 @@ export function AgentDetailContent({ }) } > - Unbind + Remove access )} @@ -840,7 +964,10 @@ export function AgentDetailContent({
{/* Recent runs */} -
+
{a.recentRuns.length === 0 ? ( @@ -887,8 +1014,8 @@ export function AgentDetailContent({

{isInstanceAdmin - ? "Unused profiles are deleted permanently. Profiles with workspace bindings are archived so runs, chats, and history remain intact." - : "Archiving hides the profile from Mission Control and binding catalogs while preserving workspace history."} + ? "Unused profiles are deleted permanently. Profiles used in workspaces are archived so runs, chats, and history remain intact." + : "Archiving hides the identity from Agents and setup catalogs while preserving workspace history."}

+ {transferOpen && isInstanceAdmin && ( + setTransferOpen(false)} /> + )} !open && setUnbindTarget(null)} variant="destructive" title={`Unbind from ${unbindTarget?.workspaceName}?`} - description="The agent stops being available in this workspace. Runs, chats, and history are preserved, and re-binding restores the same binding." + description="The agent stops being available in this workspace. Runs, chats, and history are preserved. Adding access again restores the same workspace identity." primaryLabel="Unbind" loading={unbindProfile.isPending} onConfirm={async () => { @@ -934,8 +1064,8 @@ export function AgentDetailContent({ title={`${isInstanceAdmin ? "Remove" : "Archive"} ${a.name}?`} description={ isInstanceAdmin - ? "If the profile has never been bound, it is permanently deleted. Otherwise it is archived and all workspace history stays intact." - : "The profile is hidden from the fleet and binding catalogs. Existing workspace history is preserved." + ? "If the identity has never been used in a workspace, it is permanently deleted. Otherwise it is archived and all workspace history stays intact." + : "The identity is hidden from Agents and setup catalogs. Existing workspace history is preserved." } primaryLabel={isInstanceAdmin ? "Remove profile" : "Archive profile"} loading={isInstanceAdmin ? removeProfile.isPending : archiveProfile.isPending} diff --git a/src/app/(app)/settings/agents/agent-setup-dialog.tsx b/src/app/(app)/settings/agents/agent-setup-dialog.tsx new file mode 100644 index 00000000..dc575b3a --- /dev/null +++ b/src/app/(app)/settings/agents/agent-setup-dialog.tsx @@ -0,0 +1,201 @@ +"use client"; + +import Link from "next/link"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { CenterModal } from "@/components/ui/modal"; +import { Combobox } from "@/components/ui/combobox"; +import { agentSetupDestination, type AgentSetupMode } from "@/lib/agent-setup"; +import { + personalAgentCreationCopy, + type PersonalAgentCreationPolicy, +} from "@/lib/agent-ownership-ui"; + +type Workspace = { id: string; slug: string; name: string; key: string; role?: string }; +type Profile = { + id: string; + name: string; + ownedByMe: boolean; + instanceShared: boolean; + ownerType?: string; + availability: { state: string }; + bindings: Array<{ id: string; workspace: { id: string } }>; +}; + +export function AgentSetupDialog({ + mode, + workspaces, + profiles, + initialWorkspaceId, + isInstanceAdmin, + personalCreationPolicy, + onClose, + onCreate, +}: { + mode: AgentSetupMode; + workspaces: Workspace[]; + profiles: Profile[]; + initialWorkspaceId?: string; + isInstanceAdmin: boolean; + personalCreationPolicy?: PersonalAgentCreationPolicy; + onClose: () => void; + onCreate: (workspaceId: string) => void; +}) { + const [workspaceId, setWorkspaceId] = useState( + workspaces.some((workspace) => workspace.id === initialWorkspaceId) + ? initialWorkspaceId! + : workspaces.length === 1 + ? workspaces[0].id + : "", + ); + const [profileId, setProfileId] = useState(""); + const workspace = workspaces.find((item) => item.id === workspaceId); + const profile = profiles.find((item) => item.id === profileId); + const canManage = workspace?.role === "OWNER" || workspace?.role === "ADMIN"; + const destination = agentSetupDestination({ mode, workspace, profile }); + const binding = profile?.bindings.find((item) => item.workspace.id === workspaceId); + const creation = personalAgentCreationCopy(personalCreationPolicy); + + return ( + { + if (!open) onClose(); + }} + size="md" + title={mode === "external" ? "Connect an external client" : "Let Forge run an agent"} + description={ + mode === "external" + ? "Use Codex Desktop, a CLI, or another MCP client. Your client executes the work; no Forge runtime is required." + : "Forge dispatches work to a managed execution runtime. Choose the workspace and agent before configuring its host." + } + footer={ +
+ + {destination ? ( + + Continue + + ) : ( + + )} +
+ } + > +
+
+

Workspace access

+ { + setWorkspaceId(value ?? ""); + setProfileId(""); + }} + options={workspaces.map((item) => ({ + value: item.id, + label: item.name, + secondary: item.key, + }))} + placeholder="Choose workspace…" + className="w-full" + /> +

+ Credentials and permissions apply only to this workspace. You can add other workspaces + separately. +

+
+
+

Acting identity

+ setProfileId(value ?? "")} + options={[ + ...(mode === "external" + ? [{ value: "", label: "My account", secondary: "Personal MCP token" }] + : []), + ...profiles + .filter( + (item) => + item.availability.state === "AVAILABLE" && + (isInstanceAdmin || item.ownedByMe || item.instanceShared), + ) + .map((item) => ({ + value: item.id, + label: item.name, + secondary: + item.ownerType === "SERVICE" + ? "Service-owned" + : item.ownedByMe + ? "Owned by you" + : "Available agent", + })), + ]} + placeholder={ + mode === "external" ? "My account · Personal MCP token" : "Choose an agent…" + } + className="w-full" + /> +

+ {profile + ? "The agent is the acting identity. The issued key also records the human operator; sharing an identity does not grant workspace access." + : mode === "external" + ? "Actions are attributed to your user account. Choose an agent above for a separate agent identity." + : "Choose an existing agent or create/request a new identity below."} +

+
+ {workspace?.role === "GUEST" && ( +

+ Guests cannot create credentials. Ask a workspace administrator for access. +

+ )} + {workspace && mode === "managed" && !canManage && ( +

+ Managed setup requires an owner or admin of this workspace. +

+ )} + {workspace && profile && mode === "external" && (!binding || !canManage) && ( +

+ Continue to this agent’s workspace access.{" "} + {canManage + ? "Add it to the workspace, then choose Add MCP client." + : "A workspace administrator must add the agent or issue its client credential."} +

+ )} + {workspace && mode === "managed" && canManage && ( + + Register or inspect a runtime in {workspace.name} → + + )} +
+ +

+ {isInstanceAdmin + ? "Choose personal or service ownership when creating an identity. Workspace access and catalog sharing remain separate controls." + : creation.description} +

+
+
+
+ ); +} diff --git a/src/app/(app)/settings/agents/agents-content.tsx b/src/app/(app)/settings/agents/agents-content.tsx index 2bb2f817..2b55cc47 100644 --- a/src/app/(app)/settings/agents/agents-content.tsx +++ b/src/app/(app)/settings/agents/agents-content.tsx @@ -1,8 +1,9 @@ "use client"; import Link from "next/link"; +import { useRouter } from "next/navigation"; import { useState, type ReactNode } from "react"; -import { Bot, ChevronRight, KeyRound, Link2, Plus, Server, Shield } from "lucide-react"; +import { Bot, ChevronRight, KeyRound, Link2, Server } from "lucide-react"; import { AgentProvider, RunEngine } from "@prisma/client"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; @@ -14,6 +15,9 @@ import { trpc } from "@/lib/trpc"; import { workspaceChipColor } from "@/components/global-shell/global-shell"; import { AgentAvatar } from "@/components/agents/agent-avatar"; import { RequestProfileDialog } from "@/app/(app)/w/[slug]/settings/agents/request-profile-dialog"; +import { AgentSetupDialog } from "./agent-setup-dialog"; +import { matchesAgentView, type AgentListView, type AgentSetupMode } from "@/lib/agent-setup"; +import { agentOwnerLabel, personalAgentCreationCopy } from "@/lib/agent-ownership-ui"; /** * Client body for the global agent profiles list. Each card links to its @@ -68,8 +72,10 @@ export function AgentsContent({ workspaces: Workspace[]; initialWorkspaceId?: string; }) { + const router = useRouter(); const { data: profiles, isLoading } = trpc.agents.profiles.list.useQuery(); - const { data: runtimes } = trpc.global.runtimes.useQuery(); + const policy = trpc.agents.profiles.policy.useQuery(); + const creation = personalAgentCreationCopy(policy.data?.personalAgentCreation); const utils = trpc.useUtils(); const [createOpen, setCreateOpen] = useState(false); const [name, setName] = useState(""); @@ -80,8 +86,17 @@ export function AgentsContent({ const [description, setDescription] = useState(""); const [avatar, setAvatar] = useState(""); const [capsText, setCapsText] = useState(""); - const [instanceShared, setInstanceShared] = useState(true); + const [instanceShared, setInstanceShared] = useState(false); + const [ownerType, setOwnerType] = useState<"PERSONAL" | "SERVICE">("PERSONAL"); + const { data: runtimes } = trpc.global.runtimeOptions.useQuery( + { ownerType }, + { enabled: isInstanceAdmin && createOpen }, + ); const [requestOpen, setRequestOpen] = useState(false); + const [setupMode, setSetupMode] = useState(null); + const [creationMode, setCreationMode] = useState("managed"); + const [setupWorkspaceId, setSetupWorkspaceId] = useState(initialWorkspaceId ?? ""); + const [view, setView] = useState("available"); const [workspaceId, setWorkspaceId] = useState( initialWorkspaceId && workspaces.some((workspace) => workspace.id === initialWorkspaceId) ? initialWorkspaceId @@ -90,10 +105,11 @@ export function AgentsContent({ const visibleProfiles = (profiles ?? []).filter( (profile) => - workspaceId === "all" || - (workspaceId === "unbound" - ? profile.bindings.length === 0 - : profile.bindings.some((binding) => binding.workspace.id === workspaceId)), + matchesAgentView(profile, view, isInstanceAdmin) && + (workspaceId === "all" || + (workspaceId === "unbound" + ? profile.bindings.length === 0 + : profile.bindings.some((binding) => binding.workspace.id === workspaceId))), ); function resetCreate() { @@ -105,15 +121,19 @@ export function AgentsContent({ setDescription(""); setAvatar(""); setCapsText(""); - setInstanceShared(true); + setInstanceShared(false); + setOwnerType("PERSONAL"); } const createProfile = trpc.agents.profiles.create.useMutation({ - onSuccess: () => { + onSuccess: (created) => { toast.success("Agent profile created."); void utils.agents.profiles.list.invalidate(); resetCreate(); setCreateOpen(false); + router.push( + `/agents/${created.id}?workspace=${encodeURIComponent(setupWorkspaceId)}&setup=${creationMode}`, + ); }, onError: (e) => toast.error(e.message), }); @@ -132,11 +152,12 @@ export function AgentsContent({ profileKey: profileKey.trim(), provider, runEngine: runEngine === "DEFAULT" ? null : runEngine, - runtimeId: runtimeId || null, + runtimeId: creationMode === "managed" ? runtimeId || null : null, description: description.trim() || undefined, avatar: avatar.trim() || undefined, baseCapabilities, instanceShared, + ownerType, }); } @@ -144,68 +165,44 @@ export function AgentsContent({ <>
-
- {[ - ["Define", "Identity & runtime"], - ["Bind", "Workspace adoption"], - ["Operate", "Runs & connections"], - ["Govern", isInstanceAdmin ? "Instance policy" : "Admin controlled"], - ].map(([step, detail], index) => ( -
-
- 0{index + 1} - {step} -
-

{detail}

-
- ))} -
- - {/* Create affordance / admin-gated hint */} -
- -
- New agent profiles are instance-admin-gated. - - {isInstanceAdmin - ? "You can create and share profiles across the instance." - : "Members can request a profile; an instance admin approves it."} - -
- {isInstanceAdmin ? ( - - ) : ( -
+
+ +

Let Forge run an agent

+

+ Choose an identity and runtime for work dispatched and monitored by Forge. +

+ - )} +
+ {!isInstanceAdmin && ( +

+ {policy.isError + ? "Creation policy could not be loaded. Try refreshing before creating a new identity." + : creation.description} +

+ )}
- Fleet scope - - Global profiles - + Agent identities

- Filter by workspace binding without leaving Mission Control. + Ownership, catalog availability, and workspace access are separate.

@@ -216,7 +213,7 @@ export function AgentsContent({ onChange={(value) => setWorkspaceId(value ?? "all")} options={[ { value: "all", label: "All workspaces" }, - { value: "unbound", label: "Not bound" }, + { value: "unbound", label: "No visible workspace access" }, ...workspaces.map((workspace) => ({ value: workspace.id, label: workspace.name, @@ -228,6 +225,25 @@ export function AgentsContent({ />
+
+ {( + [ + { value: "mine", label: "My agents" }, + { value: "available", label: "Available to me" }, + ...(isInstanceAdmin ? [{ value: "all", label: "All agents · Admin" }] : []), + ] as const + ).map((item) => ( + + ))} +
{isLoading ? (
@@ -238,14 +254,14 @@ export function AgentsContent({ variant="section" icon={} title="No agent profiles yet" - description="Agent profiles are global identities you bind into workspaces." + description="Start with a personal MCP connection or create/request an agent identity using setup above." /> ) : visibleProfiles.length === 0 ? ( } title="No profiles in this fleet view" - description="Choose another workspace filter or bind a profile from its Mission Control detail page." + description="Choose another filter or add workspace access from an agent’s detail page." /> ) : (
@@ -257,7 +273,7 @@ export function AgentsContent({ >
-
+
{a.name} @@ -265,17 +281,22 @@ export function AgentsContent({ {a.instanceShared && ( - instance + Instance catalog )} - {!a.ownedByMe && ( - - shared + {!a.instanceShared && ( + + {a.ownerType === "SERVICE" ? "Admin-only setup" : "Owner-only catalog"} )} - - global profile - + {a.availability.state !== "AVAILABLE" && ( + + {a.availability.state.toLowerCase().replaceAll("_", " ")} + + )} +
+
+ Owner: {agentOwnerLabel(a)}
{a.description && (
@@ -298,19 +319,19 @@ export function AgentsContent({ {a.runtime.name}
- {a.runtime.kind.toLowerCase().replace("_", " ")} + Managed execution configured
) : ( - unassigned + No managed runtime )}
- {a.bindings.length} binding{a.bindings.length === 1 ? "" : "s"} + {a.bindings.length} workspace{a.bindings.length === 1 ? "" : "s"} @@ -342,6 +363,26 @@ export function AgentsContent({ )}
+ {setupMode && ( + setSetupMode(null)} + onCreate={(selectedWorkspaceId) => { + setSetupWorkspaceId(selectedWorkspaceId); + setCreationMode(setupMode); + setSetupMode(null); + if (isInstanceAdmin) setCreateOpen(true); + else if (creation.enabled) setRequestOpen(true); + }} + /> + )} { @@ -350,12 +391,36 @@ export function AgentsContent({ }} size="md" title="Create agent profile" - description="Create the identity, then choose its one primary execution runtime. Workspace bindings and MCP clients can be added afterward." + description={ + creationMode === "external" + ? "Create the acting identity, then add workspace access and an MCP client key. External execution does not require a managed runtime." + : "Create the acting identity and optionally select a managed runtime. Continue to workspace access after creation." + } primaryLabel="Create profile" onPrimary={submitCreate} loading={createProfile.isPending} >
+ + { + setOwnerType(value === "SERVICE" ? "SERVICE" : "PERSONAL"); + setRuntimeId(""); + }} + options={[ + { value: "PERSONAL", label: "Personal · owned by my account" }, + { value: "SERVICE", label: "Service-owned · instance administrators" }, + ]} + className="w-full" + /> +

+ {ownerType === "SERVICE" + ? "A durable service identity managed by instance administrators. Your account is recorded as creator, not owner. Catalog availability is configured separately below." + : "You own this identity. An instance administrator can transfer ownership later; sharing does not change its owner."} +

+
- - - - - - - + onChange={(value) => { + if (value) setProvider(value as AgentProvider); + }} + options={Object.values(AgentProvider).map((value) => ({ + value, + label: value.toLowerCase(), + }))} + className="h-9 w-full" + /> + {creationMode === "managed" && ( + + setRunEngine((value ?? "DEFAULT") as "DEFAULT" | RunEngine)} + options={[ + { value: "DEFAULT", label: "Default" }, + ...Object.values(RunEngine).map((value) => ({ + value, + label: value.toLowerCase(), + })), + ]} + className="h-9 w-full" + /> + + )} + {creationMode === "managed" && ( + + setRuntimeId(value ?? "")} + ariaLabel="Primary execution runtime" + allowNone + noneLabel="Not assigned yet" + placeholder="Not assigned yet" + options={(runtimes ?? []).map((runtime) => ({ + value: runtime.id, + label: runtime.name, + secondary: runtime.kind.toLowerCase().replaceAll("_", " "), + }))} + className="h-9 w-full" + /> + + )}
- Execution host + {" "} + {creationMode === "external" ? "External execution" : "Managed execution"}
- Forge starts work through this runtime. MCP clients are separate and can be added per - workspace. + {creationMode === "external" + ? "Your MCP client executes the work. Add workspace access and a client credential after creating this identity." + : "Forge dispatches work through the selected runtime. MCP clients are separate connections and can be added per workspace."}
setAvatar(e.target.value)} placeholder="VI" /> @@ -457,13 +531,28 @@ export function AgentsContent({ Share in every workspace catalog - Turn this off for a private profile that only the owner can bind. + Allows workspace admins to add this identity. It does not grant access to any + workspace or data.{" "} + {ownerType === "SERVICE" + ? "Without catalog sharing, only instance administrators can set it up." + : "Owner-only by default."}
- + { + setView("mine"); + if (created.approvedAt) + router.push( + `/agents/${created.id}?workspace=${encodeURIComponent(setupWorkspaceId)}&setup=${creationMode}`, + ); + }} + /> ); } diff --git a/src/app/(app)/settings/agents/page.tsx b/src/app/(app)/settings/agents/page.tsx index 56323183..f3eed49b 100644 --- a/src/app/(app)/settings/agents/page.tsx +++ b/src/app/(app)/settings/agents/page.tsx @@ -1,9 +1,8 @@ import { redirect } from "next/navigation"; /** - * Global agent **profiles** — the user-owned definitions (the same * Compatibility route retained for bookmarks from the former account-level - * Agent Studio. Mission Control now owns agent management at `/agents`. + * Agent Studio. Agent management now lives at `/agents`. */ export default async function GlobalAgentsPage() { redirect("/agents"); diff --git a/src/app/(app)/settings/agents/transfer-agent-ownership-dialog.tsx b/src/app/(app)/settings/agents/transfer-agent-ownership-dialog.tsx new file mode 100644 index 00000000..51aa2e1a --- /dev/null +++ b/src/app/(app)/settings/agents/transfer-agent-ownership-dialog.tsx @@ -0,0 +1,211 @@ +"use client"; + +import { useState } from "react"; +import { toast } from "sonner"; +import { trpc } from "@/lib/trpc"; +import { Button } from "@/components/ui/button"; +import { Combobox } from "@/components/ui/combobox"; +import { CenterModal } from "@/components/ui/modal"; +import { agentOwnerLabel } from "@/lib/agent-ownership-ui"; + +type Profile = { + id: string; + name: string; + ownerType: string; + ownerId: string | null; + owner: { name: string | null } | null; + updatedAt: Date; + instanceShared: boolean; + bindingCount: number; + runtime: { name: string } | null; +}; + +export function TransferAgentOwnershipDialog({ + profile, + onClose, +}: { + profile: Profile; + onClose: () => void; +}) { + // Hold the reviewed ownership snapshot. A background refetch must not silently + // accept a different owner or updated profile during confirmation. + const [snapshot] = useState(profile); + const currentOwnerType = + snapshot.ownerType === "PERSONAL" || snapshot.ownerType === "SERVICE" + ? snapshot.ownerType + : null; + const [ownerType, setOwnerType] = useState<"PERSONAL" | "SERVICE">( + currentOwnerType ?? "PERSONAL", + ); + const [ownerId, setOwnerId] = useState(snapshot.ownerId ?? ""); + const [clearRuntime, setClearRuntime] = useState(false); + const [acknowledged, setAcknowledged] = useState(false); + const [stale, setStale] = useState(false); + const targets = trpc.agents.profiles.ownershipCandidates.useQuery(); + const utils = trpc.useUtils(); + const transfer = trpc.agents.profiles.transferOwnership.useMutation({ + onSuccess: async () => { + await Promise.all([ + utils.agents.profiles.get.invalidate({ id: snapshot.id }), + utils.agents.profiles.list.invalidate(), + ]); + toast.success( + "Agent ownership transferred. Workspace access and credentials were not transferred.", + ); + onClose(); + }, + onError: (error) => { + toast.error(error.message); + if (error.data?.code === "CONFLICT") { + setStale(true); + void utils.agents.profiles.get.invalidate({ id: snapshot.id }); + } + }, + }); + const selectedUser = targets.data?.find((user) => user.id === ownerId); + const changed = + snapshot.ownerType !== ownerType || (ownerType === "PERSONAL" && snapshot.ownerId !== ownerId); + const canSubmit = + !!currentOwnerType && + changed && + acknowledged && + !stale && + !transfer.isPending && + (ownerType === "SERVICE" || !!selectedUser); + + function submit() { + if (!canSubmit || !currentOwnerType) return; + transfer.mutate({ + id: snapshot.id, + ownerType, + ownerId: ownerType === "PERSONAL" ? ownerId : undefined, + expectedOwnerType: currentOwnerType, + expectedOwnerId: snapshot.ownerId, + expectedUpdatedAt: snapshot.updatedAt, + clearRuntime, + }); + } + + return ( + { + if (!open && !transfer.isPending) onClose(); + }} + title={`Transfer ownership · ${snapshot.name}`} + description="Change who manages this agent identity. Review the impact before confirming." + size="md" + footer={ +
+ + +
+ } + > +
+

+ Current owner: {agentOwnerLabel(snapshot)} +

+ { + setOwnerType(value === "SERVICE" ? "SERVICE" : "PERSONAL"); + setAcknowledged(false); + }} + options={[ + { value: "PERSONAL", label: "Personal · active user" }, + { value: "SERVICE", label: "Service-owned · instance administrators" }, + ]} + className="w-full" + /> + {ownerType === "PERSONAL" && ( +
+ { + setOwnerId(value ?? ""); + setAcknowledged(false); + }} + searchable + loading={targets.isLoading} + options={(targets.data ?? []).map((user) => ({ + value: user.id, + label: user.name ?? user.email ?? "Unnamed user", + secondary: user.email ?? undefined, + }))} + placeholder="Choose an active user…" + className="w-full" + /> +

+ Only active accounts can receive personal ownership. +

+ {targets.isError && ( +

+ Owner choices could not load. Close and retry before transferring. +

+ )} +
+ )} +
+

+ {ownerType === "SERVICE" + ? "Instance administrators will manage this service identity. It will no longer belong to a human account." + : `${selectedUser?.name ?? "The selected user"} will manage this identity. Previous personal owner rights are removed unless they are also an instance administrator.`} +

+

+ {snapshot.bindingCount} workspace access grants remain unchanged. Catalog availability + stays {snapshot.instanceShared ? "instance-shared" : "restricted"}. Credential owners, + scopes, history, and creator attribution remain unchanged. +

+

Ownership does not grant access to workspace data or transfer secret credentials.

+
+ {snapshot.runtime && ( + + )} + + {stale && ( +

+ The agent changed after this dialog opened. Close it and review the refreshed ownership + before trying again. +

+ )} +
+
+ ); +} diff --git a/src/app/(app)/settings/layout.tsx b/src/app/(app)/settings/layout.tsx index e65685f6..5f1c2da8 100644 --- a/src/app/(app)/settings/layout.tsx +++ b/src/app/(app)/settings/layout.tsx @@ -9,7 +9,7 @@ import { SettingsRail } from "@/components/settings/settings-rail"; * settings (account, agents, runtimes, connections, workspaces). Workspace * settings live under `/w/[slug]/settings/*` and use the workspace shell. * These sit *above* any single workspace, so the back-link returns to - * Mission Control (`/`), not into a workspace. + * Home (`/`), not into a workspace. */ export default async function AccountSettingsLayout({ children, @@ -24,7 +24,7 @@ export default async function AccountSettingsLayout({
{children}
diff --git a/src/app/(app)/settings/page.tsx b/src/app/(app)/settings/page.tsx index 8f698be8..805bf38f 100644 --- a/src/app/(app)/settings/page.tsx +++ b/src/app/(app)/settings/page.tsx @@ -8,13 +8,13 @@ import { ACCOUNT_SETTINGS_GROUP } from "@/components/settings/settings-nav"; const RESOURCES = [ { href: "/settings/runtimes", - label: "Runtime inventory", - hint: "Hosts you own, their health, and their authoritative home workspace.", + label: "My runtimes", + hint: "Execution hosts you own and the workspace where each was registered. Agent access is managed separately in Agents.", icon: Server, }, { href: "/settings/connections", - label: "Integration accounts", + label: "Connected accounts", hint: "Operational OAuth credentials that workspace mappings can use. Separate from sign-in.", icon: PlugZap, }, @@ -53,10 +53,10 @@ function OverviewLink({ export default function PersonalSettingsOverview() { return ( <> - +
-
+
{ACCOUNT_SETTINGS_GROUP.items.map((item) => (
{RESOURCES.map((item) => ( diff --git a/src/app/(app)/settings/runtimes/page.tsx b/src/app/(app)/settings/runtimes/page.tsx index dd7cf5e3..0de934c1 100644 --- a/src/app/(app)/settings/runtimes/page.tsx +++ b/src/app/(app)/settings/runtimes/page.tsx @@ -9,6 +9,7 @@ import { RuntimeSelfTestBadge, RuntimeSelfTestLine } from "@/components/settings import { trpc } from "@/lib/trpc"; import { relativeTime } from "@/lib/utils"; import { workspaceChipColor } from "@/components/global-shell/global-shell"; +import { runtimeOwnershipLabel } from "@/lib/runtime-ownership"; /** * Global runtimes — the compute hosts the user has registered (local @@ -64,8 +65,8 @@ export default function RuntimesPage() { return ( <>
@@ -90,17 +91,17 @@ export default function RuntimesPage() {
- Claude Code, Codex CLI, and one-off MCP sessions live under Agent access. + Connect desktop apps, CLI tools, and other MCP clients from Agents. Runtime hosts are for managed compute that Forge dials or monitors.
- Open Agent access + Connect an agent
@@ -167,6 +168,14 @@ export default function RuntimesPage() {
+
+
Owner
+
{runtimeOwnershipLabel(r)}
+
+
+
Registration home
+
{r.homeWorkspace?.name ?? "Restricted workspace"}
+
Last signal
{r.health.lastSignal}
@@ -236,9 +245,10 @@ export default function RuntimesPage() { )}
- Runtimes are global to your account, not a workspace concern.{" "} + Ownership, registration home, and workspace access are separate. A runtime can serve + multiple workspaces; owning it does not grant access to their work.{" "} - Manage the agent fleet + Manage agents and workspace access {" "} to put them to work.
diff --git a/src/app/(app)/settings/workspaces/page.tsx b/src/app/(app)/settings/workspaces/page.tsx index e05c3c58..511b1f2b 100644 --- a/src/app/(app)/settings/workspaces/page.tsx +++ b/src/app/(app)/settings/workspaces/page.tsx @@ -1,441 +1,6 @@ -"use client"; -import { useMemo, useState } from "react"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { toast } from "sonner"; -import { Plus, ArrowRight, AlertTriangle, UserRound, UsersRound } from "lucide-react"; -import { Topbar } from "@/components/topbar"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Dialog } from "@/components/ui/dialog"; -import { Card, EmptyState, MOTION, Section, SkeletonList } from "@/components/ui"; -import { trpc } from "@/lib/trpc"; -import { cn } from "@/lib/utils"; -import { workspaceColor } from "@/lib/workspace-color"; +import { redirect } from "next/navigation"; -/** - * Account-level workspaces page. Lives at `/settings/workspaces` (not - * under any `/w/[slug]`) so it remains reachable when the user has no - * active workspace. Workspace-scoped settings (rename, sprint length, danger - * zone) live under `/w/[slug]/settings/workspace`; this page is about - * moving *between* workspaces plus creating new ones. - * - * `workspace.create` seeds profile-aware labels and defaults. Personal - * workspaces intentionally skip the initial sprint. - */ -export default function WorkspacesPage() { - const router = useRouter(); - const { data: workspaces, refetch, isLoading } = trpc.workspace.list.useQuery(); - // Cross-workspace mini-stats (members / openIssues / agents / activeRuns), - // keyed by id so each row can show its tiles. Loads alongside the list; - // tiles render once it resolves (rows still work without it). - const { data: stats } = trpc.global.workspaces.useQuery(); - const statsById = useMemo( - () => new Map((stats ?? []).map((s) => [s.id, s])), - [stats], - ); - const setLast = trpc.workspace.setLastWorkspace.useMutation(); - const [createOpen, setCreateOpen] = useState(false); - - function open(w: { id: string; slug: string }) { - setLast.mutate({ workspaceId: w.id }); - router.push(`/w/${w.slug}/inbox`); - } - - return ( - <> - setCreateOpen(true)}> - - Create workspace - - } - /> -
-
-
- {isLoading ? ( - - ) : ( - - {(workspaces?.length ?? 0) === 0 && ( - } - title="No workspaces yet" - description="Create one to get started." - action={ - - } - /> - )} - {workspaces?.map((w) => { - const c = workspaceColor(w.key); - const role = w.memberships[0]?.role ?? "MEMBER"; - const st = statsById.get(w.id); - return ( -
  • - -
  • - ); - })} -
    - )} -
    - - {/* Danger zone — leaving / archiving / deleting a workspace is a - workspace-scoped, admin-gated action. This account-level page - can't self-leave (no account-scoped mutation), so we group the - destructive actions here and route each workspace to its own - manage page where the real mutations live. */} - {(workspaces?.length ?? 0) > 0 && ( -
    -
    - -

    Danger zone

    - - Leaving a workspace removes you for yourself only. Reversible only by an admin re-inviting you. - -
    -
      - {workspaces?.map((w) => ( -
    • -
      -
      - {w.name} - - {w.key} - -
      -
      - Leave, archive, or delete this workspace from its own settings. -
      -
      - - Manage - -
    • - ))} -
    -
    - )} -
    -
    - - setCreateOpen(false)} - onCreated={(slug) => { - refetch(); - router.push(`/w/${slug}/inbox`); - }} - existingKeys={new Set(workspaces?.map((w) => w.key) ?? [])} - existingSlugs={new Set(workspaces?.map((w) => w.slug) ?? [])} - /> - - ); -} - -function CreateDialog({ - open, - onClose, - onCreated, - existingKeys, - existingSlugs, -}: { - open: boolean; - onClose: () => void; - onCreated: (slug: string) => void; - existingKeys: Set; - existingSlugs: Set; -}) { - const [name, setName] = useState(""); - const [key, setKey] = useState(""); - const [slug, setSlug] = useState(""); - const [cycleLengthDays, setCycleLengthDays] = useState(7); - const [timeTrackingEnabled, setTimeTrackingEnabled] = useState(false); - const [experienceProfile, setExperienceProfile] = useState<"TEAM" | "PERSONAL">("TEAM"); - - const create = trpc.workspace.create.useMutation({ - onSuccess: (ws) => { - toast.success("Workspace created."); - reset(); - onClose(); - onCreated(ws.slug); - }, - onError: (e) => toast.error(e.message), - }); - - function reset() { - setName(""); - setKey(""); - setSlug(""); - setCycleLengthDays(7); - setTimeTrackingEnabled(false); - setExperienceProfile("TEAM"); - } - - function onNameChange(v: string) { - setName(v); - if (!slug) { - setSlug( - v - .toLowerCase() - .trim() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") - .slice(0, 48), - ); - } - if (!key) { - const suggested = v - .split(/\s+/) - .filter(Boolean) - .slice(0, 3) - .map((w) => w[0]?.toUpperCase() ?? "") - .join(""); - const singleWord = v.replace(/[^a-zA-Z]/g, "").slice(0, 3).toUpperCase(); - if (suggested.length >= 2) setKey(suggested.slice(0, 5)); - else if (singleWord.length >= 2) setKey(singleWord); - } - } - - const issues = useMemo(() => { - const errs: string[] = []; - if (name.trim().length < 1) errs.push("Name is required."); - if (!/^[A-Z]{2,6}$/.test(key)) errs.push("Key must be 2–6 uppercase letters."); - else if (existingKeys.has(key)) errs.push(`Key ${key} is already in use.`); - if (!/^[a-z0-9-]{2,48}$/.test(slug)) - errs.push("Slug must be 2–48 lowercase alphanumeric chars or dashes."); - else if (existingSlugs.has(slug)) errs.push(`Slug ${slug} is already in use.`); - if (experienceProfile === "TEAM" && (cycleLengthDays < 1 || cycleLengthDays > 90)) - errs.push("Sprint length must be between 1 and 90 days."); - return errs; - }, [name, key, slug, cycleLengthDays, experienceProfile, existingKeys, existingSlugs]); - - return ( - -
    { - e.preventDefault(); - if (issues.length > 0) return; - create.mutate({ - name: name.trim(), - key, - slug, - cycleLengthDays, - timeTrackingEnabled, - experienceProfile, - }); - }} - className="space-y-3 p-5" - > -
    Create workspace
    -

    - The key shows up in issue identifiers like ACME-42 - {" "}and is permanent. Slug appears in URLs; it can be renamed later by an - owner via a data migration. -

    - - -
    - - -
    -
    - - - onNameChange(e.target.value)} - maxLength={80} - autoFocus - aria-label="Workspace name" - /> - -
    - - setKey(e.target.value.replace(/[^a-zA-Z]/g, "").toUpperCase())} - maxLength={6} - placeholder="ACME" - className="font-mono" - aria-label="Workspace key" - /> - - - - setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, "")) - } - maxLength={48} - placeholder="acme" - className="font-mono" - aria-label="Workspace slug" - /> - -
    - {experienceProfile === "TEAM" &&
    - - setCycleLengthDays(Number(e.target.value) || 7)} - /> - - - - -
    } - -
    - The key cannot be changed later. Pick something short and stable. -
    - - {issues.length > 0 && ( -
      - {issues.map((m) => ( -
    • · {m}
    • - ))} -
    - )} - -
    - - -
    -
    -
    - ); -} - -/** Compact per-workspace mini-stat tile (members / open / agents / live). */ -function Stat({ label, value, ember }: { label: string; value: number; ember?: boolean }) { - return ( -
    -
    - {value} -
    -
    - {label} -
    -
    - ); -} - -function Field({ label, children }: { label: string; children: React.ReactNode }) { - return ( -
    -
    - {label} -
    - {children} -
    - ); +/** Keep bookmarks to the former account-settings directory working. */ +export default function LegacyWorkspacesPage() { + redirect("/workspaces"); } diff --git a/src/app/(app)/w/[slug]/settings/agents/page.tsx b/src/app/(app)/w/[slug]/settings/agents/page.tsx index f6d61783..6ca84b18 100644 --- a/src/app/(app)/w/[slug]/settings/agents/page.tsx +++ b/src/app/(app)/w/[slug]/settings/agents/page.tsx @@ -98,14 +98,14 @@ export default function AgentsBindingPage() {

    - Profiles and bindings live in Mission Control. This page only changes how a bound - agent works inside {ws.name}. + Manage identities and workspace access in Agents. This page controls how an + authorized agent works inside {ws.name}.

    @@ -121,12 +121,12 @@ export default function AgentsBindingPage() { {/* Bound agents */}
    0 ? ( - {boundRows.length} bound + {boundRows.length} with access ) : undefined } @@ -135,8 +135,8 @@ export default function AgentsBindingPage() { ) : ( diff --git a/src/app/(app)/w/[slug]/settings/agents/request-profile-dialog.tsx b/src/app/(app)/w/[slug]/settings/agents/request-profile-dialog.tsx index 621e7ca1..fa17b88c 100644 --- a/src/app/(app)/w/[slug]/settings/agents/request-profile-dialog.tsx +++ b/src/app/(app)/w/[slug]/settings/agents/request-profile-dialog.tsx @@ -4,7 +4,10 @@ import { toast } from "sonner"; import { AgentProvider, RunEngine } from "@prisma/client"; import { CenterModal } from "@/components/ui/modal"; import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Combobox } from "@/components/ui/combobox"; import { trpc } from "@/lib/trpc"; +import { personalAgentCreationCopy } from "@/lib/agent-ownership-ui"; /** * Member-facing "Request a profile" form launched from Mission Control. @@ -17,11 +20,17 @@ import { trpc } from "@/lib/trpc"; export function RequestProfileDialog({ open, onOpenChange, + onCreated, + setupMode, }: { open: boolean; onOpenChange: (open: boolean) => void; + onCreated?: (profile: { id: string; approvedAt: Date | null }) => void; + setupMode?: "external" | "managed"; }) { const utils = trpc.useUtils(); + const policy = trpc.agents.profiles.policy.useQuery(undefined, { enabled: open }); + const creation = personalAgentCreationCopy(policy.data?.personalAgentCreation); const [name, setName] = useState(""); const [profileKey, setProfileKey] = useState(""); const [provider, setProvider] = useState(AgentProvider.HERMES); @@ -41,17 +50,26 @@ export function RequestProfileDialog({ } const request = trpc.agents.profiles.request.useMutation({ - onSuccess: () => { - toast.success("Profile requested — an instance admin will review it."); + onSuccess: (profile) => { + toast.success( + profile.approvedAt + ? "Personal agent created. Add workspace access to continue." + : "Profile requested — an instance admin will review it.", + ); void utils.agents.bindings.catalog.invalidate(); void utils.agents.profiles.list.invalidate(); reset(); onOpenChange(false); + onCreated?.(profile); }, onError: (e) => toast.error(e.message), }); function submit() { + if (!creation.enabled) { + toast.error("Personal agent creation is not available under the current instance policy."); + return; + } if (!name.trim() || !profileKey.trim()) { toast.error("Name and profile key are required."); return; @@ -64,7 +82,7 @@ export function RequestProfileDialog({ name: name.trim(), profileKey: profileKey.trim(), provider, - runEngine: runEngine === "DEFAULT" ? null : runEngine, + runEngine: setupMode === "external" || runEngine === "DEFAULT" ? null : runEngine, description: description.trim() || undefined, avatar: avatar.trim() || undefined, baseCapabilities, @@ -79,11 +97,35 @@ export function RequestProfileDialog({ onOpenChange(v); }} size="md" - title="Request an agent profile" - description="Profiles are defined globally and need an instance admin's approval before they can be bound to a workspace. Submit the definition essentials here — you'll see it in the catalog once it's approved." - primaryLabel="Submit request" + title={creation.label} + description={creation.description} + primaryLabel={ + policy.data?.personalAgentCreation === "SELF_SERVICE" ? "Create my agent" : "Submit request" + } onPrimary={submit} - loading={request.isPending} + loading={request.isPending || policy.isLoading} + footer={ +
    + + +
    + } >
    @@ -106,32 +148,36 @@ export function RequestProfileDialog({ /> - - - - + onChange={(value) => { + if (value) setProvider(value as AgentProvider); + }} + options={Object.values(AgentProvider).map((value) => ({ + value, + label: value.toLowerCase(), + }))} + className="h-9 w-full" + /> + {setupMode !== "external" && ( + + setRunEngine((value ?? "DEFAULT") as "DEFAULT" | RunEngine)} + options={[ + { value: "DEFAULT", label: "Default" }, + ...Object.values(RunEngine).map((value) => ({ + value, + label: value.toLowerCase(), + })), + ]} + className="h-9 w-full" + /> + + )} setAvatar(e.target.value)} placeholder="🤖" /> diff --git a/src/app/(app)/w/[slug]/settings/runtimes/[id]/page.tsx b/src/app/(app)/w/[slug]/settings/runtimes/[id]/page.tsx index 3a17c969..5bac7dd1 100644 --- a/src/app/(app)/w/[slug]/settings/runtimes/[id]/page.tsx +++ b/src/app/(app)/w/[slug]/settings/runtimes/[id]/page.tsx @@ -1,14 +1,13 @@ "use client"; import { useEffect, useState } from "react"; import Link from "next/link"; -import { notFound, useParams } from "next/navigation"; +import { notFound, useParams, useRouter } from "next/navigation"; import { toast } from "sonner"; import { Bot, ChevronLeft, Cloud, Globe, HardDrive, Server } from "lucide-react"; import type { RuntimeKind } from "@prisma/client"; import { Topbar } from "@/components/topbar"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { Avatar } from "@/components/ui/avatar"; import { Confirm, QuickForm } from "@/components/ui/modal"; import { Card } from "@/components/settings/card"; import { Section, SkeletonList } from "@/components/ui"; @@ -16,6 +15,8 @@ import { CodeBlock } from "@/components/mcp-integration-blocks"; import { RuntimeToolSurfacePanel } from "@/components/runtime-tool-surface"; import { RuntimeCredentials } from "@/components/settings/runtime-credentials"; import { RuntimeProvisioning } from "@/components/settings/runtime-provisioning"; +import { RuntimeLifecycleActions } from "@/components/settings/runtime-lifecycle"; +import { runtimeOwnershipLabel } from "@/lib/runtime-ownership"; import { RuntimeSelfTestBadge, RuntimeSelfTestLine, @@ -46,6 +47,7 @@ function agentPresenceText(input: Date | string | null | undefined): string { */ export default function RuntimeDetailPage() { const ws = useWorkspace(); + const router = useRouter(); const params = useParams<{ id: string }>(); const id = params?.id ?? ""; const utils = trpc.useUtils(); @@ -255,19 +257,8 @@ export default function RuntimeDetailPage() { connected {relativeTime(runtime.connectedAt)} ago )} - {runtime.owner && ( - <> - · - - - {runtime.owner.name ?? "unknown"} - - - )} + · + {runtimeOwnershipLabel(runtime)}
    @@ -334,6 +325,17 @@ export default function RuntimeDetailPage() {
    +
    + +
    +
    Owner
    {runtimeOwnershipLabel(runtime)}
    +
    Registration home
    {ws.name}
    +
    +

    The registration home stores this runtime's configuration. It does not define who owns the host or grant access to other workspaces.

    + {runtime.canManageLifecycle ? { if (mode !== "prepare") router.push("/admin/runtimes"); }} /> :

    Instance administrators can transfer ownership or remove an unused registration after reviewing its dependencies.

    } +
    +
    + - {rt.owner && ( - <> - · - - - {rt.owner.name ?? "unknown"} - - - )} + · + {runtimeOwnershipLabel(rt)} + · + Registered in {ws.name}
    + + + ); +} diff --git a/src/components/admin-shell/admin-agents.tsx b/src/components/admin-shell/admin-agents.tsx index ed28a7bc..d32c28c8 100644 --- a/src/components/admin-shell/admin-agents.tsx +++ b/src/components/admin-shell/admin-agents.tsx @@ -5,6 +5,8 @@ import { Check, ExternalLink, Globe, GlobeLock, Power, PowerOff, UserPlus, X } f import { toast } from "sonner"; import { trpc } from "@/lib/trpc"; import { ADMIN, AdminPanel, AdminLoading, AdminEmpty, AdminButton, relTime } from "./admin-ui"; +import { PersonalAgentPolicy } from "./personal-agent-policy"; +import { agentOwnerLabel } from "@/lib/agent-ownership-ui"; /** * Instance agent-policy surface for `/admin/agents`. Lists global agent @@ -74,8 +76,11 @@ export function AdminAgents() { > Instance policy - Approve requests, control instance sharing, and force-disable profiles. Routine identity, - binding, archive, and removal work lives in Mission Control. + Approve requests, control instance catalog availability, and disable agents. Manage agent + identities, workspace access, archival, and removal in Agents. +
    +
    +
    {/* Pending profile requests — members request, admins approve/reject. */} {(pending.isLoading || (pending.data?.length ?? 0) > 0) && ( @@ -184,7 +189,7 @@ export function AdminAgents() { Profile Provider Runtime - Bindings + Workspaces Governance
    {profiles.isLoading ? ( @@ -238,6 +243,9 @@ export function AdminAgents() { @{p.profileKey} + + {agentOwnerLabel(p)} + diff --git a/src/components/admin-shell/admin-runtimes.tsx b/src/components/admin-shell/admin-runtimes.tsx index eacb8295..5d99d986 100644 --- a/src/components/admin-shell/admin-runtimes.tsx +++ b/src/components/admin-shell/admin-runtimes.tsx @@ -1,5 +1,10 @@ "use client"; +import { useState } from "react"; +import Link from "next/link"; import { trpc } from "@/lib/trpc"; +import { runtimeOwnershipLabel } from "@/lib/runtime-ownership"; +import { runtimeDisplayIdentity } from "@/lib/transport-display"; +import { RuntimeLifecycleActions } from "@/components/settings/runtime-lifecycle"; import { RuntimeToolSurfaceBadges } from "@/components/runtime-tool-surface"; import { RuntimeSelfTestLine } from "@/components/settings/runtime-self-test"; import { ADMIN, AdminPanel, AdminLoading, AdminEmpty } from "./admin-ui"; @@ -11,21 +16,39 @@ import { ADMIN, AdminPanel, AdminLoading, AdminEmpty } from "./admin-ui"; * multi-workspace restructure. */ export function AdminRuntimes() { + const [includeArchived, setIncludeArchived] = useState(false); const runtimes = trpc.instanceAdmin.runtimes.useQuery(); - const online = runtimes.data?.filter((r) => r.online).length ?? 0; + const accessibleWorkspaces = trpc.workspace.list.useQuery(); + const visibleRuntimes = runtimes.data?.filter( + (runtime) => includeArchived || !runtime.archivedAt, + ); + const online = visibleRuntimes?.filter((runtime) => runtime.online).length ?? 0; return ( -
    +
    +
    +

    + Review ownership separately from registration home and workspace access. +

    + +
    Runtime - Kind + Connection Tool surface Owner Agents @@ -34,10 +57,20 @@ export function AdminRuntimes() {
    {runtimes.isLoading ? ( - ) : !runtimes.data?.length ? ( + ) : runtimes.isError ? ( + + Runtime registrations could not be loaded.{" "} + + + ) : !visibleRuntimes?.length ? ( No runtimes registered. ) : ( - runtimes.data.map((r, i, arr) => { + visibleRuntimes.map((r, i, arr) => { const status = r.health.label; const statusColor = r.health.tone === "success" @@ -50,58 +83,110 @@ export function AdminRuntimes() { return (
    - - - {r.name} - {r.instanceShared && ( +
    + + + {r.name} + {r.archivedAt && ( + Archived + )} + {r.instanceShared && ( + + instance-available + + )} + + {r.adapterKey && ( - shared + {r.adapterKey} )} - - {r.adapterKey && ( - - {r.adapterKey} + + {r.health.reason} - )} - - {r.health.reason} + - + {runtimeDisplayIdentity(r).runtimeLabel} + + - - - {r.kind.toLowerCase()} - - - - {r.owner?.name ?? r.owner?.email ?? "—"} - - - {r.boundAgents} - - - {r.health.lastSignal} - - - - - {status} + + {runtimeOwnershipLabel(r)} + + + Agents + {r.boundAgents} + + + {r.health.lastSignal} - + + + + {status} + + +
    +
    + {accessibleWorkspaces.data?.some( + (workspace) => workspace.id === r.workspace.id, + ) ? ( + + Registration home: {r.workspace.name} · settings + + ) : ( + + Registration home: {r.workspace.name} · host settings require workspace access + + )} + { + if (mode === "prepare") setIncludeArchived(true); + }} + /> +
    ); }) diff --git a/src/components/admin-shell/admin-shell.tsx b/src/components/admin-shell/admin-shell.tsx index f86f1cbe..8f2db464 100644 --- a/src/components/admin-shell/admin-shell.tsx +++ b/src/components/admin-shell/admin-shell.tsx @@ -29,9 +29,9 @@ import { VersionChip } from "@/components/global-shell/version-chip"; const ADMIN_NAV: { href: string; icon: LucideIcon; label: string; hint: string }[] = [ { href: "/admin", icon: LayoutDashboard, label: "Overview", hint: "Health & license" }, - { href: "/admin/tenants", icon: Layers, label: "Workspaces", hint: "All tenants" }, + { href: "/admin/tenants", icon: Layers, label: "Workspaces", hint: "Instance directory" }, { href: "/admin/move-issues", icon: MoveRight, label: "Move issues", hint: "Between workspaces" }, - { href: "/admin/users", icon: Users, label: "Users", hint: "Across instance" }, + { href: "/admin/users", icon: Users, label: "People", hint: "Accounts & instance roles" }, { href: "/admin/auth", icon: KeyRound, @@ -72,7 +72,7 @@ function AdminSidebar({ activePath, instanceUrl }: { activePath: string; instanc className="truncate text-sm font-semibold tracking-tight" style={{ color: "hsl(var(--admin-text))" }} > - Instance admin + Administration
    {instanceUrl} @@ -118,7 +118,7 @@ function AdminSidebar({ activePath, instanceUrl }: { activePath: string; instanc style={{ color: "hsl(var(--admin-text-soft))" }} > - Back to Mission Control + Back to Home
    @@ -192,7 +192,7 @@ export function AdminPage({ style={{ background: "rgba(217,119,87,0.16)", color: "hsl(var(--ember))" }} > - Instance scope · writes affect every tenant + Instance administration · review the scope of each change Instance scope
    diff --git a/src/components/admin-shell/personal-agent-policy.tsx b/src/components/admin-shell/personal-agent-policy.tsx new file mode 100644 index 00000000..71cd9684 --- /dev/null +++ b/src/components/admin-shell/personal-agent-policy.tsx @@ -0,0 +1,95 @@ +"use client"; + +import { useState } from "react"; +import { toast } from "sonner"; +import { trpc } from "@/lib/trpc"; +import { Button } from "@/components/ui/button"; +import { Combobox } from "@/components/ui/combobox"; +import { Confirm } from "@/components/ui/modal"; +import { + type PersonalAgentCreationPolicy, + personalAgentCreationCopy, +} from "@/lib/agent-ownership-ui"; +import { AdminLoading, AdminPanel } from "./admin-ui"; + +export function PersonalAgentPolicy() { + const policy = trpc.agents.profiles.policy.useQuery(); + const [draft, setDraft] = useState(null); + const [confirmOpen, setConfirmOpen] = useState(false); + const utils = trpc.useUtils(); + const save = trpc.agents.profiles.setPolicy.useMutation({ + onSuccess: async () => { + await utils.agents.profiles.policy.invalidate(); + setDraft(null); + setConfirmOpen(false); + toast.success("Personal agent creation policy updated."); + }, + onError: (error) => toast.error(error.message), + }); + const value = draft ?? policy.data?.personalAgentCreation; + return ( + + {policy.isLoading ? ( + + ) : policy.isError ? ( +

    + Could not load creation policy. Refresh before changing it. +

    + ) : ( +
    +
    + setDraft(next as PersonalAgentCreationPolicy)} + options={[ + { value: "DISABLED", label: "Disabled", secondary: "Admins create identities" }, + { + value: "APPROVAL_REQUIRED", + label: "Approval required", + secondary: "Members request · admins review", + }, + { + value: "SELF_SERVICE", + label: "Self-service", + secondary: "Users create their own agents", + }, + ]} + className="min-w-64" + /> + +
    +

    + {personalAgentCreationCopy(value).description} +

    +

    + This controls new personal identities only. Existing identities and pending requests + remain unchanged. Service identities, catalog sharing, workspace access, and credentials + retain their separate administrator controls. +

    +
    + )} + { + if (draft) await save.mutateAsync({ personalAgentCreation: draft }); + }} + /> +
    + ); +} diff --git a/src/components/global-shell/global-operations-model.ts b/src/components/global-shell/global-operations-model.ts index 74b38297..c6fa9b08 100644 --- a/src/components/global-shell/global-operations-model.ts +++ b/src/components/global-shell/global-operations-model.ts @@ -14,11 +14,13 @@ export function deriveGlobalOperationsPosture(input: { }): GlobalOperationsPosture { if (input.runtimeCount === 0) { return { - tone: "warning", - label: "Setup required", - summary: "Register a runtime before agents can execute queued work.", - actionLabel: "Register a runtime", - actionHref: "/settings/runtimes", + tone: input.activeRuns > 0 ? "success" : "neutral", + label: input.activeRuns > 0 ? "Work in motion" : "Managed execution optional", + summary: input.activeRuns > 0 + ? `${input.activeRuns} active runs. No managed runtime is registered; external MCP work does not require one.` + : "External MCP clients can work without a runtime. Configure managed execution only when Forge should dispatch work.", + actionLabel: "Set up an agent", + actionHref: "/agents", }; } diff --git a/src/components/global-shell/global-shell.tsx b/src/components/global-shell/global-shell.tsx index f66565ed..3071fc83 100644 --- a/src/components/global-shell/global-shell.tsx +++ b/src/components/global-shell/global-shell.tsx @@ -4,6 +4,7 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; import { Sparkles, + Home, Bot, Inbox, Activity, @@ -120,9 +121,10 @@ export interface GlobalBreadcrumb { } const GLOBAL_NAV: { href: string; icon: LucideIcon; label: string; hint: string }[] = [ - { href: "/", icon: Sparkles, label: "Mission Control", hint: "Across all workspaces" }, - { href: "/agents", icon: Bot, label: "Agents", hint: "Fleet & profiles" }, - { href: "/inbox", icon: Inbox, label: "Inbox", hint: "Mentions & assignments" }, + { href: "/", icon: Home, label: "Home", hint: "My work & workspaces" }, + { href: "/mission-control", icon: Sparkles, label: "Mission Control", hint: "Agent operations & delivery" }, + { href: "/agents", icon: Bot, label: "Agents", hint: "Identity, access & connections" }, + { href: "/inbox", icon: Inbox, label: "Inbox", hint: "Assigned work & context" }, { href: "/activity", icon: Activity, label: "Activity", hint: "Live run feed" }, ]; @@ -199,6 +201,7 @@ function NavRow({ return (
    - + Workspaces - + @@ -323,7 +327,7 @@ function GlobalNavContent({ )} > - Settings + My account , @@ -340,9 +344,9 @@ function GlobalNavContent({ )} > - Instance admin + Administration - root + admin )} @@ -429,7 +433,7 @@ function GlobalTopBar({ {scope === "control" ? : } - {scope === "control" ? "Global control plane" : "Read-only across workspaces"} + {scope === "control" ? "Manage resources you can access" : "Your accessible workspaces"}
    diff --git a/src/components/global-shell/mission-control-home.tsx b/src/components/global-shell/mission-control-home.tsx index 98240646..8ea5f219 100644 --- a/src/components/global-shell/mission-control-home.tsx +++ b/src/components/global-shell/mission-control-home.tsx @@ -321,7 +321,7 @@ export function MissionControlHome() { eyebrow="Queue" title="Workspaces needing attention" count={snapshot ? `${snapshot.openIssues} open` : undefined} - action={Manage} + action={Browse} className="lg:col-span-8" > {workspaces.isLoading ? ( @@ -453,7 +453,7 @@ export function MissionControlHome() { eyebrow="Coverage" title="Agent presence" count={agents.data ? `${onlineAgents} online · ${agentsList.length} total` : undefined} - action={Manage fleet} + action={Manage agents} className="lg:col-span-5" > {agents.isLoading ? ( @@ -491,7 +491,7 @@ export function MissionControlHome() { {agent.bindings.length === 0 - ? "No workspace bindings" + ? "No workspace access" : agent.bindings.map((binding) => binding.workspace.name).join(" · ")} @@ -529,13 +529,13 @@ export function MissionControlHome() {

    No runtimes registered

    - Register a host before agents can execute queued work. + Add a host for Forge-managed execution. External MCP clients do not need one.

    - Register runtime + Set up an agent
    ) : ( diff --git a/src/components/global-shell/personal-home.tsx b/src/components/global-shell/personal-home.tsx new file mode 100644 index 00000000..7fb5fe0a --- /dev/null +++ b/src/components/global-shell/personal-home.tsx @@ -0,0 +1,168 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { ArrowRight, CheckCircle2, Inbox, Layers } from "lucide-react"; +import { trpc } from "@/lib/trpc"; +import { Button } from "@/components/ui/button"; +import { Card, EmptyState, Section, SkeletonList } from "@/components/ui"; +import { WorkspaceBadge, type GlobalShellWorkspace } from "./global-shell"; + +/** Personal entry point: uses the same permission-filtered assignments as operations. */ +export function PersonalHome({ workspaces }: { workspaces: GlobalShellWorkspace[] }) { + const work = trpc.global.work.useQuery(); + const [expanded, setExpanded] = useState(false); + const assignments = work.data ?? []; + const visible = expanded ? assignments : assignments.slice(0, 8); + + return ( +
    +
    + Open inbox → + + } + > + + {work.isLoading ? ( + + ) : work.isError ? ( + void work.refetch()}> + Try again + + } + /> + ) : assignments.length === 0 ? ( + } + title="No open assignments" + description="Open a workspace to create work or pick up an issue." + /> + ) : ( +
      + {visible.map((issue) => ( +
    • + + + + {issue.title} + + + {issue.workspace.key}-{issue.number} + + {issue.workspace.name} + {issue.status.name} + + + + +
    • + ))} +
    + )} + {assignments.length > 8 && ( +
    + +
    + )} + {assignments.length === 30 && ( +

    + Showing your 30 most recent assignments. Open a workspace to see its full issue list. +

    + )} +
    +
    +
    +
    + Browse all → + + } + > + + {workspaces.length === 0 ? ( + } + title="Make room for your work" + description="Create a workspace or ask its owner for an invitation." + action={ + + Explore workspaces → + + } + /> + ) : ( +
      + {workspaces.map((workspace) => ( +
    • + + + + {workspace.name} + + + +
    • + ))} +
    + )} +
    +
    +
    + + + + + Inbox + + Inspect your assignments and their latest context. + + + + + Recent workspace activity → + + +
    +
    +
    + ); +} diff --git a/src/components/global-shell/workspaces-content.tsx b/src/components/global-shell/workspaces-content.tsx new file mode 100644 index 00000000..589822c8 --- /dev/null +++ b/src/components/global-shell/workspaces-content.tsx @@ -0,0 +1,404 @@ +"use client"; +import { useMemo, useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { Plus, ArrowRight, UserRound, UsersRound } from "lucide-react"; +import { Topbar } from "@/components/topbar"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Dialog } from "@/components/ui/dialog"; +import { Card, EmptyState, MOTION, Section, SkeletonList } from "@/components/ui"; +import { trpc } from "@/lib/trpc"; +import { cn } from "@/lib/utils"; +import { workspaceColor } from "@/lib/workspace-color"; + +/** + * Workspace directory. Lives at `/workspaces` (not + * under any `/w/[slug]`) so it remains reachable when the user has no + * active workspace. Workspace-scoped settings (rename, sprint length, danger + * zone) live under `/w/[slug]/settings/workspace`; this page is about + * moving *between* workspaces plus creating new ones. + * + * `workspace.create` seeds profile-aware labels and defaults. Personal + * workspaces intentionally skip the initial sprint. + */ +export default function WorkspacesContent() { + const router = useRouter(); + const { data: workspaces, refetch, isLoading, isError } = trpc.workspace.list.useQuery(); + // Cross-workspace mini-stats (members / openIssues / agents / activeRuns), + // keyed by id so each row can show its tiles. Loads alongside the list; + // tiles render once it resolves (rows still work without it). + const { data: stats } = trpc.global.workspaces.useQuery(); + const statsById = useMemo( + () => new Map((stats ?? []).map((s) => [s.id, s])), + [stats], + ); + const setLast = trpc.workspace.setLastWorkspace.useMutation(); + const [createOpen, setCreateOpen] = useState(false); + + function open(w: { id: string; slug: string }) { + setLast.mutate({ workspaceId: w.id }); + router.push(`/w/${w.slug}/inbox`); + } + + return ( + <> + setCreateOpen(true)}> + + Create workspace + + } + /> +
    +
    +
    + {isLoading ? ( + + ) : isError ? ( + void refetch()}>Try again} /> + ) : ( + + {(workspaces?.length ?? 0) === 0 && ( + } + title="No workspaces yet" + description="Create one to get started." + action={ + + } + /> + )} + {workspaces?.map((w) => { + const c = workspaceColor(w.key); + const role = w.memberships[0]?.role ?? "MEMBER"; + const st = statsById.get(w.id); + return ( +
  • + + {(role === "OWNER" || role === "ADMIN") && ( + + Manage + + )} +
  • + ); + })} +
    + )} +
    + +
    +
    + + setCreateOpen(false)} + onCreated={(slug) => { + refetch(); + router.push(`/w/${slug}/inbox`); + }} + existingKeys={new Set(workspaces?.map((w) => w.key) ?? [])} + existingSlugs={new Set(workspaces?.map((w) => w.slug) ?? [])} + /> + + ); +} + +function CreateDialog({ + open, + onClose, + onCreated, + existingKeys, + existingSlugs, +}: { + open: boolean; + onClose: () => void; + onCreated: (slug: string) => void; + existingKeys: Set; + existingSlugs: Set; +}) { + const [name, setName] = useState(""); + const [key, setKey] = useState(""); + const [slug, setSlug] = useState(""); + const [cycleLengthDays, setCycleLengthDays] = useState(7); + const [timeTrackingEnabled, setTimeTrackingEnabled] = useState(false); + const [experienceProfile, setExperienceProfile] = useState<"TEAM" | "PERSONAL">("TEAM"); + + const create = trpc.workspace.create.useMutation({ + onSuccess: (ws) => { + toast.success("Workspace created."); + reset(); + onClose(); + onCreated(ws.slug); + }, + onError: (e) => toast.error(e.message), + }); + + function reset() { + setName(""); + setKey(""); + setSlug(""); + setCycleLengthDays(7); + setTimeTrackingEnabled(false); + setExperienceProfile("TEAM"); + } + + function onNameChange(v: string) { + setName(v); + if (!slug) { + setSlug( + v + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 48), + ); + } + if (!key) { + const suggested = v + .split(/\s+/) + .filter(Boolean) + .slice(0, 3) + .map((w) => w[0]?.toUpperCase() ?? "") + .join(""); + const singleWord = v.replace(/[^a-zA-Z]/g, "").slice(0, 3).toUpperCase(); + if (suggested.length >= 2) setKey(suggested.slice(0, 5)); + else if (singleWord.length >= 2) setKey(singleWord); + } + } + + const issues = useMemo(() => { + const errs: string[] = []; + if (name.trim().length < 1) errs.push("Name is required."); + if (!/^[A-Z]{2,6}$/.test(key)) errs.push("Key must be 2–6 uppercase letters."); + else if (existingKeys.has(key)) errs.push(`Key ${key} is already in use.`); + if (!/^[a-z0-9-]{2,48}$/.test(slug)) + errs.push("Slug must be 2–48 lowercase alphanumeric chars or dashes."); + else if (existingSlugs.has(slug)) errs.push(`Slug ${slug} is already in use.`); + if (experienceProfile === "TEAM" && (cycleLengthDays < 1 || cycleLengthDays > 90)) + errs.push("Sprint length must be between 1 and 90 days."); + return errs; + }, [name, key, slug, cycleLengthDays, experienceProfile, existingKeys, existingSlugs]); + + return ( + +
    { + e.preventDefault(); + if (issues.length > 0) return; + create.mutate({ + name: name.trim(), + key, + slug, + cycleLengthDays, + timeTrackingEnabled, + experienceProfile, + }); + }} + className="space-y-3 p-5" + > +
    Create workspace
    +

    + The key shows up in issue identifiers like ACME-42 + {" "}and is permanent. Slug appears in URLs; it can be renamed later by an + owner via a data migration. +

    + + +
    + + +
    +
    + + + onNameChange(e.target.value)} + maxLength={80} + autoFocus + aria-label="Workspace name" + /> + +
    + + setKey(e.target.value.replace(/[^a-zA-Z]/g, "").toUpperCase())} + maxLength={6} + placeholder="ACME" + className="font-mono" + aria-label="Workspace key" + /> + + + + setSlug(e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, "")) + } + maxLength={48} + placeholder="acme" + className="font-mono" + aria-label="Workspace slug" + /> + +
    + {experienceProfile === "TEAM" &&
    + + setCycleLengthDays(Number(e.target.value) || 7)} + /> + + + + +
    } + +
    + The key cannot be changed later. Pick something short and stable. +
    + + {issues.length > 0 && ( +
      + {issues.map((m) => ( +
    • · {m}
    • + ))} +
    + )} + +
    + + +
    +
    +
    + ); +} + +/** Compact per-workspace mini-stat tile (members / open / agents / live). */ +function Stat({ label, value, ember }: { label: string; value: number; ember?: boolean }) { + return ( +
    +
    + {value} +
    +
    + {label} +
    +
    + ); +} + +function Field({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
    +
    + {label} +
    + {children} +
    + ); +} diff --git a/src/components/settings/runtime-lifecycle.tsx b/src/components/settings/runtime-lifecycle.tsx new file mode 100644 index 00000000..d85c3e42 --- /dev/null +++ b/src/components/settings/runtime-lifecycle.tsx @@ -0,0 +1,395 @@ +"use client"; + +import { useState } from "react"; +import { toast } from "sonner"; +import { trpc } from "@/lib/trpc"; +import { canConfirmRuntimeLifecycle, runtimeOwnershipLabel } from "@/lib/runtime-ownership"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Combobox } from "@/components/ui/combobox"; +import { CenterModal } from "@/components/ui/modal"; +import { Spinner } from "@/components/ui"; + +type RuntimeLifecycleMode = "transfer" | "remove" | "prepare"; + +/** Instance-only controls; backend independently enforces authority and snapshot freshness. */ +export function RuntimeLifecycleActions({ + id, + canManage, + onChanged, +}: { + id: string; + canManage: boolean; + onChanged?: (mode: RuntimeLifecycleMode) => void; +}) { + const [mode, setMode] = useState(null); + if (!canManage) return null; + return ( + <> +
    + + + +
    + {mode && ( + setMode(null)} + onChanged={onChanged} + /> + )} + + ); +} + +function RuntimeLifecycleDialog({ + id, + mode, + onClose, + onChanged, +}: { + id: string; + mode: RuntimeLifecycleMode; + onClose: () => void; + onChanged?: (mode: RuntimeLifecycleMode) => void; +}) { + const utils = trpc.useUtils(); + const [targetWorkspaceId, setTargetWorkspaceId] = useState(""); + const [ownerType, setOwnerType] = useState<"PERSONAL" | "SERVICE" | null>(null); + const [ownerId, setOwnerId] = useState(undefined); + const [confirmation, setConfirmation] = useState(""); + const [error, setError] = useState(null); + const impact = trpc.runtime.lifecycleImpact.useQuery( + { id, targetWorkspaceId: targetWorkspaceId || undefined }, + { staleTime: 0, refetchOnMount: "always" }, + ); + const workspaces = trpc.instanceAdmin.tenants.useQuery(undefined, { + enabled: mode === "transfer", + }); + const users = trpc.instanceAdmin.users.useQuery(undefined, { enabled: mode === "transfer" }); + const transfer = trpc.runtime.lifecycleTransfer.useMutation(); + const remove = trpc.runtime.lifecycleRemove.useMutation(); + const prepare = trpc.runtime.lifecyclePrepare.useMutation(); + const pending = transfer.isPending || remove.isPending || prepare.isPending; + const snapshot = impact.data?.runtime; + const effectiveOwnerType = + ownerType ?? + (snapshot?.ownerType === "PERSONAL" || snapshot?.ownerType === "SERVICE" + ? snapshot.ownerType + : null); + const effectiveOwnerId = ownerId === undefined ? (snapshot?.ownerId ?? null) : ownerId; + const effectiveWorkspaceId = targetWorkspaceId || snapshot?.workspaceId || ""; + const hasTransferChange = + !!snapshot && + (effectiveWorkspaceId !== snapshot.workspaceId || + effectiveOwnerType !== snapshot.ownerType || + (effectiveOwnerType === "PERSONAL" ? effectiveOwnerId : null) !== snapshot.ownerId); + const blockers = + (mode === "prepare" + ? [] + : mode === "remove" + ? impact.data?.removeBlockers + : impact.data?.transferBlockers) ?? []; + const destinationReady = + mode !== "transfer" || + (!!effectiveOwnerType && + hasTransferChange && + !workspaces.isLoading && + !workspaces.isError && + !!workspaces.data?.some((workspace) => workspace.id === effectiveWorkspaceId) && + (effectiveOwnerType !== "PERSONAL" || + (!users.isLoading && + !users.isError && + !!users.data?.some( + (user) => + user.id === effectiveOwnerId && + user.status === "ACTIVE" && + !user.disabledAt && + !user.deletedAt, + )))); + const allowed = + !!snapshot && + !(mode === "prepare" && snapshot.archivedAt && snapshot.disabledAt) && + destinationReady && + canConfirmRuntimeLifecycle({ + name: snapshot.name, + confirmation, + blockers, + fetching: impact.isFetching || impact.isError, + pending, + ownerRequired: mode === "transfer" && effectiveOwnerType === "PERSONAL", + ownerId: effectiveOwnerId, + }); + + async function submit() { + if (!snapshot || !allowed) return; + setError(null); + const expected = { + id, + expectedWorkspaceId: snapshot.workspaceId, + expectedOwnerType: snapshot.ownerType, + expectedOwnerId: snapshot.ownerId, + expectedUpdatedAt: snapshot.updatedAt, + confirmName: confirmation, + }; + try { + if (mode === "prepare") { + await prepare.mutateAsync(expected); + toast.success( + "Registration archived and disabled in Forge. The external host was not stopped.", + ); + } else if (mode === "remove") { + await remove.mutateAsync(expected); + toast.success("Runtime registration removed. The external host was not stopped."); + } else { + if (!effectiveOwnerType) return; + await transfer.mutateAsync({ + ...expected, + targetWorkspaceId: effectiveWorkspaceId, + ownerType: effectiveOwnerType, + ownerId: effectiveOwnerType === "PERSONAL" ? effectiveOwnerId : null, + }); + toast.success("Runtime ownership and registration home updated."); + } + await Promise.all([ + utils.runtime.list.invalidate(), + ...(mode === "remove" ? [] : [utils.runtime.byId.invalidate({ id })]), + utils.global.runtimes.invalidate(), + utils.global.runtimeOptions.invalidate(), + utils.instanceAdmin.runtimes.invalidate(), + ]); + onClose(); + onChanged?.(mode); + } catch (failure) { + setError(failure instanceof Error ? failure.message : "The change could not be applied."); + setConfirmation(""); + void impact.refetch(); + } + } + + return ( + { + if (!open && !pending) onClose(); + }} + size="md" + title={ + mode === "prepare" + ? "Archive & disable runtime" + : mode === "remove" + ? "Remove runtime registration" + : "Transfer runtime ownership / home" + } + description="Instance-administrator action. Review the current impact before confirming." + footer={ +
    + + +
    + } + > +
    +

    + {mode === "prepare" + ? "This archives and disables the registration in Forge, preventing future managed use. It does not stop the external host or finish existing runs and delivery sessions. Resolve that work separately before transfer or removal." + : mode === "remove" + ? "This permanently deletes Forge's registration only. It does not stop or uninstall the external host, erase its files, or revoke credentials outside Forge." + : "Ownership is separate from registration home and workspace access. Transfer does not grant workspace membership, move credentials silently, or restart the external host."} +

    + {impact.isLoading ? ( + + ) : impact.isError ? ( +
    +

    Could not load the impact preview: {impact.error.message}

    + +
    + ) : ( + snapshot && ( + <> +
    +
    Runtime
    +
    {snapshot.name}
    +
    Current ownership
    +
    {runtimeOwnershipLabel(snapshot)}
    +
    Registration home
    +
    {snapshot.workspace.name}
    +
    Status
    +
    + {snapshot.archivedAt ? "Archived" : "Not archived"} ·{" "} + {snapshot.disabledAt ? "Disabled" : "Enabled"} +
    +
    + {mode === "prepare" && snapshot.archivedAt && snapshot.disabledAt && ( +

    + This registration is already archived and disabled. Review transfer or removal to + see its remaining blockers. +

    + )} + {mode === "transfer" && ( +
    + Destination +
    +
    Registration home
    + ({ + value: workspace.id, + label: workspace.name, + secondary: workspace.key, + }))} + searchable + loading={workspaces.isLoading} + onChange={(value) => { + setTargetWorkspaceId(value ?? ""); + setConfirmation(""); + }} + /> +
    +
    +
    Ownership
    + { + if (value === "PERSONAL" || value === "SERVICE") setOwnerType(value); + setConfirmation(""); + }} + /> +
    + {effectiveOwnerType === "PERSONAL" && ( +
    +
    Personal owner
    + + user.status === "ACTIVE" && !user.disabledAt && !user.deletedAt, + ) + .map((user) => ({ + value: user.id, + label: user.name ?? user.email, + secondary: user.email, + }))} + onChange={(value) => { + setOwnerId(value); + setConfirmation(""); + }} + /> +

    + The personal owner must already be a member of the destination workspace. + This action does not add membership. +

    +
    + )} + {(users.isError || workspaces.isError) && ( +

    + Could not load transfer destinations. Close and retry before transferring. +

    + )} + {!hasTransferChange && ( +

    + Choose a different owner or registration home to transfer this registration. +

    + )} +
    + )} +
    + + Registration impact · linked records + +
    + {Object.entries(impact.data?.counts ?? {}).map(([label, count]) => ( +
    +
    {label.replace(/([A-Z])/g, " $1")}
    +
    {count}
    +
    + ))} +
    +
    + {mode === "remove" && impact.data && ( +

    + Permanent removal also deletes this registration's Forge configuration, + including {impact.data.counts.secrets} stored secret(s) and{" "} + {impact.data.counts.repos} repository setting(s). External credentials are not + revoked. +

    + )} + {blockers.length > 0 && ( +
    +

    + Resolve before {mode === "remove" ? "removal" : "transfer"} +

    +
      + {blockers.map((blocker) => ( +
    • {blocker}
    • + ))} +
    +
    + )} +
    + + setConfirmation(event.target.value)} + autoComplete="off" + disabled={pending || blockers.length > 0} + className="mt-1" + /> +
    +

    + Forge rechecks this snapshot when you confirm. A changed registration or new + dependency requires another review. +

    + + ) + )} + {error && ( +

    + {error} +

    + )} +
    +
    + ); +} diff --git a/src/components/settings/settings-nav.ts b/src/components/settings/settings-nav.ts index 554385d2..0045bf76 100644 --- a/src/components/settings/settings-nav.ts +++ b/src/components/settings/settings-nav.ts @@ -132,7 +132,7 @@ export const WORKSPACE_SETTINGS_GROUPS: readonly SettingsNavGroup[] = [ label: "Agent policy", icon: Bot, description: - "Set capacity, engagement, routing eligibility, and approval policy for agents bound from Mission Control.", + "Set this workspace's capacity, engagement, routing, and approval rules. Manage agent identities and connections in Agents.", }, { path: "/dispatch-rules", @@ -143,7 +143,7 @@ export const WORKSPACE_SETTINGS_GROUPS: readonly SettingsNavGroup[] = [ }, { path: "/access", - label: "Agent access", + label: "MCP & API access", icon: Key, badge: "admin only", description: @@ -154,7 +154,7 @@ export const WORKSPACE_SETTINGS_GROUPS: readonly SettingsNavGroup[] = [ { id: "connections", label: "Connections", - hint: "Inbound + outbound I/O. For agents and their runtimes, see Automation → Agents.", + hint: "Workspace integrations. Manage agent identities and execution in Agents.", items: [ { path: "/connections", @@ -218,7 +218,7 @@ export const WORKSPACE_SETTINGS_GROUPS: readonly SettingsNavGroup[] = [ */ export const ACCOUNT_SETTINGS_GROUP: SettingsNavGroup = { id: "personal", - label: "Personal", + label: "My account", hint: "Follows you across every workspace.", items: [ { @@ -239,11 +239,5 @@ export const ACCOUNT_SETTINGS_GROUP: SettingsNavGroup = { icon: Palette, description: "Density, text size, and motion. Saved per user.", }, - { - path: "/settings/workspaces", - label: "Workspaces", - icon: Layers, - description: "Create, rename, archive, or switch workspaces you belong to.", - }, ], }; diff --git a/src/components/settings/settings-rail.tsx b/src/components/settings/settings-rail.tsx index 908d8045..2f5b2355 100644 --- a/src/components/settings/settings-rail.tsx +++ b/src/components/settings/settings-rail.tsx @@ -78,7 +78,7 @@ export function SettingsRail(props: SettingsRailProps) { Scope
    - {props.scope === "workspace" ? `Workspace · ${props.name}` : "Account & identity"} + {props.scope === "workspace" ? `Workspace · ${props.name}` : "My account"}
    @@ -192,7 +192,7 @@ export function WorkspaceSwitcher({ collapsed = false }: { collapsed?: boolean } type="button" onClick={() => { setOpen(false); - router.push(`/w/${ws.slug}/settings/workspaces`); + router.push("/workspaces"); }} className="flex w-full items-center gap-2.5 px-3 py-2 text-left text-xs text-muted-foreground hover:bg-subtle hover:text-foreground" > diff --git a/src/lib/agent-ownership-ui.ts b/src/lib/agent-ownership-ui.ts new file mode 100644 index 00000000..3f551ed2 --- /dev/null +++ b/src/lib/agent-ownership-ui.ts @@ -0,0 +1,35 @@ +export type PersonalAgentCreationPolicy = "DISABLED" | "APPROVAL_REQUIRED" | "SELF_SERVICE"; + +export function agentOwnerLabel(profile: { + ownerType: string; + owner?: { name: string | null } | null; + ownedByMe?: boolean; +}) { + if (profile.ownerType === "SERVICE") return "Service-owned · Instance administrators"; + return `${profile.owner?.name ?? "Unavailable owner"}${profile.ownedByMe ? " (you)" : ""}`; +} + +export function personalAgentCreationCopy(policy?: PersonalAgentCreationPolicy) { + if (policy === "SELF_SERVICE") + return { + label: "Create my agent", + description: + "Create a personal identity owned by your account. Workspace access and credential permissions are still granted separately.", + enabled: true, + }; + if (policy === "APPROVAL_REQUIRED") + return { + label: "Request my agent", + description: + "You can request a personal identity. An instance administrator must approve it before workspace access can be added.", + enabled: true, + }; + return { + label: policy === "DISABLED" ? "Personal creation disabled" : "Loading creation policy…", + description: + policy === "DISABLED" + ? "Instance policy disables new personal agent identities. You can continue using existing permitted agents or a personal MCP token." + : "Checking the instance’s personal agent creation policy.", + enabled: false, + }; +} diff --git a/src/lib/agent-setup.ts b/src/lib/agent-setup.ts new file mode 100644 index 00000000..20134bfd --- /dev/null +++ b/src/lib/agent-setup.ts @@ -0,0 +1,45 @@ +export type AgentSetupMode = "external" | "managed"; +export type AgentListView = "mine" | "available" | "all"; + +type SetupWorkspace = { id: string; slug: string; role?: string }; +type SetupProfile = { + id: string; + ownedByMe: boolean; + instanceShared: boolean; + ownerType?: string; + availability: { state: string }; + bindings: Array<{ id: string; workspace: { id: string } }>; +}; + +export function matchesAgentView(profile: SetupProfile, view: AgentListView, isAdmin: boolean) { + if (view === "all") return isAdmin; + if (view === "mine") return profile.ownedByMe; + return ( + (profile.ownedByMe || profile.instanceShared || (isAdmin && profile.ownerType === "SERVICE")) && + profile.availability.state === "AVAILABLE" + ); +} + +/** Navigation only. Destination mutations still enforce membership and permissions. */ +export function agentSetupDestination({ + mode, + workspace, + profile, +}: { + mode: AgentSetupMode; + workspace?: SetupWorkspace; + profile?: SetupProfile; +}): string | null { + if (!workspace || !["OWNER", "ADMIN", "MEMBER"].includes(workspace.role ?? "")) return null; + const canManage = workspace.role === "OWNER" || workspace.role === "ADMIN"; + if (mode === "managed" && !canManage) return null; + if (profile && profile.availability.state !== "AVAILABLE") return null; + const slug = encodeURIComponent(workspace.slug); + if (mode === "external" && !profile) return `/w/${slug}/settings/access?create=personal`; + if (!profile) return null; + const binding = profile.bindings.find((item) => item.workspace.id === workspace.id); + if (mode === "external" && binding && canManage) { + return `/w/${slug}/settings/access?create=agent&agentId=${encodeURIComponent(binding.id)}`; + } + return `/agents/${encodeURIComponent(profile.id)}?workspace=${encodeURIComponent(workspace.id)}&setup=${mode}`; +} diff --git a/src/lib/runtime-ownership.ts b/src/lib/runtime-ownership.ts new file mode 100644 index 00000000..dd8018a9 --- /dev/null +++ b/src/lib/runtime-ownership.ts @@ -0,0 +1,28 @@ +/** Display ownership independently from registration workspace and availability. */ +export function runtimeOwnershipLabel(runtime: { + ownerType: "PERSONAL" | "SERVICE" | "UNASSIGNED"; + owner?: { name?: string | null; email?: string | null } | null; +}): string { + if (runtime.ownerType === "SERVICE") return "Service-owned · instance managed"; + if (runtime.ownerType === "UNASSIGNED") return "Unassigned · needs an ownership decision"; + return `Personal · ${runtime.owner?.name ?? runtime.owner?.email ?? "Owner unavailable"}`; +} + +export function canConfirmRuntimeLifecycle(input: { + name: string; + confirmation: string; + blockers: readonly string[]; + fetching: boolean; + pending: boolean; + ownerRequired?: boolean; + ownerId?: string | null; +}): boolean { + return ( + !!input.name && + input.confirmation === input.name && + input.blockers.length === 0 && + !input.fetching && + !input.pending && + (!input.ownerRequired || !!input.ownerId) + ); +} diff --git a/src/server/routers/__tests__/agent-binding-mission-control.test.ts b/src/server/routers/__tests__/agent-binding-mission-control.test.ts index 24048bca..994aac58 100644 --- a/src/server/routers/__tests__/agent-binding-mission-control.test.ts +++ b/src/server/routers/__tests__/agent-binding-mission-control.test.ts @@ -12,6 +12,11 @@ import { const fixtures: TestFixture[] = []; afterEach(async () => { + await getPrisma().agentProfile.deleteMany({ + where: { + ownerId: { in: fixtures.flatMap((fixture) => [fixture.user.id, fixture.secondUser.id]) }, + }, + }); while (fixtures.length) await fixtures.pop()!.cleanup(); }); @@ -31,6 +36,82 @@ async function makeProfile(fixture: TestFixture, profileKey: string) { } describe("Mission Control workspace binding actions", () => { + it.each(["disabled", "pending", "archived"] as const)( + "rejects %s profiles through both entrypoints and hides them from the catalog", + async (state) => { + const fixture = await createWorkspaceFixture({ keyPrefix: "BG" }); + fixtures.push(fixture); + const profile = await makeProfile(fixture, `unavailable-${state}`); + await getPrisma().agentProfile.update({ + where: { id: profile.id }, + data: + state === "disabled" + ? { disabledAt: new Date() } + : state === "archived" + ? { archivedAt: new Date() } + : { requestedById: fixture.user.id, approvedAt: null }, + }); + const caller = agentBindingRouter.createCaller(await buildContext(fixture)); + const code = state === "archived" ? "NOT_FOUND" : "FORBIDDEN"; + await expect(caller.bind({ profileId: profile.id })).rejects.toMatchObject({ code }); + await expect( + caller.bindFromMissionControl({ workspaceId: fixture.workspace.id, profileId: profile.id }), + ).rejects.toMatchObject({ code }); + expect((await caller.catalog()).some((row) => row.id === profile.id)).toBe(false); + expect(await getPrisma().agent.count({ where: { profileId: profile.id } })).toBe(0); + }, + ); + + it("keeps private profile access consistent without granting workspace administration", async () => { + const fixture = await createWorkspaceFixture({ keyPrefix: "BA" }); + fixtures.push(fixture); + const profile = await getPrisma().agentProfile.create({ + data: { + ownerId: fixture.secondUser.id, + profileKey: "private-definition", + name: "Private definition", + approvedAt: new Date(), + }, + }); + const caller = agentBindingRouter.createCaller(await buildContext(fixture)); + expect((await caller.catalog()).some((row) => row.id === profile.id)).toBe(false); + await expect(caller.bind({ profileId: profile.id })).rejects.toMatchObject({ + code: "FORBIDDEN", + }); + await expect( + caller.bindFromMissionControl({ workspaceId: fixture.workspace.id, profileId: profile.id }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + await getPrisma().user.update({ + where: { id: fixture.user.id }, + data: { instanceRole: "INSTANCE_ADMIN" }, + }); + expect((await caller.catalog()).some((row) => row.id === profile.id)).toBe(true); + const binding = await caller.bind({ profileId: profile.id }); + await caller.unbind({ agentId: binding.id }); + expect( + ( + await caller.bindFromMissionControl({ + workspaceId: fixture.workspace.id, + profileId: profile.id, + }) + ).id, + ).toBe(binding.id); + + await getPrisma().user.update({ + where: { id: fixture.secondUser.id }, + data: { instanceRole: "INSTANCE_ADMIN" }, + }); + const member = agentBindingRouter.createCaller( + await buildContext(fixture, { asUserId: fixture.secondUser.id }), + ); + await expect(member.bind({ profileId: profile.id })).rejects.toMatchObject({ + code: "FORBIDDEN", + }); + await expect( + member.bindFromMissionControl({ workspaceId: fixture.workspace.id, profileId: profile.id }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); + it("lets a workspace owner bind and unbind with durable audit events", async () => { const fixture = await createWorkspaceFixture({ keyPrefix: "MCB" }); fixtures.push(fixture); diff --git a/src/server/routers/__tests__/agent-profile-ownership.test.ts b/src/server/routers/__tests__/agent-profile-ownership.test.ts new file mode 100644 index 00000000..3d4b2ac4 --- /dev/null +++ b/src/server/routers/__tests__/agent-profile-ownership.test.ts @@ -0,0 +1,414 @@ +import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { AgentProfile, InstanceAgentPolicy } from "@prisma/client"; +import { agentProfileRouter } from "@/server/routers/agent-profile"; +import { agentBindingRouter } from "@/server/routers/agent-binding"; +import { globalRouter } from "@/server/routers/global"; +import { + buildContext, + createWorkspaceFixture, + disconnectPrisma, + getPrisma, + type TestFixture, +} from "./helpers"; + +const fixtures: TestFixture[] = []; +let originalPolicy: InstanceAgentPolicy | null; + +beforeEach(async () => { + originalPolicy = await getPrisma().instanceAgentPolicy.findUnique({ where: { id: "default" } }); +}); +afterEach(async () => { + const db = getPrisma(); + const userIds = fixtures.flatMap((fixture) => [fixture.user.id, fixture.secondUser.id]); + await db.agentProfile.deleteMany({ + where: { OR: [{ ownerId: { in: userIds } }, { createdById: { in: userIds } }] }, + }); + await db.instanceAuditLog.deleteMany({ where: { actorId: { in: userIds } } }); + while (fixtures.length) await fixtures.pop()!.cleanup(); + if (originalPolicy) { + await db.instanceAgentPolicy.upsert({ + where: { id: "default" }, + create: originalPolicy, + update: { personalAgentCreation: originalPolicy.personalAgentCreation }, + }); + } else { + await db.instanceAgentPolicy.deleteMany({ where: { id: "default" } }); + } +}); +afterAll(disconnectPrisma); + +async function setup() { + const fixture = await createWorkspaceFixture({ keyPrefix: "PO" }); + fixtures.push(fixture); + await getPrisma().user.update({ + where: { id: fixture.user.id }, + data: { instanceRole: "INSTANCE_ADMIN" }, + }); + return { + fixture, + admin: agentProfileRouter.createCaller(await buildContext(fixture)), + member: agentProfileRouter.createCaller( + await buildContext(fixture, { asUserId: fixture.secondUser.id }), + ), + }; +} + +function snapshot(profile: Pick) { + return { + expectedOwnerType: profile.ownerType as "PERSONAL" | "SERVICE", + expectedOwnerId: profile.ownerId, + expectedUpdatedAt: profile.updatedAt, + }; +} + +describe("Explicit agent ownership and personal creation policy", () => { + it.each(["bind", "bindFromMissionControl"] as const)( + "%s waits for an ownership change and rechecks availability", + async (entrypoint) => { + const { fixture, admin } = await setup(); + const profile = await admin.create({ + profileKey: `lock-${entrypoint.toLowerCase()}`, + name: "Ownership lock", + }); + await getPrisma().user.update({ + where: { id: fixture.user.id }, + data: { instanceRole: "MEMBER" }, + }); + const caller = agentBindingRouter.createCaller(await buildContext(fixture)); + let unlock!: () => void; + let signalLocked!: () => void; + const release = new Promise((resolve) => { + unlock = resolve; + }); + const locked = new Promise((resolve) => { + signalLocked = resolve; + }); + const transfer = getPrisma().$transaction( + async (tx) => { + await tx.$queryRaw`SELECT "id" FROM "AgentProfile" WHERE "id" = ${profile.id} FOR UPDATE`; + await tx.agentProfile.update({ + where: { id: profile.id }, + data: { ownerId: fixture.secondUser.id }, + }); + signalLocked(); + await release; + }, + { timeout: 10_000 }, + ); + await locked; + const attempt = ( + entrypoint === "bind" + ? caller.bind({ profileId: profile.id }) + : caller.bindFromMissionControl({ + profileId: profile.id, + workspaceId: fixture.workspace.id, + }) + ).then( + () => ({ code: "UNEXPECTED_SUCCESS" }), + (error: unknown) => error as { code: string }, + ); + let observedLock = false; + try { + const deadline = Date.now() + 3_000; + while (Date.now() < deadline) { + const waiting = await getPrisma().$queryRaw< + { count: bigint }[] + >`SELECT count(*) AS count FROM pg_stat_activity WHERE wait_event_type = 'Lock' AND query LIKE '%AgentProfile%FOR SHARE%'`; + if (Number(waiting[0]?.count ?? 0) > 0) { + observedLock = true; + break; + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } + } finally { + unlock(); + await transfer; + } + expect(observedLock).toBe(true); + expect(await attempt).toMatchObject({ code: "FORBIDDEN" }); + expect(await getPrisma().agent.count({ where: { profileId: profile.id } })).toBe(0); + }, + ); + + it("defaults to approval and never confers workspace access with a request", async () => { + const { fixture, member } = await setup(); + await getPrisma().instanceAgentPolicy.deleteMany({ where: { id: "default" } }); + expect(await member.policy()).toEqual({ personalAgentCreation: "APPROVAL_REQUIRED" }); + const profile = await member.request({ + profileKey: "pending-personal", + name: "Pending personal", + }); + expect(profile).toMatchObject({ + ownerType: "PERSONAL", + ownerId: fixture.secondUser.id, + createdById: fixture.secondUser.id, + requestedById: fixture.secondUser.id, + approvedAt: null, + runtimeId: null, + instanceShared: false, + }); + expect(await getPrisma().agent.count({ where: { profileId: profile.id } })).toBe(0); + const bindings = agentBindingRouter.createCaller(await buildContext(fixture)); + await expect(bindings.bind({ profileId: profile.id })).rejects.toMatchObject({ + code: "FORBIDDEN", + }); + }); + + it("honors disabled/self-service policy without accepting sharing or runtime authority", async () => { + const { fixture, admin, member } = await setup(); + await expect(member.setPolicy({ personalAgentCreation: "SELF_SERVICE" })).rejects.toMatchObject( + { code: "FORBIDDEN" }, + ); + await admin.setPolicy({ personalAgentCreation: "DISABLED" }); + await expect( + member.request({ profileKey: "disabled-personal", name: "Disabled" }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + await admin.setPolicy({ personalAgentCreation: "SELF_SERVICE" }); + const elevated = { profileKey: "elevated", name: "Elevated", instanceShared: true }; + await expect(member.request(elevated)).rejects.toMatchObject({ code: "BAD_REQUEST" }); + const profile = await member.request({ profileKey: "self-service", name: "Self service" }); + expect(profile.approvedAt).not.toBeNull(); + expect(profile).toMatchObject({ + ownerType: "PERSONAL", + ownerId: fixture.secondUser.id, + createdById: fixture.secondUser.id, + instanceShared: false, + runtimeId: null, + }); + const memberBindings = agentBindingRouter.createCaller( + await buildContext(fixture, { asUserId: fixture.secondUser.id }), + ); + await expect(memberBindings.bind({ profileId: profile.id })).rejects.toMatchObject({ + code: "FORBIDDEN", + }); + expect( + await getPrisma().instanceAuditLog.count({ + where: { actorId: fixture.user.id, action: "agent.policy.updated" }, + }), + ).toBe(2); + }); + + it("keeps service ownership independent of creator and catalog sharing", async () => { + const { fixture, admin, member } = await setup(); + const profile = await admin.create({ + profileKey: "service-owner", + name: "Service owner", + ownerType: "SERVICE", + }); + expect(profile).toMatchObject({ + ownerType: "SERVICE", + ownerId: null, + createdById: fixture.user.id, + instanceShared: false, + }); + expect((await admin.get({ id: profile.id })).availability.scope).toBe("ADMIN_ONLY"); + await expect( + member.create({ profileKey: "not-service", name: "Not service", ownerType: "SERVICE" }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + await expect(member.get({ id: profile.id })).rejects.toMatchObject({ code: "FORBIDDEN" }); + await admin.setInstanceShared({ id: profile.id, instanceShared: true }); + expect((await member.get({ id: profile.id })).canEdit).toBe(false); + await expect(member.update({ id: profile.id, name: "Cannot edit" })).rejects.toMatchObject({ + code: "FORBIDDEN", + }); + await expect( + admin.create({ profileKey: profile.profileKey, name: "Duplicate", ownerType: "SERVICE" }), + ).rejects.toMatchObject({ code: "CONFLICT" }); + await expect( + admin.create({ + profileKey: profile.profileKey, + name: "Different personal owner", + ownerType: "PERSONAL", + ownerId: fixture.secondUser.id, + }), + ).resolves.toMatchObject({ ownerId: fixture.secondUser.id }); + await expect( + admin.create({ + profileKey: "invalid-service", + name: "Invalid", + ownerType: "SERVICE", + ownerId: fixture.user.id, + }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + }); + + it("requires an explicit runtime clear before transferring private runtime authority", async () => { + const { fixture, admin } = await setup(); + const runtime = await getPrisma().runtime.create({ + data: { + workspaceId: fixture.workspace.id, + ownerType: "PERSONAL", + ownerId: fixture.user.id, + kind: "REMOTE_HTTP", + name: "Private runtime", + }, + }); + const profile = await admin.create({ + profileKey: "runtime-transfer", + name: "Runtime transfer", + runtimeId: runtime.id, + }); + const bindings = agentBindingRouter.createCaller(await buildContext(fixture)); + const binding = await bindings.bind({ profileId: profile.id }); + await expect( + admin.transferOwnership({ id: profile.id, ownerType: "SERVICE", ...snapshot(profile) }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + const transferred = await admin.transferOwnership({ + id: profile.id, + ownerType: "SERVICE", + clearRuntime: true, + ...snapshot(profile), + }); + expect(transferred).toMatchObject({ + ownerType: "SERVICE", + ownerId: null, + runtimeId: null, + createdById: fixture.user.id, + }); + expect( + (await getPrisma().agent.findUniqueOrThrow({ where: { id: binding.id } })).runtimeId, + ).toBeNull(); + await expect( + admin.transferOwnership({ + id: profile.id, + ownerType: "PERSONAL", + ownerId: fixture.secondUser.id, + ...snapshot(profile), + }), + ).rejects.toMatchObject({ code: "CONFLICT" }); + const personal = await admin.transferOwnership({ + id: profile.id, + ownerType: "PERSONAL", + ownerId: fixture.secondUser.id, + ...snapshot(transferred), + }); + expect(personal).toMatchObject({ + ownerId: fixture.secondUser.id, + createdById: fixture.user.id, + instanceShared: false, + }); + const audit = await getPrisma().instanceAuditLog.findMany({ + where: { actorId: fixture.user.id, action: "agent.profile.ownership_transferred" }, + }); + expect(audit).toHaveLength(2); + expect(audit[0]?.metadata).not.toHaveProperty("webhookSecret"); + }); + + it("rejects suspended targets, unauthorized actors, and concurrent stale transfers", async () => { + const { fixture, admin, member } = await setup(); + const profile = await admin.create({ + profileKey: "transfer-concurrency", + name: "Transfer concurrency", + }); + await expect( + member.transferOwnership({ id: profile.id, ownerType: "SERVICE", ...snapshot(profile) }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + await getPrisma().user.update({ + where: { id: fixture.secondUser.id }, + data: { status: "SUSPENDED" }, + }); + await expect( + admin.transferOwnership({ + id: profile.id, + ownerType: "PERSONAL", + ownerId: fixture.secondUser.id, + ...snapshot(profile), + }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + expect( + (await admin.ownershipCandidates()).some((user) => user.id === fixture.secondUser.id), + ).toBe(false); + await getPrisma().user.update({ + where: { id: fixture.secondUser.id }, + data: { status: "ACTIVE" }, + }); + const results = await Promise.allSettled([ + admin.transferOwnership({ id: profile.id, ownerType: "SERVICE", ...snapshot(profile) }), + admin.transferOwnership({ + id: profile.id, + ownerType: "PERSONAL", + ownerId: fixture.secondUser.id, + ...snapshot(profile), + }), + ]); + expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1); + expect(results.filter((result) => result.status === "rejected")).toHaveLength(1); + expect( + await getPrisma().instanceAuditLog.count({ + where: { actorId: fixture.user.id, action: "agent.profile.ownership_transferred" }, + }), + ).toBe(1); + }); + + it("does not lend an administrator's private runtime to another profile owner", async () => { + const { fixture, admin } = await setup(); + const privateRuntime = await getPrisma().runtime.create({ + data: { + workspaceId: fixture.workspace.id, + ownerType: "PERSONAL", + ownerId: fixture.user.id, + name: "Private", + kind: "REMOTE_HTTP", + }, + }); + await expect( + admin.create({ + profileKey: "different-owner", + name: "Different owner", + ownerId: fixture.secondUser.id, + runtimeId: privateRuntime.id, + }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + await expect( + admin.create({ + profileKey: "private-service", + name: "Private service", + ownerType: "SERVICE", + runtimeId: privateRuntime.id, + }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + const serviceRuntime = await getPrisma().runtime.create({ + data: { + workspaceId: fixture.workspace.id, + ownerType: "SERVICE", + ownerId: null, + name: "Service", + kind: "REMOTE_HTTP", + }, + }); + const globals = globalRouter.createCaller(await buildContext(fixture)); + const memberGlobals = globalRouter.createCaller( + await buildContext(fixture, { asUserId: fixture.secondUser.id }), + ); + const serviceOptions = await globals.runtimeOptions({ ownerType: "SERVICE" }); + expect(serviceOptions.some((row) => row.id === privateRuntime.id)).toBe(false); + expect(serviceOptions.some((row) => row.id === serviceRuntime.id)).toBe(true); + expect(serviceOptions.every((row) => !("config" in row) && !("endpoint" in row))).toBe(true); + await expect(memberGlobals.runtimeOptions({ ownerType: "SERVICE" })).rejects.toMatchObject({ + code: "FORBIDDEN", + }); + expect((await memberGlobals.runtimeOptions()).some((row) => row.id === serviceRuntime.id)).toBe( + false, + ); + await expect( + admin.create({ + profileKey: "service-runtime", + name: "Service runtime", + ownerType: "SERVICE", + runtimeId: serviceRuntime.id, + }), + ).resolves.toMatchObject({ ownerType: "SERVICE", runtimeId: serviceRuntime.id }); + await getPrisma().runtime.update({ + where: { id: privateRuntime.id }, + data: { instanceShared: true }, + }); + await expect( + admin.create({ + profileKey: "shared-runtime", + name: "Shared runtime", + ownerId: fixture.secondUser.id, + runtimeId: privateRuntime.id, + }), + ).resolves.toMatchObject({ ownerId: fixture.secondUser.id, runtimeId: privateRuntime.id }); + }); +}); diff --git a/src/server/routers/__tests__/agent-profile.test.ts b/src/server/routers/__tests__/agent-profile.test.ts index fef4b145..2444fd71 100644 --- a/src/server/routers/__tests__/agent-profile.test.ts +++ b/src/server/routers/__tests__/agent-profile.test.ts @@ -1,9 +1,11 @@ import { afterAll, afterEach, describe, expect, it } from "vitest"; import { AgentProvider, PluginScope, RuntimeKind } from "@prisma/client"; import { agentProfileRouter } from "@/server/routers/agent-profile"; +import { agentBindingRouter } from "@/server/routers/agent-binding"; import { buildContext, createWorkspaceFixture, + createIssue, disconnectPrisma, getPrisma, type TestFixture, @@ -12,6 +14,11 @@ import { const fixtures: TestFixture[] = []; afterEach(async () => { + await getPrisma().agentProfile.deleteMany({ + where: { + ownerId: { in: fixtures.flatMap((fixture) => [fixture.user.id, fixture.secondUser.id]) }, + }, + }); while (fixtures.length) await fixtures.pop()!.cleanup(); }); @@ -20,6 +27,247 @@ afterAll(async () => { }); describe("Agent Studio profile ownership", () => { + it.each(["MEMBER", "GUEST"] as const)( + "filters restricted-project run content for a %s until explicitly granted", + async (role) => { + const fixture = await createWorkspaceFixture({ keyPrefix: "PR" }); + fixtures.push(fixture); + const prisma = getPrisma(); + const membership = await prisma.membership.update({ + where: { + userId_workspaceId: { userId: fixture.secondUser.id, workspaceId: fixture.workspace.id }, + }, + data: { role }, + }); + const project = await prisma.project.create({ + data: { + workspaceId: fixture.workspace.id, + createdById: fixture.user.id, + key: "PRIVATE", + name: "Restricted project", + visibility: "RESTRICTED", + }, + }); + const issue = await createIssue(fixture, { + title: "Restricted issue", + projectId: project.id, + }); + const profile = await prisma.agentProfile.create({ + data: { + ownerId: fixture.secondUser.id, + profileKey: `restricted-${role.toLowerCase()}`, + name: "Project agent", + }, + }); + const binding = await prisma.agent.create({ + data: { + workspaceId: fixture.workspace.id, + profileId: profile.id, + profileKey: profile.profileKey, + name: profile.name, + }, + }); + await prisma.agentRun.create({ + data: { + workspaceId: fixture.workspace.id, + agentId: binding.id, + issueId: issue.id, + currentStep: "Restricted current step", + }, + }); + const caller = agentProfileRouter.createCaller( + await buildContext(fixture, { asUserId: fixture.secondUser.id }), + ); + const hidden = await caller.get({ id: profile.id }); + expect(hidden.bindings).toHaveLength(1); + expect(hidden.recentRuns).toEqual([]); + await prisma.projectAccess.create({ + data: { + workspaceId: fixture.workspace.id, + projectId: project.id, + membershipId: membership.id, + role: "VIEWER", + }, + }); + const permitted = await caller.get({ id: profile.id }); + expect(permitted.recentRuns).toHaveLength(1); + expect(permitted.recentRuns[0]?.issue.id).toBe(issue.id); + await prisma.projectAccess.deleteMany({ + where: { projectId: project.id, membershipId: membership.id }, + }); + expect((await caller.get({ id: profile.id })).recentRuns).toEqual([]); + await prisma.user.update({ + where: { id: fixture.secondUser.id }, + data: { instanceRole: "INSTANCE_ADMIN" }, + }); + expect((await caller.get({ id: profile.id })).recentRuns).toHaveLength(1); + }, + ); + + it("returns credential presence without exposing dispatch secrets to shared-profile viewers", async () => { + const fixture = await createWorkspaceFixture({ keyPrefix: "PS" }); + fixtures.push(fixture); + const profile = await getPrisma().agentProfile.create({ + data: { + ownerId: fixture.user.id, + profileKey: "safe-shared-definition", + name: "Shared definition", + instanceShared: true, + webhookSecret: `test-only-${fixture.workspace.id}`, + }, + }); + const context = await buildContext(fixture, { asUserId: fixture.secondUser.id }); + const profiles = agentProfileRouter.createCaller(context); + const bindings = agentBindingRouter.createCaller(context); + const reads = [ + await profiles.get({ id: profile.id }), + (await profiles.list()).find((row) => row.id === profile.id)!, + (await bindings.catalog()).find((row) => row.id === profile.id)!, + ]; + for (const read of reads) { + expect("webhookSecret" in read).toBe(false); + expect(read.hasWebhookSecret).toBe(true); + } + await getPrisma().agent.create({ + data: { + workspaceId: fixture.workspace.id, + profileId: profile.id, + profileKey: profile.profileKey, + name: profile.name, + webhookSecret: profile.webhookSecret, + }, + }); + const binding = (await bindings.list())[0]!; + expect("webhookSecret" in binding).toBe(false); + expect(binding.hasWebhookSecret).toBe(true); + }); + + it("redacts credentials from setup and governance mutation responses without clearing them", async () => { + const fixture = await createWorkspaceFixture({ keyPrefix: "PM" }); + fixtures.push(fixture); + const prisma = getPrisma(); + await prisma.user.update({ + where: { id: fixture.user.id }, + data: { instanceRole: "INSTANCE_ADMIN" }, + }); + const profile = await prisma.agentProfile.create({ + data: { + ownerId: fixture.secondUser.id, + profileKey: "shared-secret", + name: "Shared agent", + instanceShared: true, + webhookSecret: `test-only-${fixture.workspace.id}`, + }, + }); + const context = await buildContext(fixture); + const profiles = agentProfileRouter.createCaller(context); + const bindings = agentBindingRouter.createCaller(context); + const bound = await bindings.bind({ profileId: profile.id }); + const policy = await bindings.setPolicy({ agentId: bound.id, maxConcurrent: 2 }); + const unbound = await bindings.unbind({ agentId: bound.id }); + const rebound = await bindings.bindFromMissionControl({ + workspaceId: fixture.workspace.id, + profileId: profile.id, + }); + const unboundAgain = await bindings.unbindFromMissionControl({ + workspaceId: fixture.workspace.id, + agentId: rebound.id, + }); + const updated = await profiles.update({ id: profile.id, name: "Updated shared agent" }); + const shared = await profiles.setInstanceShared({ id: profile.id, instanceShared: false }); + const disabled = await profiles.setDisabled({ id: profile.id, disabled: true }); + const approved = await profiles.approve({ id: profile.id }); + const rejected = await profiles.reject({ id: profile.id }); + const archived = await profiles.archive({ id: profile.id }); + for (const response of [ + bound, + policy, + unbound, + rebound, + unboundAgain, + updated, + shared, + disabled, + approved, + rejected, + archived, + ]) { + expect("webhookSecret" in response).toBe(false); + expect(response.hasWebhookSecret).toBe(true); + } + const stored = await prisma.agentProfile.findUniqueOrThrow({ + where: { id: profile.id }, + select: { webhookSecret: true }, + }); + expect(Boolean(stored.webhookSecret)).toBe(true); + }); + + it("does not turn profile ownership into cross-workspace work or key access", async () => { + const owner = await createWorkspaceFixture({ keyPrefix: "PO" }); + const other = await createWorkspaceFixture({ keyPrefix: "PX" }); + fixtures.push(owner, other); + const prisma = getPrisma(); + const profile = await prisma.agentProfile.create({ + data: { + ownerId: owner.user.id, + profileKey: "isolated-owner", + name: "Owned agent", + instanceShared: true, + }, + }); + const binding = await prisma.agent.create({ + data: { + workspaceId: other.workspace.id, + profileId: profile.id, + profileKey: profile.profileKey, + name: profile.name, + }, + }); + await prisma.apiKey.create({ + data: { + workspaceId: other.workspace.id, + userId: other.user.id, + name: "Private client", + hashedKey: `private-${profile.id}`, + prefix: "private", + scopes: [PluginScope.READ_ISSUES], + linkedAgentId: binding.id, + }, + }); + const issue = await createIssue(other, { title: "Private workspace issue" }); + await prisma.agentRun.create({ + data: { + workspaceId: other.workspace.id, + issueId: issue.id, + agentId: binding.id, + currentStep: "Private workspace work", + }, + }); + const caller = agentProfileRouter.createCaller(await buildContext(owner)); + const detail = await caller.get({ id: profile.id }); + expect(detail.canEdit).toBe(true); + expect(detail.bindings).toEqual([]); + expect(detail.bindingCount).toBe(0); + expect(detail.recentRuns).toEqual([]); + expect(detail.owner).toEqual({ id: owner.user.id, name: owner.user.name, image: null }); + expect(detail.availability).toEqual({ scope: "INSTANCE_CATALOG", state: "AVAILABLE" }); + const listed = (await caller.list()).find((row) => row.id === profile.id)!; + expect(listed.bindings).toEqual([]); + expect(listed.bindingCount).toBe(0); + expect(listed.clientCount).toBe(0); + + // Explicit instance administration retains the existing global visibility. + await prisma.user.update({ + where: { id: owner.user.id }, + data: { instanceRole: "INSTANCE_ADMIN" }, + }); + const adminDetail = await caller.get({ id: profile.id }); + expect(adminDetail.bindings).toHaveLength(1); + expect(adminDetail.bindings[0]?.apiKeys[0]?.name).toBe("Private client"); + expect(adminDetail.bindings[0]?.canManage).toBe(false); + expect(adminDetail.recentRuns).toHaveLength(1); + }); + it("syncs identity execution fields to active workspace bindings", async () => { const fixture = await createWorkspaceFixture({ keyPrefix: "APS" }); fixtures.push(fixture); @@ -28,6 +276,7 @@ describe("Agent Studio profile ownership", () => { data: { workspaceId: fixture.workspace.id, ownerId: fixture.user.id, + ownerType: "PERSONAL", name: "Codex primary", kind: RuntimeKind.REMOTE_HTTP, }, diff --git a/src/server/routers/__tests__/agent-transport.test.ts b/src/server/routers/__tests__/agent-transport.test.ts index a546ea4e..cc838d2a 100644 --- a/src/server/routers/__tests__/agent-transport.test.ts +++ b/src/server/routers/__tests__/agent-transport.test.ts @@ -95,6 +95,8 @@ describe("agent runtime profile isolation", () => { data: { workspaceId: fixture.workspace.id, name: "Hermes Victor", + ownerType: "PERSONAL", + ownerId: fixture.user.id, kind: RuntimeKind.REMOTE_HTTP, adapterKey: "hermes", endpoint: "http://127.0.0.1:8649/v1", diff --git a/src/server/routers/__tests__/global.test.ts b/src/server/routers/__tests__/global.test.ts index 3ad23db5..c57502c0 100644 --- a/src/server/routers/__tests__/global.test.ts +++ b/src/server/routers/__tests__/global.test.ts @@ -13,6 +13,11 @@ import { const fixtures: TestFixture[] = []; afterEach(async () => { + await getPrisma().agentProfile.deleteMany({ + where: { + ownerId: { in: fixtures.flatMap((fixture) => [fixture.user.id, fixture.secondUser.id]) }, + }, + }); while (fixtures.length) await fixtures.pop()!.cleanup(); }); @@ -21,6 +26,62 @@ afterAll(async () => { }); describe("globalRouter", () => { + it("does not expose other workspaces through owned or shared agent and runtime summaries", async () => { + const owner = await createWorkspaceFixture({ keyPrefix: "GO" }); + const other = await createWorkspaceFixture({ keyPrefix: "GX" }); + fixtures.push(owner, other); + const prisma = getPrisma(); + const runtime = await prisma.runtime.create({ + data: { + ownerId: owner.user.id, + ownerType: "PERSONAL", + workspaceId: other.workspace.id, + name: "Owned runtime", + kind: "REMOTE_HTTP", + }, + }); + const profile = await prisma.agentProfile.create({ + data: { + ownerId: owner.user.id, + profileKey: "global-visibility", + name: "Shared profile", + instanceShared: true, + }, + }); + await prisma.agent.create({ + data: { + workspaceId: other.workspace.id, + profileId: profile.id, + runtimeId: runtime.id, + profileKey: profile.profileKey, + name: "Private workspace agent", + status: "ONLINE", + }, + }); + const caller = globalRouter.createCaller(await buildContext(owner)); + const agent = (await caller.agents()).find((row) => row.id === profile.id)!; + expect(agent.bindings).toEqual([]); + expect(agent.bindingCount).toBe(0); + expect(agent.online).toBe(false); + const host = (await caller.runtimes()).find((row) => row.id === runtime.id)!; + expect(host.homeWorkspace).toBeNull(); + expect(host.workspacesInUse).toEqual([]); + expect(host.boundAgents).toEqual([]); + const member = globalRouter.createCaller( + await buildContext(owner, { asUserId: owner.secondUser.id }), + ); + expect((await member.agents()).find((row) => row.id === profile.id)?.bindings).toEqual([]); + + await prisma.user.update({ + where: { id: owner.user.id }, + data: { instanceRole: "INSTANCE_ADMIN" }, + }); + expect((await caller.agents()).find((row) => row.id === profile.id)?.bindings).toHaveLength(1); + expect((await caller.runtimes()).find((row) => row.id === runtime.id)?.homeWorkspace?.id).toBe( + other.workspace.id, + ); + }); + it("reports active runs against configured concurrent agent slots", async () => { const fixture = await createWorkspaceFixture({ keyPrefix: "GLB" }); fixtures.push(fixture); diff --git a/src/server/routers/__tests__/helpers.ts b/src/server/routers/__tests__/helpers.ts index c76ad945..5035eb04 100644 --- a/src/server/routers/__tests__/helpers.ts +++ b/src/server/routers/__tests__/helpers.ts @@ -96,6 +96,12 @@ export async function createWorkspaceFixture( }); const cleanup = async () => { + // Personal resource ownership deliberately restricts user deletion. Only + // dispose of profiles owned by these fixture users; service resources must + // survive deletion of their creator and are cleaned up by their own tests. + await prisma.agentProfile.deleteMany({ + where: { ownerType: "PERSONAL", ownerId: { in: [user.id, secondUser.id] } }, + }); // ON DELETE CASCADE on Workspace fans out to everything tenant-scoped. await prisma.workspace.delete({ where: { id: workspace.id } }).catch(() => {}); await prisma.user diff --git a/src/server/routers/__tests__/runtime-admin-gating.test.ts b/src/server/routers/__tests__/runtime-admin-gating.test.ts index b5ad37e4..696569f5 100644 --- a/src/server/routers/__tests__/runtime-admin-gating.test.ts +++ b/src/server/routers/__tests__/runtime-admin-gating.test.ts @@ -32,6 +32,7 @@ async function seedRuntime(fixture: TestFixture) { data: { workspaceId: fixture.workspace.id, ownerId: fixture.user.id, + ownerType: "PERSONAL", name: "seed runtime", kind: RuntimeKind.REMOTE_HTTP, adapterKey: "codex-app-server", diff --git a/src/server/routers/__tests__/runtime-github-app.test.ts b/src/server/routers/__tests__/runtime-github-app.test.ts index 3008e730..225f8dbd 100644 --- a/src/server/routers/__tests__/runtime-github-app.test.ts +++ b/src/server/routers/__tests__/runtime-github-app.test.ts @@ -42,6 +42,7 @@ async function setup(keyPrefix: string) { data: { workspaceId: fixture.workspace.id, ownerId: fixture.user.id, + ownerType: "PERSONAL", name: "test runtime", kind: RuntimeKind.REMOTE_HTTP, adapterKey: "codex-app-server", diff --git a/src/server/routers/__tests__/runtime-lifecycle.test.ts b/src/server/routers/__tests__/runtime-lifecycle.test.ts new file mode 100644 index 00000000..50c93101 --- /dev/null +++ b/src/server/routers/__tests__/runtime-lifecycle.test.ts @@ -0,0 +1,426 @@ +import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest"; +import { runtimeRouter } from "@/server/routers/runtime"; +import { agentRouter } from "@/server/routers/agent"; +import { transferRuntime } from "@/server/services/runtime-lifecycle"; +import { + buildContext, + createWorkspaceFixture, + createIssue, + disconnectPrisma, + getPrisma, + type TestFixture, +} from "./helpers"; + +const db = getPrisma(); +let source: TestFixture; +let target: TestFixture; +async function caller(asUserId?: string) { + return runtimeRouter.createCaller(await buildContext(source, { asUserId })); +} +async function host(extra: Record = {}) { + return db.runtime.create({ + data: { + workspaceId: source.workspace.id, + ownerId: source.user.id, + ownerType: "PERSONAL", + createdById: source.user.id, + name: "Lifecycle fixture", + kind: "REMOTE_HTTP", + archivedAt: new Date(), + disabledAt: new Date(), + ...extra, + }, + }); +} +async function expected(id: string) { + const r = (await (await caller()).lifecycleImpact({ id })).runtime; + return { + id, + expectedWorkspaceId: r.workspaceId, + expectedOwnerType: r.ownerType, + expectedOwnerId: r.ownerId, + expectedUpdatedAt: r.updatedAt, + confirmName: r.name, + }; +} +function signal() { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { resolve, promise }; +} +async function waitForBlockedBy(pid: number) { + const deadline = Date.now() + 5_000; + while (Date.now() < deadline) { + const rows = await db.$queryRaw< + Array<{ waiting: boolean }> + >`SELECT EXISTS(SELECT 1 FROM pg_stat_activity WHERE ${pid} = ANY(pg_blocking_pids(pid))) AS waiting`; + if (rows[0]?.waiting) return; + await new Promise((resolve) => setTimeout(resolve, 20)); + } + throw new Error("Expected runtime row-lock wait"); +} + +describe("runtime ownership lifecycle (real Postgres)", () => { + beforeEach(async () => { + source = await createWorkspaceFixture(); + target = await createWorkspaceFixture(); + await db.user.update({ + where: { id: source.user.id }, + data: { instanceRole: "INSTANCE_ADMIN" }, + }); + }); + afterEach(async () => { + await db.instanceAuditLog.deleteMany({ where: { actorId: source.user.id } }); + await target.cleanup(); + await source.cleanup(); + }); + afterAll(disconnectPrisma); + + it("requires instance administration even for the personal owner", async () => { + const r = await host(); + await db.user.update({ where: { id: source.user.id }, data: { instanceRole: "MEMBER" } }); + await expect((await caller()).lifecycleImpact({ id: r.id })).rejects.toMatchObject({ + code: "FORBIDDEN", + }); + }); + + it("prepares globally with exact confirmation without claiming external host termination", async () => { + const r = await host({ archivedAt: null, disabledAt: null }); + const api = await caller(); + const review = await expected(r.id); + await expect(api.lifecyclePrepare({ ...review, confirmName: "wrong" })).rejects.toMatchObject({ + code: "BAD_REQUEST", + }); + const result = await api.lifecyclePrepare(review); + expect(result.runtime.archivedAt).not.toBeNull(); + expect(result.runtime.disabledAt).not.toBeNull(); + expect(result.externalHostStopped).toBe(false); + expect( + await db.instanceAuditLog.count({ + where: { action: "runtime.lifecycle.prepare", actorId: source.user.id }, + }), + ).toBe(1); + }); + + it("moves an unused registration preserving ID and creator, allowing empty adapter config", async () => { + const r = await host({ config: {} }); + const result = await ( + await caller() + ).lifecycleTransfer({ + ...(await expected(r.id)), + targetWorkspaceId: target.workspace.id, + ownerType: "SERVICE", + ownerId: null, + }); + expect(result).toMatchObject({ + id: r.id, + workspaceId: target.workspace.id, + ownerType: "SERVICE", + ownerId: null, + }); + expect((await db.runtime.findUniqueOrThrow({ where: { id: r.id } })).createdById).toBe( + source.user.id, + ); + expect( + await db.instanceAuditLog.count({ + where: { actorId: source.user.id, action: "runtime.lifecycle.transfer" }, + }), + ).toBe(1); + }); + + it("validates active destination and personal owner membership", async () => { + const r = await host(); + const api = await caller(); + const review = await expected(r.id); + await expect( + api.lifecycleTransfer({ + ...review, + targetWorkspaceId: target.workspace.id, + ownerType: "PERSONAL", + ownerId: source.user.id, + }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + await expect( + api.lifecycleTransfer({ + ...review, + targetWorkspaceId: target.workspace.id, + ownerType: "SERVICE", + ownerId: target.user.id, + }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + await db.workspace.update({ + where: { id: target.workspace.id }, + data: { deletedAt: new Date() }, + }); + await expect( + api.lifecycleTransfer({ + ...review, + targetWorkspaceId: target.workspace.id, + ownerType: "SERVICE", + ownerId: null, + }), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + }); + + it("rejects a stale reviewed snapshot", async () => { + const r = await host(); + const review = await expected(r.id); + await db.runtime.update({ where: { id: r.id }, data: { name: "Changed" } }); + await expect((await caller()).lifecycleRemove(review)).rejects.toMatchObject({ + code: "CONFLICT", + }); + }); + + it("invalidates a removal preview after child repository configuration changes", async () => { + const r = await host(); const api = await caller(); const review = await expected(r.id); + await api.setRepo({ runtimeId: r.id, url: "https://example.com/repo", path: "repo" }); + await expect(api.lifecycleRemove(review)).rejects.toMatchObject({ code: "CONFLICT" }); + expect(await db.runtimeRepo.count({ where: { runtimeId: r.id } })).toBe(1); + }); + + it("blocks waiting runs and active delivery, retaining terminal histories on ownership change", async () => { + const r = await host(); const api = await caller(); + const agent = await db.agent.create({ data: { workspaceId: source.workspace.id, profileKey: "history", name: "History agent" } }); + const connection = await db.agentConnection.create({ data: { workspaceId: source.workspace.id, agentId: agent.id, + runtimeId: r.id, kind: "MANAGED_RUNTIME", livenessModel: "HEARTBEAT", status: "DISCONNECTED", disconnectedAt: new Date() } }); + const issue = await createIssue(source); + const run = await db.agentRun.create({ data: { workspaceId: source.workspace.id, issueId: issue.id, agentId: agent.id, + connectionId: connection.id, status: "WAITING" } }); + const delivery = await db.workSession.create({ data: { workspaceId: source.workspace.id, issueId: issue.id, + ownerAgentId: agent.id, ownerConnectionId: connection.id, source: "FORGE_AGENT", repoFullName: "fixture/repo", branch: "fixture" } }); + const impact = await api.lifecycleImpact({ id: r.id }); + expect(impact.transferBlockers.join(" ")).toContain("nonterminal run"); + expect(impact.transferBlockers.join(" ")).toContain("active delivery"); + await db.agentRun.update({ where: { id: run.id }, data: { status: "COMPLETED", finishedAt: new Date() } }); + await expect(api.lifecycleTransfer({ ...await expected(r.id), targetWorkspaceId: source.workspace.id, ownerType: "SERVICE", ownerId: null })).rejects.toMatchObject({ code: "CONFLICT" }); + await db.workSession.update({ where: { id: delivery.id }, data: { status: "ABANDONED", endedAt: new Date() } }); + await api.lifecycleTransfer({ ...await expected(r.id), targetWorkspaceId: source.workspace.id, ownerType: "SERVICE", ownerId: null }); + expect(await db.agentRun.findUnique({ where: { id: run.id } })).not.toBeNull(); + expect(await db.workSession.findUnique({ where: { id: delivery.id } })).not.toBeNull(); + await expect(api.lifecycleRemove(await expected(r.id))).rejects.toMatchObject({ code: "CONFLICT" }); + }); + + it("blocks references and active diagnostics without exposing secrets", async () => { + const r = await host({ secret: "private-value" }); + await db.agentProfile.create({ + data: { + ownerId: source.user.id, + name: "Unbound profile", + profileKey: "unbound", + runtimeId: r.id, + }, + }); + await db.runtimeDiagnosticAttempt.create({ + data: { + runtimeId: r.id, + workspaceId: source.workspace.id, + requestId: `pending-${r.id}`, + kind: "PROBE", + trigger: "MANUAL_RUNTIME", + }, + }); + const impact = await (await caller()).lifecycleImpact({ id: r.id }); + expect(impact.counts.profiles).toBe(1); + expect(impact.counts.diagnostics).toBe(1); + expect(impact.transferBlockers.join(" ")).toContain("unfinished"); + expect(JSON.stringify(impact)).not.toContain("private-value"); + await expect((await caller()).lifecycleRemove(await expected(r.id))).rejects.toMatchObject({ + code: "CONFLICT", + }); + }); + + it("retains terminal diagnostics on ownership-only transfer but prevents cross-home relocation or removal", async () => { + const r = await host(); + const api = await caller(); + const record = await db.runtimeDiagnosticAttempt.create({ + data: { + runtimeId: r.id, + workspaceId: source.workspace.id, + requestId: `done-${r.id}`, + kind: "PROBE", + trigger: "MANUAL_RUNTIME", + completedAt: new Date(), + }, + }); + await expect( + api.lifecycleTransfer({ + ...(await expected(r.id)), + targetWorkspaceId: target.workspace.id, + ownerType: "SERVICE", + ownerId: null, + }), + ).rejects.toMatchObject({ code: "CONFLICT" }); + await api.lifecycleTransfer({ + ...(await expected(r.id)), + targetWorkspaceId: source.workspace.id, + ownerType: "SERVICE", + ownerId: null, + }); + expect( + await db.runtimeDiagnosticAttempt.findUnique({ where: { id: record.id } }), + ).not.toBeNull(); + await expect(api.lifecycleRemove(await expected(r.id))).rejects.toMatchObject({ + code: "CONFLICT", + }); + }); + + it("blocks credentials and repository configuration during transfer", async () => { + const r = await host(); + await db.runtimeSecret.create({ + data: { + runtimeId: r.id, + workspaceId: source.workspace.id, + key: "TOKEN", + valueEnc: "not-disclosed", + }, + }); + await db.runtimeRepo.create({ + data: { + runtimeId: r.id, + workspaceId: source.workspace.id, + url: "https://example.com/repo", + path: "repo", + }, + }); + const impact = await ( + await caller() + ).lifecycleImpact({ id: r.id, targetWorkspaceId: target.workspace.id }); + expect(impact.counts.secrets).toBe(1); + expect(impact.counts.repos).toBe(1); + await expect( + (await caller()).lifecycleTransfer({ + ...(await expected(r.id)), + targetWorkspaceId: target.workspace.id, + ownerType: "SERVICE", + ownerId: null, + }), + ).rejects.toMatchObject({ code: "CONFLICT" }); + }); + + it("removes unused archived configuration and audits the exact resource without host claims", async () => { + const r = await host(); + const result = await (await caller()).lifecycleRemove(await expected(r.id)); + expect(result).toEqual({ removed: true, id: r.id, externalHostStopped: false }); + expect(await db.runtime.findUnique({ where: { id: r.id } })).toBeNull(); + expect( + await db.instanceAuditLog.count({ + where: { actorId: source.user.id, action: "runtime.lifecycle.remove" }, + }), + ).toBe(1); + }); + + it("does not let workspace members mutate a shared runtime through registration or archive", async () => { + const r = await host({ instanceShared: true, archivedAt: null, disabledAt: null }); + const member = await caller(source.secondUser.id); + await expect( + member.register({ + name: r.name, + kind: r.kind, + endpoint: "https://example.com", + providersAvailable: [], + }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + await expect(member.archive({ id: r.id })).rejects.toMatchObject({ code: "FORBIDDEN" }); + await expect(member.setEnabled({ id: r.id, enabled: false })).rejects.toMatchObject({ + code: "FORBIDDEN", + }); + }); + + it("stamps personal registration ownership and never echoes a stored secret", async () => { + const api = await caller(); + const r = await api.register({ + name: "Fresh personal", + kind: "REMOTE_HTTP", + providersAvailable: [], + }); + expect(r).toMatchObject({ + ownerType: "PERSONAL", + ownerId: source.user.id, + createdById: source.user.id, + }); + await db.runtime.update({ where: { id: r.id }, data: { secret: "do-not-echo" } }); + const updated = await api.register({ name: r.name, kind: r.kind, providersAvailable: [] }); + expect(updated).not.toHaveProperty("secret"); + expect(updated.hasSecret).toBe(true); + }); + + it.each(["SERVICE", "PERSONAL"] as const)("requires adoption before new legacy-router attachments (%s)", async (ownerType) => { + const r = await host({ ownerType: "UNASSIGNED", ownerId: null, archivedAt: null, disabledAt: null }); + const agents = agentRouter.createCaller(await buildContext(source)); + const existing = await agents.create({ name: "Unattached", profileKey: "unattached" }); + await expect(agents.create({ name: "Blocked", profileKey: "blocked", runtimeId: r.id })).rejects.toMatchObject({ code: "BAD_REQUEST" }); + await expect(agents.update({ id: existing.id, runtimeId: r.id })).rejects.toMatchObject({ code: "BAD_REQUEST" }); + const api = await caller(); + await api.lifecyclePrepare(await expected(r.id)); + await api.lifecycleTransfer({ ...await expected(r.id), targetWorkspaceId: source.workspace.id, + ownerType, ownerId: ownerType === "PERSONAL" ? source.user.id : null }); + await api.unarchive({ id: r.id }); await api.setEnabled({ id: r.id, enabled: true }); + await expect(agents.create({ name: "Allowed", profileKey: "allowed", runtimeId: r.id })).resolves.toMatchObject({ runtimeId: r.id }); + await expect(agents.update({ id: existing.id, runtimeId: r.id })).resolves.toMatchObject({ runtimeId: r.id }); + }); + + it("preserves existing unassigned-runtime bindings when editing unrelated agent metadata", async () => { + const r = await host({ ownerType: "UNASSIGNED", ownerId: null, archivedAt: null, disabledAt: null }); + const agent = await db.agent.create({ data: { workspaceId: source.workspace.id, profileKey: "legacy", name: "Legacy", runtimeId: r.id } }); + const updated = await agentRouter.createCaller(await buildContext(source)).update({ id: agent.id, name: "Renamed" }); + expect(updated.runtimeId).toBe(r.id); + expect((await db.runtime.findUniqueOrThrow({ where: { id: r.id } })).ownerType).toBe("UNASSIGNED"); + }); + + it.each(["agent attachment", "metadata edit", "diagnostic request"])( + "rechecks old-home authority after concurrent transfer: %s", + async (operation) => { + const r = await host(); + const review = await expected(r.id); + const ready = signal(); + const release = signal(); + let holder = 0; + const transfer = db.$transaction( + async (tx) => { + await transferRuntime( + tx, + { + ...review, + targetWorkspaceId: target.workspace.id, + ownerType: "SERVICE", + ownerId: null, + }, + source.user.id, + ); + holder = (await tx.$queryRaw>`SELECT pg_backend_pid() AS pid`)[0] + .pid; + ready.resolve(); + await release.promise; + }, + { timeout: 15_000 }, + ); + await ready.promise; + const context = await buildContext(source); + const mutation = + operation === "agent attachment" + ? agentRouter + .createCaller(context) + .create({ name: "Late binding", profileKey: "late", runtimeId: r.id }) + : operation === "metadata edit" + ? runtimeRouter.createCaller(context).update({ id: r.id, name: "Late edit" }) + : runtimeRouter.createCaller(context).verifyConnection({ id: r.id }); + const outcome = mutation.then( + () => null, + (error: unknown) => error, + ); + try { + await waitForBlockedBy(holder); + } finally { + release.resolve(); + } + await transfer; + expect(await outcome).toMatchObject({ + code: operation === "agent attachment" ? "BAD_REQUEST" : "NOT_FOUND", + }); + expect(await db.agent.count({ where: { runtimeId: r.id } })).toBe(0); + expect(await db.runtimeDiagnosticAttempt.count({ where: { runtimeId: r.id } })).toBe(0); + }, + ); +}); diff --git a/src/server/routers/__tests__/runtime-secrets.test.ts b/src/server/routers/__tests__/runtime-secrets.test.ts index dcf088bf..fab102bf 100644 --- a/src/server/routers/__tests__/runtime-secrets.test.ts +++ b/src/server/routers/__tests__/runtime-secrets.test.ts @@ -31,6 +31,7 @@ async function setup(keyPrefix: string) { data: { workspaceId: fixture.workspace.id, ownerId: fixture.user.id, + ownerType: "PERSONAL", name: "test runtime", kind: RuntimeKind.REMOTE_HTTP, adapterKey: "codex-app-server", diff --git a/src/server/routers/agent-binding.ts b/src/server/routers/agent-binding.ts index 84f4bebd..40b72803 100644 --- a/src/server/routers/agent-binding.ts +++ b/src/server/routers/agent-binding.ts @@ -3,6 +3,13 @@ import { TRPCError } from "@trpc/server"; import { EngagementMode, EventKind } from "@prisma/client"; import { router, workspaceProcedure, adminProcedure, protectedProcedure } from "@/server/trpc"; import { recordChange } from "@/server/audit"; +import { profileTransaction } from "@/server/services/agent-profile-policy"; +import { + agentProfileAvailability, + assertAgentProfileBindable, + availableAgentProfilesWhere, + publicAgentDefinition, +} from "@/server/services/agent-profile-access"; /** * Workspace agent **bindings** — the per-workspace adoption of a global @@ -17,29 +24,33 @@ import { recordChange } from "@/server/audit"; export const agentBindingRouter = router({ /** Profiles available to bind here (owned + instance-shared) and not already bound. */ catalog: workspaceProcedure.query(async ({ ctx }) => { + const actor = await ctx.db.user.findUnique({ + where: { id: ctx.session.user.id }, + select: { instanceRole: true }, + }); const bound = await ctx.db.agent.findMany({ where: { workspaceId: ctx.workspaceId, archivedAt: null, profileId: { not: null } }, select: { profileId: true }, }); const boundIds = new Set(bound.map((b) => b.profileId!)); const profiles = await ctx.db.agentProfile.findMany({ - where: { - archivedAt: null, - disabledAt: null, - // Only approved profiles are bindable. A profile is approved when it - // wasn't requested (admin-created → requestedById null) or has been - // approved (approvedAt set). Pending requests are hidden from the catalog. - AND: [ - { OR: [{ ownerId: ctx.session.user.id }, { instanceShared: true }] }, - { OR: [{ requestedById: null }, { approvedAt: { not: null } }] }, - ], - }, + where: availableAgentProfilesWhere( + ctx.session.user.id, + actor?.instanceRole === "INSTANCE_ADMIN", + ), orderBy: { createdAt: "asc" }, - include: { runtime: { select: { id: true, name: true, kind: true } } }, + include: { + owner: { select: { id: true, name: true, image: true } }, + runtime: { select: { id: true, name: true, kind: true } }, + }, }); return profiles .filter((p) => !boundIds.has(p.id)) - .map((p) => ({ ...p, ownedByMe: p.ownerId === ctx.session.user.id })); + .map((p) => ({ + ...publicAgentDefinition(p), + availability: agentProfileAvailability(p), + ownedByMe: p.ownerId === ctx.session.user.id, + })); }), /** Agents bound to this workspace, with policy + live-use rollup. */ @@ -57,6 +68,7 @@ export const agentBindingRouter = router({ disabledAt: true, baseCapabilities: true, ownerId: true, + ownerType: true, }, }, runtime: { select: { id: true, name: true, kind: true } }, @@ -70,7 +82,7 @@ export const agentBindingRouter = router({ ctx.db.agentRun.count({ where: { agentId: a.id, startedAt: { gte: since } } }), ctx.db.agentRun.count({ where: { agentId: a.id, status: "ACTIVE" } }), ]); - return { ...a, runs24, activeRuns }; + return { ...publicAgentDefinition(a), runs24, activeRuns }; }), ); }), @@ -87,83 +99,90 @@ export const agentBindingRouter = router({ capabilities: z.array(z.string().min(1).max(40)).optional(), }), ) - .mutation(async ({ ctx, input }) => { - const profile = await ctx.db.agentProfile.findUnique({ where: { id: input.profileId } }); - if (!profile || profile.archivedAt) - throw new TRPCError({ code: "NOT_FOUND", message: "Profile not found." }); - const shareable = profile.ownerId === ctx.session.user.id || profile.instanceShared; - if (!shareable) - throw new TRPCError({ - code: "FORBIDDEN", - message: "That profile isn't available to this workspace.", - }); - // Pending (requested-but-unapproved) profiles can't be bound. - if (profile.requestedById && !profile.approvedAt) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "That profile is pending instance-admin approval.", - }); - } + .mutation(({ ctx, input }) => + profileTransaction( + ctx.db, + async (tx) => { + // Serialize definition adoption with ownership, sharing, and lifecycle changes. + await tx.$queryRaw`SELECT "id" FROM "AgentProfile" WHERE "id" = ${input.profileId} FOR SHARE`; + const [profile, actor] = await Promise.all([ + tx.agentProfile.findUnique({ where: { id: input.profileId } }), + tx.user.findUnique({ + where: { id: ctx.session.user.id }, + select: { instanceRole: true }, + }), + ]); + assertAgentProfileBindable( + profile, + ctx.session.user.id, + actor?.instanceRole === "INSTANCE_ADMIN", + ); - // profileKey is unique per (workspaceId). Reuse an archived binding if present. - const existing = await ctx.db.agent.findUnique({ - where: { - workspaceId_profileKey: { workspaceId: ctx.workspaceId, profileKey: profile.profileKey }, - }, - select: { id: true, archivedAt: true }, - }); - if (existing && !existing.archivedAt) { - throw new TRPCError({ - code: "CONFLICT", - message: "That profile is already bound to this workspace.", - }); - } + // profileKey is unique per (workspaceId). Reuse an archived binding if present. + const existing = await tx.agent.findUnique({ + where: { + workspaceId_profileKey: { + workspaceId: ctx.workspaceId, + profileKey: profile.profileKey, + }, + }, + select: { id: true, archivedAt: true }, + }); + if (existing && !existing.archivedAt) { + throw new TRPCError({ + code: "CONFLICT", + message: "That profile is already bound to this workspace.", + }); + } - const data = { - profileId: profile.id, - name: profile.name, - profileKey: profile.profileKey, - description: profile.description, - avatar: profile.avatar, - provider: profile.provider, - runtimeMode: profile.runtimeMode, - runEngine: profile.runEngine, - webhookUrl: profile.webhookUrl, - webhookSecret: profile.webhookSecret, - runtimeId: profile.runtimeId, - role: profile.role, - templateMarkdown: profile.templateMarkdown, - capabilities: input.capabilities ?? profile.baseCapabilities, - maxConcurrent: input.maxConcurrent ?? 1, - autoDispatchEligible: input.autoDispatchEligible ?? true, - engagementMode: input.engagementMode ?? null, - requireApprovalBeforeStart: input.requireApprovalBeforeStart ?? false, - }; + const data = { + profileId: profile.id, + name: profile.name, + profileKey: profile.profileKey, + description: profile.description, + avatar: profile.avatar, + provider: profile.provider, + runtimeMode: profile.runtimeMode, + runEngine: profile.runEngine, + webhookUrl: profile.webhookUrl, + webhookSecret: profile.webhookSecret, + runtimeId: profile.runtimeId, + role: profile.role, + templateMarkdown: profile.templateMarkdown, + capabilities: input.capabilities ?? profile.baseCapabilities, + maxConcurrent: input.maxConcurrent ?? 1, + autoDispatchEligible: input.autoDispatchEligible ?? true, + engagementMode: input.engagementMode ?? null, + requireApprovalBeforeStart: input.requireApprovalBeforeStart ?? false, + }; - const agent = existing - ? await ctx.db.agent.update({ - where: { id: existing.id }, - data: { ...data, archivedAt: null }, - }) - : await ctx.db.agent.create({ data: { ...data, workspaceId: ctx.workspaceId } }); + const agent = existing + ? await tx.agent.update({ + where: { id: existing.id }, + data: { ...data, archivedAt: null }, + }) + : await tx.agent.create({ data: { ...data, workspaceId: ctx.workspaceId } }); - await recordChange(ctx.db, { - workspaceId: ctx.workspaceId, - actorId: ctx.session.user.id, - actorAgentId: ctx.apiKey?.linkedAgentId ?? null, - entity: "Agent", - entityId: agent.id, - action: "create", - after: { profileKey: agent.profileKey, profileId: profile.id }, - eventKind: EventKind.AGENT_CREATED, - subjectType: "agent", - subjectId: agent.id, - payload: { profileKey: agent.profileKey, profileId: profile.id, bound: true }, - ip: ctx.ip, - userAgent: ctx.userAgent, - }); - return agent; - }), + await recordChange(tx, { + workspaceId: ctx.workspaceId, + actorId: ctx.session.user.id, + actorAgentId: ctx.apiKey?.linkedAgentId ?? null, + entity: "Agent", + entityId: agent.id, + action: "create", + after: { profileKey: agent.profileKey, profileId: profile.id }, + eventKind: EventKind.AGENT_CREATED, + subjectType: "agent", + subjectId: agent.id, + payload: { profileKey: agent.profileKey, profileId: profile.id, bound: true }, + ip: ctx.ip, + userAgent: ctx.userAgent, + }); + return publicAgentDefinition(agent); + }, + "ReadCommitted", + ), + ), /** * Mission Control bind action. The caller chooses an explicit workspace, @@ -176,113 +195,103 @@ export const agentBindingRouter = router({ profileId: z.string().cuid(), }), ) - .mutation(async ({ ctx, input }) => { - const membership = await ctx.db.membership.findUnique({ - where: { - userId_workspaceId: { - userId: ctx.session.user.id, - workspaceId: input.workspaceId, - }, - }, - select: { role: true }, - }); - if (!membership || (membership.role !== "OWNER" && membership.role !== "ADMIN")) { - throw new TRPCError({ code: "FORBIDDEN", message: "Workspace admin role required." }); - } + .mutation(({ ctx, input }) => + profileTransaction( + ctx.db, + async (tx) => { + await tx.$queryRaw`SELECT "id" FROM "AgentProfile" WHERE "id" = ${input.profileId} FOR SHARE`; + const membership = await tx.membership.findUnique({ + where: { + userId_workspaceId: { + userId: ctx.session.user.id, + workspaceId: input.workspaceId, + }, + }, + select: { role: true }, + }); + if (!membership || (membership.role !== "OWNER" && membership.role !== "ADMIN")) { + throw new TRPCError({ code: "FORBIDDEN", message: "Workspace admin role required." }); + } - const [profile, actor] = await Promise.all([ - ctx.db.agentProfile.findUnique({ where: { id: input.profileId } }), - ctx.db.user.findUnique({ - where: { id: ctx.session.user.id }, - select: { instanceRole: true }, - }), - ]); - if (!profile || profile.archivedAt) { - throw new TRPCError({ code: "NOT_FOUND", message: "Profile not found." }); - } - const available = - profile.ownerId === ctx.session.user.id || - profile.instanceShared || - actor?.instanceRole === "INSTANCE_ADMIN"; - if (!available) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "That profile isn't available to this workspace.", - }); - } - if (profile.requestedById && !profile.approvedAt) { - throw new TRPCError({ - code: "FORBIDDEN", - message: "That profile is pending instance-admin approval.", - }); - } - if (profile.disabledAt) { - throw new TRPCError({ code: "FORBIDDEN", message: "That profile is disabled." }); - } + const [profile, actor] = await Promise.all([ + tx.agentProfile.findUnique({ where: { id: input.profileId } }), + tx.user.findUnique({ + where: { id: ctx.session.user.id }, + select: { instanceRole: true }, + }), + ]); + assertAgentProfileBindable( + profile, + ctx.session.user.id, + actor?.instanceRole === "INSTANCE_ADMIN", + ); - const existing = await ctx.db.agent.findUnique({ - where: { - workspaceId_profileKey: { - workspaceId: input.workspaceId, + const existing = await tx.agent.findUnique({ + where: { + workspaceId_profileKey: { + workspaceId: input.workspaceId, + profileKey: profile.profileKey, + }, + }, + select: { id: true, archivedAt: true }, + }); + if (existing && !existing.archivedAt) { + throw new TRPCError({ + code: "CONFLICT", + message: "That profile is already bound to this workspace.", + }); + } + + const data = { + profileId: profile.id, + name: profile.name, profileKey: profile.profileKey, - }, - }, - select: { id: true, archivedAt: true }, - }); - if (existing && !existing.archivedAt) { - throw new TRPCError({ - code: "CONFLICT", - message: "That profile is already bound to this workspace.", - }); - } + description: profile.description, + avatar: profile.avatar, + provider: profile.provider, + runtimeMode: profile.runtimeMode, + runEngine: profile.runEngine, + webhookUrl: profile.webhookUrl, + webhookSecret: profile.webhookSecret, + runtimeId: profile.runtimeId, + role: profile.role, + templateMarkdown: profile.templateMarkdown, + capabilities: profile.baseCapabilities, + maxConcurrent: 1, + autoDispatchEligible: true, + engagementMode: null, + requireApprovalBeforeStart: false, + }; - const data = { - profileId: profile.id, - name: profile.name, - profileKey: profile.profileKey, - description: profile.description, - avatar: profile.avatar, - provider: profile.provider, - runtimeMode: profile.runtimeMode, - runEngine: profile.runEngine, - webhookUrl: profile.webhookUrl, - webhookSecret: profile.webhookSecret, - runtimeId: profile.runtimeId, - role: profile.role, - templateMarkdown: profile.templateMarkdown, - capabilities: profile.baseCapabilities, - maxConcurrent: 1, - autoDispatchEligible: true, - engagementMode: null, - requireApprovalBeforeStart: false, - }; + const agent = existing + ? await tx.agent.update({ + where: { id: existing.id }, + data: { ...data, archivedAt: null }, + }) + : await tx.agent.create({ + data: { ...data, workspaceId: input.workspaceId }, + }); - const agent = existing - ? await ctx.db.agent.update({ - where: { id: existing.id }, - data: { ...data, archivedAt: null }, - }) - : await ctx.db.agent.create({ - data: { ...data, workspaceId: input.workspaceId }, + await recordChange(tx, { + workspaceId: input.workspaceId, + actorId: ctx.session.user.id, + actorAgentId: null, + entity: "Agent", + entityId: agent.id, + action: "create", + after: { profileKey: agent.profileKey, profileId: profile.id }, + eventKind: EventKind.AGENT_CREATED, + subjectType: "agent", + subjectId: agent.id, + payload: { profileKey: agent.profileKey, profileId: profile.id, bound: true }, + ip: ctx.ip, + userAgent: ctx.userAgent, }); - - await recordChange(ctx.db, { - workspaceId: input.workspaceId, - actorId: ctx.session.user.id, - actorAgentId: null, - entity: "Agent", - entityId: agent.id, - action: "create", - after: { profileKey: agent.profileKey, profileId: profile.id }, - eventKind: EventKind.AGENT_CREATED, - subjectType: "agent", - subjectId: agent.id, - payload: { profileKey: agent.profileKey, profileId: profile.id, bound: true }, - ip: ctx.ip, - userAgent: ctx.userAgent, - }); - return agent; - }), + return publicAgentDefinition(agent); + }, + "ReadCommitted", + ), + ), /** Per-binding policy edit. */ setPolicy: adminProcedure @@ -304,7 +313,7 @@ export const agentBindingRouter = router({ if (!agent) throw new TRPCError({ code: "NOT_FOUND" }); const { agentId, ...patch } = input; const data = Object.fromEntries(Object.entries(patch).filter(([, v]) => v !== undefined)); - return ctx.db.agent.update({ where: { id: agentId }, data }); + return publicAgentDefinition(await ctx.db.agent.update({ where: { id: agentId }, data })); }), /** Unbind (archive) an agent binding. Runs / chats / history are preserved. */ @@ -335,7 +344,7 @@ export const agentBindingRouter = router({ ip: ctx.ip, userAgent: ctx.userAgent, }); - return updated; + return publicAgentDefinition(updated); }), /** Mission Control unbind action, explicitly scoped to a managed workspace. */ @@ -379,6 +388,6 @@ export const agentBindingRouter = router({ ip: ctx.ip, userAgent: ctx.userAgent, }); - return updated; + return publicAgentDefinition(updated); }), }); diff --git a/src/server/routers/agent-profile.ts b/src/server/routers/agent-profile.ts index 985a236d..e7c25d05 100644 --- a/src/server/routers/agent-profile.ts +++ b/src/server/routers/agent-profile.ts @@ -1,22 +1,27 @@ import { z } from "zod"; import { TRPCError } from "@trpc/server"; -import { - AgentProvider, - AgentRole, - AgentRuntimeMode, - RunEngine, - type Prisma, - type PrismaClient, -} from "@prisma/client"; +import { AgentProvider, AgentRole, AgentRuntimeMode, RunEngine, type Prisma } from "@prisma/client"; import { router, globalProcedure, protectedProcedure, instanceAdminProcedure } from "@/server/trpc"; +import { issueWhereForViewer } from "@/server/services/project-access"; +import { + agentProfileAvailability, + canViewAgentWorkspace, + publicAgentDefinition, +} from "@/server/services/agent-profile-access"; +import { + assertActiveProfileOwner, + assertProfileRuntimeAvailable, + auditAgentProfile, + profileTransaction, + readAgentPolicy, + type ProfileDb, +} from "@/server/services/agent-profile-policy"; /** - * Global agent **profiles** — the user-owned definitions in the - * three-tier model (definition → binding → instance policy). Reads are - * cross-workspace (`globalProcedure`); **creating** a profile requires - * INSTANCE_ADMIN (members see a "requires instance admin" affordance in - * the bind catalog). Owners may edit/archive their own profiles; instance - * admins may edit any, mark instance-shared, and force-disable. + * Personal and instance-managed service definitions. Ownership determines who + * manages the definition; catalog sharing never grants workspace access. + * Member creation follows explicit instance policy. Service creation, sharing, + * and ownership transfers require instance administration. * * Part of the multi-workspace restructure (docs/plans/multiws-restructure.md). */ @@ -27,19 +32,19 @@ const profileKeySchema = z .max(40) .regex(/^[a-z0-9][a-z0-9-_]*$/, "Lowercase, digits, `-` or `_` only"); -async function instanceRoleOf(db: PrismaClient, userId: string) { +async function instanceRoleOf(db: ProfileDb, userId: string) { const u = await db.user.findUnique({ where: { id: userId }, select: { instanceRole: true } }); return u?.instanceRole ?? "MEMBER"; } /** Throws unless the caller owns the profile or is an instance admin. */ -async function assertCanEdit(db: PrismaClient, userId: string, profileId: string) { +async function assertCanEdit(db: ProfileDb, userId: string, profileId: string) { const p = await db.agentProfile.findUnique({ where: { id: profileId }, - select: { ownerId: true }, + select: { ownerId: true, ownerType: true }, }); if (!p) throw new TRPCError({ code: "NOT_FOUND", message: "Profile not found." }); - if (p.ownerId === userId) return; + if (p.ownerType === "PERSONAL" && p.ownerId === userId) return; if ((await instanceRoleOf(db, userId)) === "INSTANCE_ADMIN") return; throw new TRPCError({ code: "FORBIDDEN", @@ -47,36 +52,48 @@ async function assertCanEdit(db: PrismaClient, userId: string, profileId: string }); } -async function assertRuntimeAvailable( - db: PrismaClient, - runtimeId: string | null | undefined, - ownerId: string, - actorId: string, -) { - if (!runtimeId) return; - const runtime = await db.runtime.findFirst({ - where: { - id: runtimeId, - archivedAt: null, - OR: [{ ownerId }, { ownerId: actorId }, { instanceShared: true }], - }, - select: { id: true }, - }); - if (!runtime) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "That runtime is not available to this agent profile.", - }); - } -} +const ownerTypeSchema = z.enum(["PERSONAL", "SERVICE"]); +const creationPolicySchema = z.enum(["DISABLED", "APPROVAL_REQUIRED", "SELF_SERVICE"]); +const auditActor = (ctx: { + session: { user: { id: string } }; + ip: string | null; + userAgent: string | null; +}) => ({ actorId: ctx.session.user.id, ip: ctx.ip, userAgent: ctx.userAgent }); export const agentProfileRouter = router({ + policy: protectedProcedure.query(({ ctx }) => readAgentPolicy(ctx.db)), + + setPolicy: instanceAdminProcedure + .input(z.object({ personalAgentCreation: creationPolicySchema })) + .mutation(({ ctx, input }) => + profileTransaction(ctx.db, async (tx) => { + const before = await readAgentPolicy(tx); + await tx.instanceAgentPolicy.upsert({ + where: { id: "default" }, + create: { id: "default", ...input }, + update: input, + }); + await auditAgentProfile(tx, auditActor(ctx), "agent.policy.updated", { + before, + after: input, + }); + return input; + }), + ), + + ownershipCandidates: instanceAdminProcedure.query(({ ctx }) => + ctx.db.user.findMany({ + where: { status: "ACTIVE", deletedAt: null, disabledAt: null }, + select: { id: true, name: true, email: true, image: true }, + orderBy: [{ name: "asc" }, { id: "asc" }], + }), + ), /** Owned + instance-shared profiles, with cross-workspace binding summary. */ list: globalProcedure.query(async ({ ctx }) => { const [instanceRole, memberships] = await Promise.all([ instanceRoleOf(ctx.db, ctx.session.user.id), ctx.db.membership.findMany({ - where: { userId: ctx.session.user.id }, + where: { userId: ctx.session.user.id, workspace: { deletedAt: null } }, select: { workspaceId: true }, }), ]); @@ -90,6 +107,8 @@ export const agentProfileRouter = router({ }, orderBy: { createdAt: "asc" }, include: { + owner: { select: { id: true, name: true, image: true } }, + createdBy: { select: { id: true, name: true } }, runtime: { select: { id: true, name: true, kind: true } }, bindings: { where: { archivedAt: null }, @@ -105,14 +124,18 @@ export const agentProfileRouter = router({ }, }); return profiles.map((p) => { - const canSeeAllBindings = - p.ownerId === ctx.session.user.id || instanceRole === "INSTANCE_ADMIN"; - const bindings = canSeeAllBindings - ? p.bindings - : p.bindings.filter((binding) => visibleWorkspaceIds.has(binding.workspace.id)); + const bindings = p.bindings.filter((binding) => + canViewAgentWorkspace( + binding.workspace.id, + visibleWorkspaceIds, + instanceRole === "INSTANCE_ADMIN", + ), + ); return { - ...p, + ...publicAgentDefinition(p), bindings, + bindingCount: bindings.length, + availability: agentProfileAvailability(p), ownedByMe: p.ownerId === ctx.session.user.id, online: bindings.some((b) => b.status === "ONLINE" || b.status === "BUSY"), clientCount: bindings.reduce((total, binding) => total + binding._count.apiKeys, 0), @@ -125,6 +148,8 @@ export const agentProfileRouter = router({ const p = await ctx.db.agentProfile.findUnique({ where: { id: input.id }, include: { + owner: { select: { id: true, name: true, image: true } }, + createdBy: { select: { id: true, name: true } }, runtime: { select: { id: true, name: true, kind: true } }, bindings: { where: { archivedAt: null }, @@ -167,8 +192,9 @@ export const agentProfileRouter = router({ where: { userId: ctx.session.user.id, workspaceId: { in: workspaceIds }, + workspace: { deletedAt: null }, }, - select: { workspaceId: true, role: true }, + select: { id: true, workspaceId: true, role: true }, }) : []; const visibleWorkspaceIds = new Set(memberships.map((membership) => membership.workspaceId)); @@ -177,13 +203,30 @@ export const agentProfileRouter = router({ .filter((membership) => membership.role === "OWNER" || membership.role === "ADMIN") .map((membership) => membership.workspaceId), ); - const visibleBindings = canEdit - ? p.bindings - : p.bindings.filter((binding) => visibleWorkspaceIds.has(binding.workspace.id)); + // Managing a definition does not grant access to the workspaces using it. + const visibleBindings = p.bindings.filter((binding) => + canViewAgentWorkspace( + binding.workspace.id, + visibleWorkspaceIds, + instanceRole === "INSTANCE_ADMIN", + ), + ); const bindingIds = visibleBindings.map((binding) => binding.id); const recentRuns = bindingIds.length ? await ctx.db.agentRun.findMany({ - where: { agentId: { in: bindingIds } }, + where: { + agentId: { in: bindingIds }, + ...(instanceRole === "INSTANCE_ADMIN" + ? {} + : { + issue: { + deletedAt: null, + OR: memberships.map((membership) => + issueWhereForViewer({ workspaceId: membership.workspaceId, membership }), + ), + }, + }), + }, orderBy: { startedAt: "desc" }, take: 15, select: { @@ -200,11 +243,13 @@ export const agentProfileRouter = router({ }) : []; return { - ...p, + ...publicAgentDefinition(p), bindings: visibleBindings.map((binding) => ({ ...binding, canManage: manageableWorkspaceIds.has(binding.workspace.id), })), + bindingCount: visibleBindings.length, + availability: agentProfileAvailability(p), ownedByMe: p.ownerId === ctx.session.user.id, canEdit, recentRuns, @@ -221,46 +266,58 @@ export const agentProfileRouter = router({ requestedBy: { select: { id: true, name: true, email: true, image: true } }, }, }); - return profiles.map((p) => ({ ...p, requestedAt: p.createdAt })); + return profiles.map((p) => ({ ...publicAgentDefinition(p), requestedAt: p.createdAt })); }), /** - * Member-initiated profile request. Any signed-in user creates a PENDING - * profile (`requestedById` = caller, `approvedAt` = null). Not bindable - * until an instance admin approves. Owner defaults to the requester so the - * per-owner unique key constraint applies to their own requests. + * Member-initiated personal setup. Approval follows current instance policy; + * sharing, alternate ownership, and runtime credentials are never accepted. */ request: protectedProcedure .input( - z.object({ - profileKey: profileKeySchema, - name: z.string().min(1).max(80), - description: z.string().max(2000).optional(), - avatar: z.string().max(2048).optional(), - provider: z.nativeEnum(AgentProvider).default(AgentProvider.HERMES), - runEngine: z.nativeEnum(RunEngine).nullish(), - baseCapabilities: z.array(z.string().min(1).max(40)).default([]), - templateMarkdown: z.string().nullish(), - }), + z + .object({ + profileKey: profileKeySchema, + name: z.string().min(1).max(80), + description: z.string().max(2000).optional(), + avatar: z.string().max(2048).optional(), + provider: z.nativeEnum(AgentProvider).default(AgentProvider.HERMES), + runEngine: z.nativeEnum(RunEngine).nullish(), + baseCapabilities: z.array(z.string().min(1).max(40)).default([]), + templateMarkdown: z.string().nullish(), + }) + .strict(), ) .mutation(async ({ ctx, input }) => { const ownerId = ctx.session.user.id; - const dupe = await ctx.db.agentProfile.findUnique({ - where: { ownerId_profileKey: { ownerId, profileKey: input.profileKey } }, - select: { id: true }, - }); - if (dupe) - throw new TRPCError({ - code: "CONFLICT", - message: "You already have a profile with this key.", + return profileTransaction(ctx.db, async (tx) => { + const policy = await readAgentPolicy(tx); + if (policy.personalAgentCreation === "DISABLED") { + throw new TRPCError({ + code: "FORBIDDEN", + message: "Personal agent creation is disabled by instance policy.", + }); + } + await assertActiveProfileOwner(tx, ownerId); + const profile = await tx.agentProfile.create({ + data: { + ...input, + ownerType: "PERSONAL", + ownerId, + createdById: ownerId, + requestedById: ownerId, + approvedAt: policy.personalAgentCreation === "SELF_SERVICE" ? new Date() : null, + instanceShared: false, + }, }); - return ctx.db.agentProfile.create({ - data: { - ...input, + await auditAgentProfile(tx, auditActor(ctx), "agent.profile.requested", { + profileId: profile.id, + ownerType: "PERSONAL", ownerId, - requestedById: ownerId, - approvedAt: null, - }, + policy: policy.personalAgentCreation, + approved: Boolean(profile.approvedAt), + }); + return publicAgentDefinition(profile); }); }), @@ -268,14 +325,19 @@ export const agentProfileRouter = router({ approve: instanceAdminProcedure .input(z.object({ id: z.string().cuid() })) .mutation(async ({ ctx, input }) => { - const p = await ctx.db.agentProfile.findUnique({ - where: { id: input.id }, - select: { id: true, approvedAt: true }, - }); - if (!p) throw new TRPCError({ code: "NOT_FOUND", message: "Profile not found." }); - return ctx.db.agentProfile.update({ - where: { id: input.id }, - data: { approvedAt: new Date(), disabledAt: null }, + return profileTransaction(ctx.db, async (tx) => { + const p = await tx.agentProfile.findUnique({ + where: { id: input.id }, + select: { id: true, approvedAt: true, ownerId: true }, + }); + if (!p) throw new TRPCError({ code: "NOT_FOUND", message: "Profile not found." }); + if (p.ownerId) await assertActiveProfileOwner(tx, p.ownerId); + const profile = await tx.agentProfile.update({ + where: { id: input.id }, + data: { approvedAt: new Date(), disabledAt: null }, + }); + await auditAgentProfile(tx, auditActor(ctx), "agent.profile.approved", { profileId: p.id }); + return publicAgentDefinition(profile); }); }), @@ -283,14 +345,18 @@ export const agentProfileRouter = router({ reject: instanceAdminProcedure .input(z.object({ id: z.string().cuid() })) .mutation(async ({ ctx, input }) => { - const p = await ctx.db.agentProfile.findUnique({ - where: { id: input.id }, - select: { id: true }, - }); - if (!p) throw new TRPCError({ code: "NOT_FOUND", message: "Profile not found." }); - return ctx.db.agentProfile.update({ - where: { id: input.id }, - data: { archivedAt: new Date() }, + return profileTransaction(ctx.db, async (tx) => { + const p = await tx.agentProfile.findUnique({ + where: { id: input.id }, + select: { id: true }, + }); + if (!p) throw new TRPCError({ code: "NOT_FOUND", message: "Profile not found." }); + const profile = await tx.agentProfile.update({ + where: { id: input.id }, + data: { archivedAt: new Date() }, + }); + await auditAgentProfile(tx, auditActor(ctx), "agent.profile.rejected", { profileId: p.id }); + return publicAgentDefinition(profile); }); }), @@ -311,24 +377,32 @@ export const agentProfileRouter = router({ templateMarkdown: z.string().nullish(), webhookUrl: z.string().url().max(2048).nullish(), instanceShared: z.boolean().default(false), - ownerId: z.string().cuid().optional(), + ownerType: ownerTypeSchema.default("PERSONAL"), + ownerId: z.string().cuid().nullish(), }), ) .mutation(async ({ ctx, input }) => { - const ownerId = input.ownerId ?? ctx.session.user.id; - await assertRuntimeAvailable(ctx.db, input.runtimeId, ownerId, ctx.session.user.id); - const dupe = await ctx.db.agentProfile.findUnique({ - where: { ownerId_profileKey: { ownerId, profileKey: input.profileKey } }, - select: { id: true }, - }); - if (dupe) + if (input.ownerType === "SERVICE" && input.ownerId) throw new TRPCError({ - code: "CONFLICT", - message: "That owner already has a profile with this key.", + code: "BAD_REQUEST", + message: "Service agents are instance-managed and cannot have a personal owner.", }); + const ownerId = input.ownerType === "SERVICE" ? null : (input.ownerId ?? ctx.session.user.id); const { ownerId: _drop, ...rest } = input; - // Admin-created profiles are pre-approved (requestedById stays null). - return ctx.db.agentProfile.create({ data: { ...rest, ownerId, approvedAt: new Date() } }); + return profileTransaction(ctx.db, async (tx) => { + if (ownerId) await assertActiveProfileOwner(tx, ownerId); + await assertProfileRuntimeAvailable(tx, input.runtimeId, input.ownerType, ownerId); + const profile = await tx.agentProfile.create({ + data: { ...rest, ownerId, createdById: ctx.session.user.id, approvedAt: new Date() }, + }); + await auditAgentProfile(tx, auditActor(ctx), "agent.profile.created", { + profileId: profile.id, + ownerType: input.ownerType, + ownerId, + instanceShared: input.instanceShared, + }); + return publicAgentDefinition(profile); + }); }), /** Edit a profile's definition. Owner or instance admin. */ @@ -350,16 +424,21 @@ export const agentProfileRouter = router({ }), ) .mutation(async ({ ctx, input }) => { - await assertCanEdit(ctx.db, ctx.session.user.id, input.id); - const current = await ctx.db.agentProfile.findUniqueOrThrow({ - where: { id: input.id }, - select: { ownerId: true }, - }); - if (Object.prototype.hasOwnProperty.call(input, "runtimeId")) { - await assertRuntimeAvailable(ctx.db, input.runtimeId, current.ownerId, ctx.session.user.id); - } const { id, ...data } = input; - return ctx.db.$transaction(async (tx) => { + return profileTransaction(ctx.db, async (tx) => { + await assertCanEdit(tx, ctx.session.user.id, input.id); + const current = await tx.agentProfile.findUniqueOrThrow({ + where: { id }, + select: { ownerId: true, ownerType: true }, + }); + if (Object.prototype.hasOwnProperty.call(input, "runtimeId")) { + await assertProfileRuntimeAvailable( + tx, + input.runtimeId, + current.ownerType === "SERVICE" ? "SERVICE" : "PERSONAL", + current.ownerId, + ); + } const profile = await tx.agentProfile.update({ where: { id }, data }); const bindingData: Prisma.AgentUpdateManyMutationInput = {}; const syncedFields = [ @@ -385,40 +464,143 @@ export const agentProfileRouter = router({ data: bindingData, }); } - return profile; + await auditAgentProfile(tx, auditActor(ctx), "agent.profile.updated", { + profileId: id, + changedFields: Object.keys(data).filter( + (field) => data[field as keyof typeof data] !== undefined, + ), + }); + return publicAgentDefinition(profile); }); }), + transferOwnership: instanceAdminProcedure + .input( + z + .object({ + id: z.string().cuid(), + ownerType: ownerTypeSchema, + ownerId: z.string().cuid().nullish(), + expectedOwnerType: ownerTypeSchema, + expectedOwnerId: z.string().cuid().nullable(), + expectedUpdatedAt: z.date(), + clearRuntime: z.boolean().default(false), + }) + .strict(), + ) + .mutation(({ ctx, input }) => + profileTransaction(ctx.db, async (tx) => { + if (input.ownerType === "PERSONAL" && !input.ownerId) + throw new TRPCError({ code: "BAD_REQUEST", message: "Choose a personal owner." }); + if (input.ownerType === "SERVICE" && input.ownerId) + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Service agents cannot have a personal owner.", + }); + const ownerId = input.ownerType === "SERVICE" ? null : input.ownerId!; + const current = await tx.agentProfile.findUnique({ where: { id: input.id } }); + if (!current) throw new TRPCError({ code: "NOT_FOUND", message: "Profile not found." }); + if ( + current.ownerType !== input.expectedOwnerType || + current.ownerId !== input.expectedOwnerId || + current.updatedAt.getTime() !== input.expectedUpdatedAt.getTime() + ) { + throw new TRPCError({ + code: "CONFLICT", + message: + "The agent changed since you opened it. Refresh before transferring ownership.", + }); + } + if (ownerId) await assertActiveProfileOwner(tx, ownerId); + if (!input.clearRuntime) + await assertProfileRuntimeAvailable(tx, current.runtimeId, input.ownerType, ownerId); + const changed = await tx.agentProfile.updateMany({ + where: { + id: input.id, + ownerType: input.expectedOwnerType, + ownerId: input.expectedOwnerId, + updatedAt: input.expectedUpdatedAt, + }, + data: { + ownerType: input.ownerType, + ownerId, + ...(input.clearRuntime ? { runtimeId: null } : {}), + }, + }); + if (changed.count !== 1) + throw new TRPCError({ + code: "CONFLICT", + message: "Ownership changed. Refresh before trying again.", + }); + if (input.clearRuntime) + await tx.agent.updateMany({ where: { profileId: input.id }, data: { runtimeId: null } }); + await auditAgentProfile(tx, auditActor(ctx), "agent.profile.ownership_transferred", { + profileId: input.id, + before: { ownerType: current.ownerType, ownerId: current.ownerId }, + after: { ownerType: input.ownerType, ownerId }, + clearedRuntime: input.clearRuntime, + }); + return publicAgentDefinition( + await tx.agentProfile.findUniqueOrThrow({ where: { id: input.id } }), + ); + }), + ), + /** Instance admin: mark a profile available to every workspace's catalog. */ setInstanceShared: instanceAdminProcedure .input(z.object({ id: z.string().cuid(), instanceShared: z.boolean() })) - .mutation(async ({ ctx, input }) => - ctx.db.agentProfile.update({ - where: { id: input.id }, - data: { instanceShared: input.instanceShared }, + .mutation(({ ctx, input }) => + profileTransaction(ctx.db, async (tx) => { + const before = await tx.agentProfile.findUniqueOrThrow({ + where: { id: input.id }, + select: { instanceShared: true }, + }); + const profile = await tx.agentProfile.update({ + where: { id: input.id }, + data: { instanceShared: input.instanceShared }, + }); + await auditAgentProfile(tx, auditActor(ctx), "agent.profile.sharing_updated", { + profileId: input.id, + before: before.instanceShared, + after: input.instanceShared, + }); + return publicAgentDefinition(profile); }), ), /** Instance admin: force-disable (or re-enable) a profile and all its bindings. */ setDisabled: instanceAdminProcedure .input(z.object({ id: z.string().cuid(), disabled: z.boolean() })) - .mutation(async ({ ctx, input }) => - ctx.db.agentProfile.update({ - where: { id: input.id }, - data: { disabledAt: input.disabled ? new Date() : null }, + .mutation(({ ctx, input }) => + profileTransaction(ctx.db, async (tx) => { + const profile = await tx.agentProfile.update({ + where: { id: input.id }, + data: { disabledAt: input.disabled ? new Date() : null }, + }); + await auditAgentProfile(tx, auditActor(ctx), "agent.profile.disabled_updated", { + profileId: input.id, + disabled: input.disabled, + }); + return publicAgentDefinition(profile); }), ), /** Archive a profile (owner or instance admin). Bindings survive via SetNull. */ archive: protectedProcedure .input(z.object({ id: z.string().cuid() })) - .mutation(async ({ ctx, input }) => { - await assertCanEdit(ctx.db, ctx.session.user.id, input.id); - return ctx.db.agentProfile.update({ - where: { id: input.id }, - data: { archivedAt: new Date() }, - }); - }), + .mutation(({ ctx, input }) => + profileTransaction(ctx.db, async (tx) => { + await assertCanEdit(tx, ctx.session.user.id, input.id); + const profile = await tx.agentProfile.update({ + where: { id: input.id }, + data: { archivedAt: new Date() }, + }); + await auditAgentProfile(tx, auditActor(ctx), "agent.profile.archived", { + profileId: input.id, + }); + return publicAgentDefinition(profile); + }), + ), /** * Instance admin: smart remove — what the admin UI's "Remove" action calls. @@ -429,21 +611,30 @@ export const agentProfileRouter = router({ */ remove: instanceAdminProcedure .input(z.object({ id: z.string().cuid() })) - .mutation(async ({ ctx, input }) => { - const profile = await ctx.db.agentProfile.findUnique({ - where: { id: input.id }, - select: { id: true, name: true, _count: { select: { bindings: true } } }, - }); - if (!profile) throw new TRPCError({ code: "NOT_FOUND", message: "Profile not found." }); - const boundAgents = profile._count.bindings; - if (boundAgents > 0) { - await ctx.db.agentProfile.update({ - where: { id: profile.id }, - data: { archivedAt: new Date() }, + .mutation(({ ctx, input }) => + profileTransaction(ctx.db, async (tx) => { + const profile = await tx.agentProfile.findUnique({ + where: { id: input.id }, + select: { id: true, name: true, _count: { select: { bindings: true } } }, }); - return { action: "archived" as const, name: profile.name, boundAgents }; - } - await ctx.db.agentProfile.delete({ where: { id: profile.id } }); - return { action: "deleted" as const, name: profile.name, boundAgents: 0 }; - }), + if (!profile) throw new TRPCError({ code: "NOT_FOUND", message: "Profile not found." }); + const boundAgents = profile._count.bindings; + if (boundAgents > 0) { + await tx.agentProfile.update({ + where: { id: profile.id }, + data: { archivedAt: new Date() }, + }); + await auditAgentProfile(tx, auditActor(ctx), "agent.profile.archived", { + profileId: profile.id, + boundAgents, + }); + return { action: "archived" as const, name: profile.name, boundAgents }; + } + await tx.agentProfile.delete({ where: { id: profile.id } }); + await auditAgentProfile(tx, auditActor(ctx), "agent.profile.deleted", { + profileId: profile.id, + }); + return { action: "deleted" as const, name: profile.name, boundAgents: 0 }; + }), + ), }); diff --git a/src/server/routers/agent.ts b/src/server/routers/agent.ts index c497da5f..b85edf28 100644 --- a/src/server/routers/agent.ts +++ b/src/server/routers/agent.ts @@ -12,7 +12,6 @@ import { RuntimeDiagnosticKind, RuntimeDiagnosticTrigger, type Prisma, - type PrismaClient, } from "@prisma/client"; import { router, workspaceProcedure, adminProcedure } from "@/server/trpc"; import { resolveEngagementMode } from "@/server/services/engagement-mode"; @@ -102,19 +101,20 @@ function redactWebhookSecret(value: * passed `undefined`). */ async function resolveRuntimeId( - db: PrismaClient, + db: Prisma.TransactionClient, workspaceId: string, raw: string | null | undefined, excludeAgentId?: string, ): Promise { const id = raw || null; // "" and null both clear the attachment if (!id) return null; + await db.$queryRaw`SELECT "id" FROM "Runtime" WHERE "id" = ${id} FOR UPDATE`; const rt = await db.runtime.findFirst({ - where: { id, workspaceId }, + where: { id, workspaceId, archivedAt: null, disabledAt: null, ownerType: { not: "UNASSIGNED" } }, select: { id: true, adapterKey: true }, }); if (!rt) - throw new TRPCError({ code: "BAD_REQUEST", message: "Runtime not found in this workspace." }); + throw new TRPCError({ code: "BAD_REQUEST", message: "Choose an available, owned runtime in this workspace. Adopt unassigned runtimes first; archived, disabled, or moved runtimes cannot be attached." }); const adapter = getRuntimeAdapter(rt.adapterKey); if (adapter && !adapter.multiAgent) { const attached = await db.agent.count({ @@ -699,8 +699,8 @@ export const agentRouter = router({ if (existing) { throw new TRPCError({ code: "CONFLICT", message: "profileKey already used." }); } - const runtimeId = await resolveRuntimeId(ctx.db, ctx.workspaceId, input.runtimeId); return ctx.db.$transaction(async (tx) => { + const runtimeId = await resolveRuntimeId(tx, ctx.workspaceId, input.runtimeId); const agent = await tx.agent.create({ data: { workspaceId: ctx.workspaceId, @@ -769,12 +769,12 @@ export const agentRouter = router({ const before = await ctx.db.agent.findFirstOrThrow({ where: { id, workspaceId: ctx.workspaceId }, }); - // Only touch the attachment when the caller sent the field at all. - const runtimePatch = - runtimeId === undefined - ? {} - : { runtimeId: await resolveRuntimeId(ctx.db, ctx.workspaceId, runtimeId, id) }; return ctx.db.$transaction(async (tx) => { + // Resolve under the same runtime lock/transaction as the write so + // lifecycle transfer cannot invalidate the old-home authorization. + const runtimePatch = runtimeId === undefined ? {} : { + runtimeId: await resolveRuntimeId(tx, ctx.workspaceId, runtimeId, id), + }; const after = await tx.agent.update({ where: { id }, data: { ...patch, ...runtimePatch } }); await recordChange(tx, { workspaceId: ctx.workspaceId, diff --git a/src/server/routers/global.ts b/src/server/routers/global.ts index 9e026527..5d8745c6 100644 --- a/src/server/routers/global.ts +++ b/src/server/routers/global.ts @@ -1,4 +1,6 @@ import type { PrismaClient } from "@prisma/client"; +import { z } from "zod"; +import { TRPCError } from "@trpc/server"; import { router, globalProcedure } from "@/server/trpc"; import { deriveRuntimeHealthStatus } from "@/server/services/runtime-status"; import { runtimeConfigStatus } from "@/server/services/runtime-config"; @@ -10,6 +12,14 @@ import { mapTimelineRow, } from "@/server/routers/event"; import { issueWhereForViewer } from "@/server/services/project-access"; +import { + canViewAgentWorkspace, + agentProfileAvailability, +} from "@/server/services/agent-profile-access"; +import { + assertActiveProfileOwner, + profileRuntimeOptionsWhere, +} from "@/server/services/agent-profile-policy"; /** * Cross-workspace, read-only aggregations for the global "concourse" @@ -27,6 +37,16 @@ async function memberWorkspaceAccess(db: PrismaClient, userId: string) { }); } +async function agentWorkspaceVisibility(db: PrismaClient, userId: string) { + const [memberships, user] = await Promise.all([ + memberWorkspaceAccess(db, userId), + db.user.findUnique({ where: { id: userId }, select: { instanceRole: true } }), + ]); + const membershipIds = new Set(memberships.map((membership) => membership.workspaceId)); + return (workspaceId: string) => + canViewAgentWorkspace(workspaceId, membershipIds, user?.instanceRole === "INSTANCE_ADMIN"); +} + export const globalRouter = router({ /** * The caller's workspaces with per-workspace mini-stats for the @@ -158,6 +178,7 @@ export const globalRouter = router({ * owned by others are included read-only. */ agents: globalProcedure.query(async ({ ctx }) => { + const canViewWorkspace = await agentWorkspaceVisibility(ctx.db, ctx.session.user.id); const profiles = await ctx.db.agentProfile.findMany({ where: { archivedAt: null, OR: [{ ownerId: ctx.session.user.id }, { instanceShared: true }] }, orderBy: { createdAt: "asc" }, @@ -172,7 +193,13 @@ export const globalRouter = router({ baseCapabilities: true, instanceShared: true, disabledAt: true, + archivedAt: true, + requestedById: true, + approvedAt: true, ownerId: true, + ownerType: true, + createdById: true, + owner: { select: { id: true, name: true, image: true } }, runtime: { select: { id: true, name: true, kind: true } }, bindings: { where: { archivedAt: null }, @@ -186,17 +213,71 @@ export const globalRouter = router({ }, }, }); - return profiles.map((p) => ({ - ...p, - ownedByMe: p.ownerId === ctx.session.user.id, - bindingCount: p.bindings.length, - // ONLINE if any binding is currently online/busy. - online: p.bindings.some((b) => b.status === "ONLINE" || b.status === "BUSY"), - })); + return profiles.map((p) => { + const bindings = p.bindings.filter((binding) => canViewWorkspace(binding.workspace.id)); + return { + ...p, + bindings, + availability: agentProfileAvailability(p), + ownedByMe: p.ownerId === ctx.session.user.id, + bindingCount: bindings.length, + online: bindings.some((b) => b.status === "ONLINE" || b.status === "BUSY"), + }; + }); }), + /** Safe setup choices, evaluated against the intended profile owner. */ + runtimeOptions: globalProcedure + .input( + z + .object({ + ownerType: z.enum(["PERSONAL", "SERVICE"]).default("PERSONAL"), + ownerId: z.string().cuid().optional(), + }) + .default({ ownerType: "PERSONAL" }), + ) + .query(async ({ ctx, input }) => { + const user = await ctx.db.user.findUnique({ + where: { id: ctx.session.user.id }, + select: { instanceRole: true }, + }); + if ( + (input.ownerType === "SERVICE" || + (input.ownerId && input.ownerId !== ctx.session.user.id)) && + user?.instanceRole !== "INSTANCE_ADMIN" + ) { + throw new TRPCError({ + code: "FORBIDDEN", + message: + "Instance administration is required to configure another owner or a service agent.", + }); + } + if (input.ownerType === "SERVICE" && input.ownerId) + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Service agents cannot have a personal owner.", + }); + const ownerId = input.ownerType === "SERVICE" ? null : (input.ownerId ?? ctx.session.user.id); + if (ownerId) await assertActiveProfileOwner(ctx.db, ownerId); + return ctx.db.runtime.findMany({ + where: profileRuntimeOptionsWhere(input.ownerType, ownerId), + orderBy: [{ name: "asc" }, { id: "asc" }], + select: { + id: true, + name: true, + kind: true, + adapterKey: true, + ownerType: true, + ownerId: true, + instanceShared: true, + owner: { select: { id: true, name: true, image: true } }, + }, + }); + }), + /** The caller's runtimes (global, user-owned) with bound agents + workspaces in use. */ runtimes: globalProcedure.query(async ({ ctx }) => { + const canViewWorkspace = await agentWorkspaceVisibility(ctx.db, ctx.session.user.id); const runtimes = await ctx.db.runtime.findMany({ where: { ownerId: ctx.session.user.id, archivedAt: null }, orderBy: { createdAt: "asc" }, @@ -205,6 +286,11 @@ export const globalRouter = router({ name: true, kind: true, adapterKey: true, + ownerType: true, + ownerId: true, + instanceShared: true, + createdById: true, + owner: { select: { id: true, name: true, image: true } }, config: true, endpoint: true, providersAvailable: true, @@ -223,10 +309,7 @@ export const globalRouter = router({ disabledAt: true, archivedAt: true, createdAt: true, - // The runtime's own home workspace (where it was registered). Always - // present, unlike `workspacesInUse` (derived from bound agents), so the - // settings UI can always link to the per-runtime detail page — even for - // a freshly registered daemon with no agents yet. + // Home-workspace identity is returned only when the caller can view it. workspace: { select: { id: true, slug: true, name: true, key: true } }, agents: { where: { archivedAt: null }, @@ -240,14 +323,20 @@ export const globalRouter = router({ }, }); return runtimes.map((r) => { + const agents = r.agents.filter((agent) => canViewWorkspace(agent.workspace.id)); const workspaces = new Map(); - for (const a of r.agents) workspaces.set(a.workspace.id, a.workspace); + for (const a of agents) workspaces.set(a.workspace.id, a.workspace); const health = deriveRuntimeHealthStatus(r); return { id: r.id, name: r.name, kind: r.kind, adapterKey: r.adapterKey, + ownerType: r.ownerType, + ownerId: r.ownerId, + instanceShared: r.instanceShared, + createdById: r.createdById, + owner: r.owner, config: r.config, endpoint: r.endpoint, providersAvailable: r.providersAvailable, @@ -266,8 +355,8 @@ export const globalRouter = router({ registeredAt: r.createdAt, online: health.kind === "online", disabled: !!r.disabledAt, - boundAgents: r.agents.map((a) => ({ id: a.id, profileKey: a.profileKey, name: a.name })), - homeWorkspace: r.workspace, + boundAgents: agents.map((a) => ({ id: a.id, profileKey: a.profileKey, name: a.name })), + homeWorkspace: canViewWorkspace(r.workspace.id) ? r.workspace : null, workspacesInUse: [...workspaces.values()], }; }); diff --git a/src/server/routers/instance-admin.ts b/src/server/routers/instance-admin.ts index 74bd7a17..9225a41a 100644 --- a/src/server/routers/instance-admin.ts +++ b/src/server/routers/instance-admin.ts @@ -388,11 +388,15 @@ export const instanceAdminRouter = router({ /** Every runtime across the instance (not just the caller's). */ runtimes: instanceAdminProcedure.query(async ({ ctx }) => { const runtimes = await ctx.db.runtime.findMany({ - where: { archivedAt: null }, orderBy: { createdAt: "asc" }, select: { id: true, name: true, + ownerType: true, + ownerId: true, + updatedAt: true, + workspace: { select: { id: true, name: true, slug: true, key: true } }, + createdBy: { select: { id: true, name: true } }, kind: true, adapterKey: true, heartbeatAt: true, @@ -421,6 +425,7 @@ export const instanceAdminRouter = router({ const health = deriveRuntimeHealthStatus(r); return { ...r, + homeWorkspace: r.workspace, health, selfTest: summarizeRuntimeSelfTest(r), runtimeInfoSummary: summarizeRuntimeInfo(r), diff --git a/src/server/routers/runtime.ts b/src/server/routers/runtime.ts index 12af4910..dc3ee1d8 100644 --- a/src/server/routers/runtime.ts +++ b/src/server/routers/runtime.ts @@ -8,7 +8,14 @@ import { type Runtime, type PrismaClient, } from "@prisma/client"; -import { router, workspaceProcedure, adminProcedure } from "@/server/trpc"; +import { router, workspaceProcedure, adminProcedure, instanceAdminProcedure } from "@/server/trpc"; +import { + runtimeLifecycleImpact, + transferRuntime, + removeRuntime, + lockManagedRuntime, + prepareRuntimeLifecycle, +} from "@/server/services/runtime-lifecycle"; import { encryptSecret } from "@/server/crypto"; import { getRuntimeAdapter, managedAdapters, PLANNED_ADAPTERS } from "@/server/runtimes/adapters"; import { recordRuntimeHeartbeatPresence } from "@/server/services/heartbeat"; @@ -130,6 +137,14 @@ function withRuntimeHealth & RuntimeForHealth & Runti */ const runtimeId = z.string().min(1).max(40); +const lifecycleExpected = { + id: runtimeId, + expectedWorkspaceId: z.string().cuid(), + expectedOwnerType: z.enum(["PERSONAL", "SERVICE", "UNASSIGNED"]), + expectedOwnerId: z.string().cuid().nullable(), + expectedUpdatedAt: z.date(), + confirmName: z.string().min(1).max(120), +}; /** Confirm a runtime belongs to the caller's workspace, or 404. */ async function assertRuntimeInWorkspace( @@ -173,7 +188,72 @@ const baseFields = { providersAvailable: z.array(z.nativeEnum(AgentProvider)).max(16).default([]), }; +async function queueScopedDiagnostic( + db: PrismaClient, + workspaceId: string, + id: string, + userId: string, + kind: RuntimeDiagnosticKind, +) { + return db.$transaction(async (tx) => { + await tx.$queryRaw`SELECT "id" FROM "Runtime" WHERE "id" = ${id} FOR UPDATE`; + const runtime = await tx.runtime.findFirst({ + where: { id, workspaceId }, + select: { id: true, archivedAt: true, disabledAt: true }, + }); + if (!runtime) + throw new TRPCError({ + code: "NOT_FOUND", + message: "Runtime no longer belongs to this workspace.", + }); + if (runtime.archivedAt || runtime.disabledAt) + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Restore and enable the runtime before testing it.", + }); + return requestRuntimeDiagnostic( + { + workspaceId, + runtimeId: id, + kind, + trigger: RuntimeDiagnosticTrigger.MANUAL_RUNTIME, + requestedById: userId, + }, + tx, + ); + }); +} + export const runtimeRouter = router({ + lifecycleImpact: instanceAdminProcedure + .input(z.object({ id: runtimeId, targetWorkspaceId: z.string().cuid().optional() })) + .query(({ ctx, input }) => runtimeLifecycleImpact(ctx.db, input.id, input.targetWorkspaceId)), + + lifecycleTransfer: instanceAdminProcedure + .input( + z.object({ + ...lifecycleExpected, + targetWorkspaceId: z.string().cuid(), + ownerType: z.enum(["PERSONAL", "SERVICE"]), + ownerId: z.string().cuid().nullable(), + }), + ) + .mutation(({ ctx, input }) => + ctx.db.$transaction((tx) => transferRuntime(tx, input, ctx.session.user.id)), + ), + + lifecycleRemove: instanceAdminProcedure + .input(z.object(lifecycleExpected)) + .mutation(({ ctx, input }) => + ctx.db.$transaction((tx) => removeRuntime(tx, input, ctx.session.user.id)), + ), + + lifecyclePrepare: instanceAdminProcedure + .input(z.object(lifecycleExpected)) + .mutation(({ ctx, input }) => + ctx.db.$transaction((tx) => prepareRuntimeLifecycle(tx, input, ctx.session.user.id)), + ), + list: workspaceProcedure .input( z.object({ includeArchived: z.boolean().default(false) }).default({ includeArchived: false }), @@ -217,7 +297,14 @@ export const runtimeRouter = router({ }, }); if (!runtime) throw new TRPCError({ code: "NOT_FOUND" }); - return withRuntimeHealth(runtime); + const actor = await ctx.db.user.findUnique({ + where: { id: ctx.session.user.id }, + select: { instanceRole: true }, + }); + return { + ...withRuntimeHealth(runtime), + canManageLifecycle: actor?.instanceRole === "INSTANCE_ADMIN", + }; }), register: workspaceProcedure.input(z.object(baseFields)).mutation(async ({ ctx, input }) => { @@ -235,38 +322,49 @@ export const runtimeRouter = router({ // cached runtime id (fresh clone / config wipe), so a plain create would // stack a duplicate row for the same host every re-register. Reuse a // non-archived match instead. - const existing = await ctx.db.runtime.findFirst({ - where: { - workspaceId: ctx.workspaceId, - name: input.name, - kind: input.kind, - archivedAt: null, - }, - select: { id: true }, - }); - if (existing) { - return ctx.db.runtime.update({ - where: { id: existing.id }, - data: { - endpoint: input.endpoint || null, - providersAvailable: input.providersAvailable, - ...liveTimes, + return ctx.db.$transaction(async (tx) => { + // Serialize same-home registrations and deletion, then lock any match. + await tx.$queryRaw`SELECT "id" FROM "Workspace" WHERE "id" = ${ctx.workspaceId} FOR UPDATE`; + const existing = await tx.runtime.findFirst({ + where: { + workspaceId: ctx.workspaceId, + name: input.name, + kind: input.kind, + archivedAt: null, }, + select: { id: true }, }); - } - // For LOCAL_DAEMON the daemon registers itself and owns the row; - // for REMOTE_HTTP an admin typically registers it but the same - // attribution holds. ownerId is set from the calling user. - return ctx.db.runtime.create({ - data: { - workspaceId: ctx.workspaceId, - name: input.name, - kind: input.kind, - endpoint: input.endpoint || null, - providersAvailable: input.providersAvailable, - ownerId: ctx.session.user.id, - ...liveTimes, - }, + if (existing) { + await lockManagedRuntime(tx, existing.id, ctx.workspaceId, ctx.session.user.id); + return withRuntimeHealth( + await tx.runtime.update({ + where: { id: existing.id }, + data: { + endpoint: input.endpoint || null, + providersAvailable: input.providersAvailable, + ...liveTimes, + }, + }), + ); + } + // For LOCAL_DAEMON the daemon registers itself and owns the row; + // for REMOTE_HTTP an admin typically registers it but the same + // attribution holds. ownerId is set from the calling user. + return withRuntimeHealth( + await tx.runtime.create({ + data: { + workspaceId: ctx.workspaceId, + name: input.name, + kind: input.kind, + endpoint: input.endpoint || null, + providersAvailable: input.providersAvailable, + ownerId: ctx.session.user.id, + ownerType: "PERSONAL", + createdById: ctx.session.user.id, + ...liveTimes, + }, + }), + ); }); }), @@ -286,7 +384,7 @@ export const runtimeRouter = router({ } const now = new Date(); const updated = await ctx.db.runtime.update({ - where: { id: row.id }, + where: { id: row.id, workspaceId: ctx.workspaceId, archivedAt: null, disabledAt: null }, data: { heartbeatAt: now }, select: { id: true, heartbeatAt: true }, }); @@ -305,14 +403,14 @@ export const runtimeRouter = router({ archive: workspaceProcedure .input(z.object({ id: runtimeId })) .mutation(async ({ ctx, input }) => { - const row = await ctx.db.runtime.findFirst({ - where: { id: input.id, workspaceId: ctx.workspaceId }, - select: { id: true }, - }); - if (!row) throw new TRPCError({ code: "NOT_FOUND" }); - return ctx.db.runtime.update({ - where: { id: row.id }, - data: { archivedAt: new Date() }, + return ctx.db.$transaction(async (tx) => { + const row = await lockManagedRuntime(tx, input.id, ctx.workspaceId, ctx.session.user.id); + return withRuntimeHealth( + await tx.runtime.update({ + where: { id: row.id }, + data: { archivedAt: new Date() }, + }), + ); }); }), @@ -324,19 +422,19 @@ export const runtimeRouter = router({ unarchive: workspaceProcedure .input(z.object({ id: runtimeId })) .mutation(async ({ ctx, input }) => { - const row = await ctx.db.runtime.findFirst({ - where: { id: input.id, workspaceId: ctx.workspaceId }, - select: { id: true, archivedAt: true }, - }); - if (!row) throw new TRPCError({ code: "NOT_FOUND" }); - if (!row.archivedAt) { - // Already active — nothing to do, but return the row so the - // caller can refresh its cached copy uniformly. - return ctx.db.runtime.findUniqueOrThrow({ where: { id: row.id } }); - } - return ctx.db.runtime.update({ - where: { id: row.id }, - data: { archivedAt: null }, + return ctx.db.$transaction(async (tx) => { + const row = await lockManagedRuntime(tx, input.id, ctx.workspaceId, ctx.session.user.id); + if (!row.archivedAt) { + // Already active — nothing to do, but return the row so the + // caller can refresh its cached copy uniformly. + return withRuntimeHealth(row); + } + return withRuntimeHealth( + await tx.runtime.update({ + where: { id: row.id }, + data: { archivedAt: null }, + }), + ); }); }), @@ -384,6 +482,8 @@ export const runtimeRouter = router({ providersAvailable: input.providersAvailable ?? adapter.providers, config, ownerId: ctx.session.user.id, + ownerType: "PERSONAL", + createdById: ctx.session.user.id, }, }); return withRuntimeHealth(row); @@ -404,53 +504,41 @@ export const runtimeRouter = router({ ) .mutation(async ({ ctx, input }) => { const { id, secret, endpoint, adapterKey, config, ...rest } = input; - const row = await ctx.db.runtime.findFirst({ - where: { id, workspaceId: ctx.workspaceId }, - select: { id: true, adapterKey: true }, - }); - if (!row) throw new TRPCError({ code: "NOT_FOUND" }); - if (adapterKey !== undefined && !getRuntimeAdapter(adapterKey)) { - throw new TRPCError({ code: "BAD_REQUEST", message: "Unknown adapter key." }); - } - if (endpoint) assertEndpointTransport(endpoint); - // Validate against the effective adapter (incoming override or stored). - const effectiveAdapter = adapterKey ?? row.adapterKey; - const validatedConfig = - config !== undefined ? validateRuntimeConfig(effectiveAdapter, config) : undefined; - const updated = await ctx.db.runtime.update({ - where: { id: row.id }, - data: { - ...rest, - ...(adapterKey !== undefined ? { adapterKey } : {}), - ...(endpoint !== undefined ? { endpoint: endpoint || null } : {}), - // "" → keep; null → clear; non-empty → set. - ...(secret === undefined ? {} : { secret: secret || null }), - ...(validatedConfig !== undefined ? { config: validatedConfig } : {}), - }, + return ctx.db.$transaction(async (tx) => { + const row = await lockManagedRuntime(tx, id, ctx.workspaceId, ctx.session.user.id); + if (adapterKey !== undefined && !getRuntimeAdapter(adapterKey)) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Unknown adapter key." }); + } + if (endpoint) assertEndpointTransport(endpoint); + // Validate against the effective adapter (incoming override or stored). + const effectiveAdapter = adapterKey ?? row.adapterKey; + const validatedConfig = + config !== undefined ? validateRuntimeConfig(effectiveAdapter, config) : undefined; + const updated = await tx.runtime.update({ + where: { id: row.id }, + data: { + ...rest, + ...(adapterKey !== undefined ? { adapterKey } : {}), + ...(endpoint !== undefined ? { endpoint: endpoint || null } : {}), + // "" → keep; null → clear; non-empty → set. + ...(secret === undefined ? {} : { secret: secret || null }), + ...(validatedConfig !== undefined ? { config: validatedConfig } : {}), + }, + }); + return withRuntimeHealth(updated); }); - return withRuntimeHealth(updated); }), verifyConnection: workspaceProcedure .input(z.object({ id: runtimeId })) .mutation(async ({ ctx, input }) => { - const runtime = await ctx.db.runtime.findFirst({ - where: { id: input.id, workspaceId: ctx.workspaceId }, - }); - if (!runtime) throw new TRPCError({ code: "NOT_FOUND" }); - if (runtime.archivedAt) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Runtime is archived; restore it before testing.", - }); - } - const requestId = await requestRuntimeDiagnostic({ - workspaceId: ctx.workspaceId, - runtimeId: runtime.id, - kind: RuntimeDiagnosticKind.PROBE, - trigger: RuntimeDiagnosticTrigger.MANUAL_RUNTIME, - requestedById: ctx.session.user.id, - }); + const requestId = await queueScopedDiagnostic( + ctx.db, + ctx.workspaceId, + input.id, + ctx.session.user.id, + RuntimeDiagnosticKind.PROBE, + ); let attempt; try { attempt = await waitForRuntimeDiagnostic(requestId, { timeoutMs: 15_000 }); @@ -460,7 +548,7 @@ export const runtimeRouter = router({ message: error instanceof Error ? error.message : "Runtime diagnostic timed out.", }); } - const updated = await ctx.db.runtime.findUniqueOrThrow({ where: { id: runtime.id } }); + const updated = await ctx.db.runtime.findUniqueOrThrow({ where: { id: input.id } }); const health = deriveRuntimeHealthStatus(updated); return { runtime: withRuntimeHealth(updated), @@ -472,24 +560,13 @@ export const runtimeRouter = router({ runSelfTest: workspaceProcedure .input(z.object({ id: runtimeId })) .mutation(async ({ ctx, input }) => { - const runtime = await ctx.db.runtime.findFirst({ - where: { id: input.id, workspaceId: ctx.workspaceId }, - }); - if (!runtime) throw new TRPCError({ code: "NOT_FOUND" }); - if (runtime.archivedAt) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Runtime is archived; restore it before running a self-test.", - }); - } - - const requestId = await requestRuntimeDiagnostic({ - workspaceId: ctx.workspaceId, - runtimeId: runtime.id, - kind: RuntimeDiagnosticKind.SELF_TEST, - trigger: RuntimeDiagnosticTrigger.MANUAL_RUNTIME, - requestedById: ctx.session.user.id, - }); + const requestId = await queueScopedDiagnostic( + ctx.db, + ctx.workspaceId, + input.id, + ctx.session.user.id, + RuntimeDiagnosticKind.SELF_TEST, + ); let attempt; try { attempt = await waitForRuntimeDiagnostic(requestId, { timeoutMs: 55_000 }); @@ -499,7 +576,7 @@ export const runtimeRouter = router({ message: error instanceof Error ? error.message : "Runtime self-test timed out.", }); } - const updated = await ctx.db.runtime.findUniqueOrThrow({ where: { id: runtime.id } }); + const updated = await ctx.db.runtime.findUniqueOrThrow({ where: { id: input.id } }); const result = diagnosticResult(attempt); return { runtime: withRuntimeHealth(updated), @@ -517,16 +594,14 @@ export const runtimeRouter = router({ setEnabled: workspaceProcedure .input(z.object({ id: runtimeId, enabled: z.boolean() })) .mutation(async ({ ctx, input }) => { - const row = await ctx.db.runtime.findFirst({ - where: { id: input.id, workspaceId: ctx.workspaceId }, - select: { id: true }, - }); - if (!row) throw new TRPCError({ code: "NOT_FOUND" }); - const updated = await ctx.db.runtime.update({ - where: { id: row.id }, - data: { disabledAt: input.enabled ? null : new Date() }, + return ctx.db.$transaction(async (tx) => { + const row = await lockManagedRuntime(tx, input.id, ctx.workspaceId, ctx.session.user.id); + const updated = await tx.runtime.update({ + where: { id: row.id }, + data: { disabledAt: input.enabled ? null : new Date() }, + }); + return withRuntimeHealth(updated); }); - return withRuntimeHealth(updated); }), /** Adapter catalog for the UI (managed runtimes are creatable). */ @@ -586,30 +661,39 @@ export const runtimeRouter = router({ }), ) .mutation(async ({ ctx, input }) => { - const rt = await assertRuntimeInWorkspace(ctx.db, ctx.workspaceId, input.runtimeId); - const valueEnc = encryptSecret(input.value); - return ctx.db.runtimeSecret.upsert({ - where: { runtimeId_key: { runtimeId: rt.id, key: input.key } }, - create: { - runtimeId: rt.id, - workspaceId: ctx.workspaceId, - key: input.key, - valueEnc, - description: input.description ?? null, - }, - update: { valueEnc, description: input.description ?? null }, - select: { id: true, key: true, description: true, createdAt: true, updatedAt: true }, + return ctx.db.$transaction(async (tx) => { + const rt = await lockManagedRuntime( + tx, + input.runtimeId, + ctx.workspaceId, + ctx.session.user.id, + ); + const valueEnc = encryptSecret(input.value); + return tx.runtimeSecret.upsert({ + where: { runtimeId_key: { runtimeId: rt.id, key: input.key } }, + create: { + runtimeId: rt.id, + workspaceId: ctx.workspaceId, + key: input.key, + valueEnc, + description: input.description ?? null, + }, + update: { valueEnc, description: input.description ?? null }, + select: { id: true, key: true, description: true, createdAt: true, updatedAt: true }, + }); }); }), deleteSecret: adminProcedure .input(z.object({ runtimeId, key: envVarKey })) .mutation(async ({ ctx, input }) => { - await assertRuntimeInWorkspace(ctx.db, ctx.workspaceId, input.runtimeId); - await ctx.db.runtimeSecret.deleteMany({ - where: { runtimeId: input.runtimeId, key: input.key }, + return ctx.db.$transaction(async (tx) => { + await lockManagedRuntime(tx, input.runtimeId, ctx.workspaceId, ctx.session.user.id); + await tx.runtimeSecret.deleteMany({ + where: { runtimeId: input.runtimeId, key: input.key }, + }); + return { ok: true }; }); - return { ok: true }; }), // ── Runtime repos ──────────────────────────────────────────────────── @@ -635,29 +719,45 @@ export const runtimeRouter = router({ }), ) .mutation(async ({ ctx, input }) => { - const rt = await assertRuntimeInWorkspace(ctx.db, ctx.workspaceId, input.runtimeId); - return ctx.db.runtimeRepo.upsert({ - where: { runtimeId_path: { runtimeId: rt.id, path: input.path } }, - create: { - runtimeId: rt.id, - workspaceId: ctx.workspaceId, - url: input.url, - branch: input.branch || null, - path: input.path, - }, - update: { url: input.url, branch: input.branch || null }, - select: { id: true, url: true, branch: true, path: true, createdAt: true, updatedAt: true }, + return ctx.db.$transaction(async (tx) => { + const rt = await lockManagedRuntime( + tx, + input.runtimeId, + ctx.workspaceId, + ctx.session.user.id, + ); + return tx.runtimeRepo.upsert({ + where: { runtimeId_path: { runtimeId: rt.id, path: input.path } }, + create: { + runtimeId: rt.id, + workspaceId: ctx.workspaceId, + url: input.url, + branch: input.branch || null, + path: input.path, + }, + update: { url: input.url, branch: input.branch || null }, + select: { + id: true, + url: true, + branch: true, + path: true, + createdAt: true, + updatedAt: true, + }, + }); }); }), deleteRepo: adminProcedure .input(z.object({ runtimeId, id: z.string().cuid() })) .mutation(async ({ ctx, input }) => { - await assertRuntimeInWorkspace(ctx.db, ctx.workspaceId, input.runtimeId); - await ctx.db.runtimeRepo.deleteMany({ - where: { id: input.id, runtimeId: input.runtimeId }, + return ctx.db.$transaction(async (tx) => { + await lockManagedRuntime(tx, input.runtimeId, ctx.workspaceId, ctx.session.user.id); + await tx.runtimeRepo.deleteMany({ + where: { id: input.id, runtimeId: input.runtimeId }, + }); + return { ok: true }; }); - return { ok: true }; }), // ── Runtime GitHub App ─────────────────────────────────────────────── @@ -697,24 +797,31 @@ export const runtimeRouter = router({ linkGithubApp: adminProcedure .input(z.object({ runtimeId, githubAppId: z.string().min(1).max(40).nullable() })) .mutation(async ({ ctx, input }) => { - const rt = await assertRuntimeInWorkspace(ctx.db, ctx.workspaceId, input.runtimeId); - if (input.githubAppId) { - // Must be an app in the same workspace. - const app = await ctx.db.githubApp.findFirst({ - where: { id: input.githubAppId, workspaceId: ctx.workspaceId }, - select: { id: true }, - }); - if (!app) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "GitHub App not found in this workspace.", + return ctx.db.$transaction(async (tx) => { + const rt = await lockManagedRuntime( + tx, + input.runtimeId, + ctx.workspaceId, + ctx.session.user.id, + ); + if (input.githubAppId) { + // Must be an app in the same workspace. + const app = await tx.githubApp.findFirst({ + where: { id: input.githubAppId, workspaceId: ctx.workspaceId }, + select: { id: true }, }); + if (!app) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "GitHub App not found in this workspace.", + }); + } } - } - await ctx.db.runtime.update({ - where: { id: rt.id }, - data: { githubAppId: input.githubAppId }, + await tx.runtime.update({ + where: { id: rt.id }, + data: { githubAppId: input.githubAppId }, + }); + return { ok: true }; }); - return { ok: true }; }), }); diff --git a/src/server/routers/workspace.ts b/src/server/routers/workspace.ts index 6753d1d2..f8762c61 100644 --- a/src/server/routers/workspace.ts +++ b/src/server/routers/workspace.ts @@ -20,6 +20,7 @@ import { import { router, protectedProcedure, workspaceProcedure, adminProcedure } from "@/server/trpc"; import { rateLimit } from "@/server/rate-limit"; import { recordChange } from "@/server/audit"; +import { deleteWorkspacePreservingRuntimes } from "@/server/services/workspace-deletion"; import { deliverWorkspaceInvitation, expireWorkspaceInvitations, @@ -645,25 +646,19 @@ export const workspaceRouter = router({ if (ctx.membership.role !== Role.OWNER) { throw new TRPCError({ code: "FORBIDDEN", message: "Only owners can delete a workspace." }); } - const ws = await ctx.db.workspace.findUniqueOrThrow({ - where: { id: ctx.workspaceId }, - select: { id: true, name: true }, - }); - if (input.confirmName !== ws.name) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Confirmation name does not match workspace name.", - }); - } - // Drop the bucket + objects first while we still have the slug. - // Swallow failures so a dead MinIO doesn't block a workspace delete. - await deleteWorkspaceBucket(ctx.workspaceId).catch((err) => { - console.warn( - `[workspace.delete] bucket cleanup failed for ${ctx.workspaceId}:`, - (err as Error).message, - ); - }); - return ctx.db.workspace.delete({ where: { id: ctx.workspaceId } }); + return ctx.db.$transaction((tx) => + deleteWorkspacePreservingRuntimes(tx, ctx.workspaceId, input.confirmName, async () => { + // Guards have passed and the row still reserves this bucket's slug. + // Preserve best-effort cleanup before deletion; storage and DB + // changes are not atomic if the later database delete fails. + await deleteWorkspaceBucket(ctx.workspaceId).catch((err) => { + console.warn( + `[workspace.delete] bucket cleanup failed for ${ctx.workspaceId}:`, + (err as Error).message, + ); + }); + }), + ); }), /** diff --git a/src/server/services/__tests__/hermes-connector-retry.test.ts b/src/server/services/__tests__/hermes-connector-retry.test.ts index 7b624005..9e89c8fa 100644 --- a/src/server/services/__tests__/hermes-connector-retry.test.ts +++ b/src/server/services/__tests__/hermes-connector-retry.test.ts @@ -52,6 +52,7 @@ describe("Hermes connector retry worker", () => { const runtime = await prisma.runtime.create({ data: { workspaceId: f.workspace.id, + ownerType: "PERSONAL", ownerId: f.user.id, name: "Retry runtime", kind: "REMOTE_HTTP", diff --git a/src/server/services/__tests__/managed-runtime-lifecycle.test.ts b/src/server/services/__tests__/managed-runtime-lifecycle.test.ts index f2dd109b..b66faf8f 100644 --- a/src/server/services/__tests__/managed-runtime-lifecycle.test.ts +++ b/src/server/services/__tests__/managed-runtime-lifecycle.test.ts @@ -68,6 +68,7 @@ describe("managed runtime lifecycle acceptance", () => { const runtime = await prisma.runtime.create({ data: { workspaceId: fixture.workspace.id, + ownerType: "PERSONAL", ownerId: fixture.user.id, name: "Managed acceptance runtime", kind: RuntimeKind.REMOTE_HTTP, diff --git a/src/server/services/__tests__/mcp.test.ts b/src/server/services/__tests__/mcp.test.ts index 3bf59612..cfc0d8e8 100644 --- a/src/server/services/__tests__/mcp.test.ts +++ b/src/server/services/__tests__/mcp.test.ts @@ -2091,6 +2091,7 @@ describe("mcp — awareness tools (Stream BA)", () => { name: "host-a", kind: "LOCAL_DAEMON", ownerId: f.user.id, + ownerType: "PERSONAL", }, }); const { ctx } = buildMcpCtx(f); @@ -2115,6 +2116,7 @@ describe("mcp — awareness tools (Stream BA)", () => { name: "elsewhere", kind: "REMOTE_HTTP", ownerId: other.user.id, + ownerType: "PERSONAL", }, }); const { ctx } = buildMcpCtx(f); @@ -2135,6 +2137,7 @@ describe("mcp — awareness tools (Stream BA)", () => { kind: "REMOTE_HTTP", adapterKey: "hermes", ownerId: f.user.id, + ownerType: "PERSONAL", config: { localWorkspaceTools: true, toolCapabilities: ["terminal", "filesystem", "git"], @@ -2144,6 +2147,8 @@ describe("mcp — awareness tools (Stream BA)", () => { }); const { ctx } = buildMcpCtx(f); + ctx.apiKey!.kind = "PERSONAL"; + ctx.apiKey!.principalType = "USER"; const updated = (await call( "runtimes.configure", { @@ -5676,6 +5681,7 @@ describe("mcp — orchestration loop tools", () => { workspaceId: f.workspace.id, ownerId: f.user.id, name: "Hermes Test", + ownerType: "PERSONAL", kind: "REMOTE_HTTP", adapterKey: "hermes", }, diff --git a/src/server/services/__tests__/orchestration.test.ts b/src/server/services/__tests__/orchestration.test.ts index d5427f90..68c9bde6 100644 --- a/src/server/services/__tests__/orchestration.test.ts +++ b/src/server/services/__tests__/orchestration.test.ts @@ -297,6 +297,7 @@ describe("orchestration: decompose + addSteps", () => { const runtime = await prisma.runtime.create({ data: { workspaceId: fixture.workspace.id, + ownerType: "PERSONAL", ownerId: fixture.user.id, name: "Codex planner runtime", kind: RuntimeKind.LOCAL_DAEMON, @@ -361,6 +362,7 @@ describe("orchestration: decompose + addSteps", () => { const runtime = await prisma.runtime.create({ data: { workspaceId: fixture.workspace.id, + ownerType: "PERSONAL", ownerId: fixture.user.id, name: "Unanchored planner runtime", kind: RuntimeKind.LOCAL_DAEMON, diff --git a/src/server/services/__tests__/resource-ownership-schema.test.ts b/src/server/services/__tests__/resource-ownership-schema.test.ts new file mode 100644 index 00000000..6c73a869 --- /dev/null +++ b/src/server/services/__tests__/resource-ownership-schema.test.ts @@ -0,0 +1,129 @@ +import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest"; +import { randomUUID } from "node:crypto"; +import { + createWorkspaceFixture, + disconnectPrisma, + getPrisma, + type TestFixture, +} from "@/server/routers/__tests__/helpers"; + +const db = getPrisma(); +let fixture: TestFixture; +let prefix: string; + +describe("resource ownership database invariants (Postgres)", () => { + beforeEach(async () => { + fixture = await createWorkspaceFixture(); + prefix = `ownership-${randomUUID()}`; + }); + afterEach(async () => { + // Test-created service profiles have no personal owner, including after + // deleting their creator; identify them by this test's unique namespace. + await db.agentProfile.deleteMany({ where: { profileKey: { startsWith: prefix } } }); + await fixture.cleanup(); + }); + afterAll(disconnectPrisma); + + it.each([ + { ownerType: "PERSONAL" as const, withOwner: false }, + { ownerType: "SERVICE" as const, withOwner: true }, + { ownerType: "UNASSIGNED" as const, withOwner: false }, + ])("rejects invalid profile owner shape: $ownerType / owner=$withOwner", async ({ ownerType, withOwner }) => { + await expect(db.agentProfile.create({ data: { + name: "Invalid profile", + profileKey: prefix, + ownerType, + ownerId: withOwner ? fixture.user.id : null, + } })).rejects.toThrow(/AgentProfile_owner_shape_check/); + expect(await db.agentProfile.count({ where: { profileKey: prefix } })).toBe(0); + }); + + it.each([ + { ownerType: "PERSONAL" as const, withOwner: false }, + { ownerType: "SERVICE" as const, withOwner: true }, + { ownerType: "UNASSIGNED" as const, withOwner: true }, + ])("rejects invalid runtime owner shape: $ownerType / owner=$withOwner", async ({ ownerType, withOwner }) => { + await expect(db.runtime.create({ data: { + name: prefix, + kind: "LOCAL_DAEMON", + workspaceId: fixture.workspace.id, + ownerType, + ownerId: withOwner ? fixture.user.id : null, + } })).rejects.toThrow(/Runtime_owner_shape_check/); + expect(await db.runtime.count({ where: { name: prefix } })).toBe(0); + }); + + it("allows only one concurrent service identity per key without consuming personal namespaces", async () => { + const personalProfiles = await Promise.all([fixture.user.id, fixture.secondUser.id].map((ownerId) => + db.agentProfile.create({ data: { profileKey: prefix, name: "Personal", ownerType: "PERSONAL", ownerId } }), + )); + const serviceAttempts = await Promise.allSettled(Array.from({ length: 4 }, () => + db.agentProfile.create({ data: { profileKey: prefix, name: "Service", ownerType: "SERVICE" } }), + )); + expect(personalProfiles).toHaveLength(2); + expect(serviceAttempts.filter((result) => result.status === "fulfilled")).toHaveLength(1); + const rejected = serviceAttempts.filter((result) => result.status === "rejected"); + expect(rejected).toHaveLength(3); + for (const result of rejected) { + if (result.status === "rejected") expect(result.reason).toMatchObject({ code: "P2002" }); + } + expect(await db.agentProfile.count({ where: { profileKey: prefix } })).toBe(3); + }); + + it("preserves a service identity and runtime when their creator is deleted", async () => { + const runtime = await db.runtime.create({ data: { + name: prefix, kind: "LOCAL_DAEMON", workspaceId: fixture.workspace.id, + ownerType: "SERVICE", createdById: fixture.user.id, + } }); + const profile = await db.agentProfile.create({ data: { + profileKey: prefix, name: "Service", ownerType: "SERVICE", + createdById: fixture.user.id, runtimeId: runtime.id, + } }); + await db.user.delete({ where: { id: fixture.user.id } }); + expect(await db.agentProfile.findUniqueOrThrow({ where: { id: profile.id } })).toMatchObject({ + ownerType: "SERVICE", ownerId: null, createdById: null, runtimeId: runtime.id, + }); + expect(await db.runtime.findUniqueOrThrow({ where: { id: runtime.id } })).toMatchObject({ + ownerType: "SERVICE", ownerId: null, createdById: null, + }); + }); + + it("restricts personal profile owner deletion until ownership transfers", async () => { + const profile = await db.agentProfile.create({ data: { + profileKey: prefix, name: "Personal", ownerType: "PERSONAL", + ownerId: fixture.user.id, createdById: fixture.user.id, + } }); + await expect(db.user.delete({ where: { id: fixture.user.id } })).rejects.toMatchObject({ code: "P2003" }); + await db.agentProfile.update({ where: { id: profile.id }, data: { ownerId: fixture.secondUser.id } }); + await db.user.delete({ where: { id: fixture.user.id } }); + expect(await db.agentProfile.findUniqueOrThrow({ where: { id: profile.id } })).toMatchObject({ + ownerType: "PERSONAL", ownerId: fixture.secondUser.id, createdById: null, + }); + }); + + it("restricts personal runtime owner deletion until conversion to service ownership", async () => { + const runtime = await db.runtime.create({ data: { + name: prefix, kind: "LOCAL_DAEMON", workspaceId: fixture.workspace.id, + ownerType: "PERSONAL", ownerId: fixture.user.id, createdById: fixture.user.id, + } }); + await expect(db.user.delete({ where: { id: fixture.user.id } })).rejects.toMatchObject({ code: "P2003" }); + await db.runtime.update({ where: { id: runtime.id }, data: { ownerType: "SERVICE", ownerId: null } }); + await db.user.delete({ where: { id: fixture.user.id } }); + expect(await db.runtime.findUniqueOrThrow({ where: { id: runtime.id } })).toMatchObject({ + ownerType: "SERVICE", ownerId: null, createdById: null, + }); + }); + + it("preserves unassigned runtime classification until explicit adoption", async () => { + const runtime = await db.runtime.create({ data: { + name: prefix, kind: "LOCAL_DAEMON", workspaceId: fixture.workspace.id, + } }); + expect(runtime).toMatchObject({ ownerType: "UNASSIGNED", ownerId: null, createdById: null }); + await db.runtime.update({ where: { id: runtime.id }, data: { + ownerType: "PERSONAL", ownerId: fixture.secondUser.id, + } }); + expect(await db.runtime.findUniqueOrThrow({ where: { id: runtime.id } })).toMatchObject({ + ownerType: "PERSONAL", ownerId: fixture.secondUser.id, createdById: null, + }); + }); +}); diff --git a/src/server/services/__tests__/run-dispatcher.test.ts b/src/server/services/__tests__/run-dispatcher.test.ts index b33fe279..f078cc3f 100644 --- a/src/server/services/__tests__/run-dispatcher.test.ts +++ b/src/server/services/__tests__/run-dispatcher.test.ts @@ -53,6 +53,7 @@ describe("runs dispatcher", () => { const runtime = await prisma.runtime.create({ data: { workspaceId: fixture.workspace.id, + ownerType: "PERSONAL", ownerId: fixture.user.id, name: "Hermes TTL test", kind: RuntimeKind.REMOTE_HTTP, @@ -110,6 +111,7 @@ describe("runs dispatcher", () => { const runtime = await prisma.runtime.create({ data: { workspaceId: fixture.workspace.id, + ownerType: "PERSONAL", ownerId: fixture.user.id, name: "Hermes patient wait", kind: RuntimeKind.REMOTE_HTTP, @@ -170,6 +172,7 @@ describe("runs dispatcher", () => { const runtime = await prisma.runtime.create({ data: { workspaceId: fixture.workspace.id, + ownerType: "PERSONAL", ownerId: fixture.user.id, name: "mock runs", kind: RuntimeKind.LOCAL_DAEMON, @@ -258,6 +261,7 @@ describe("runs dispatcher", () => { const runtime = await prisma.runtime.create({ data: { workspaceId: fixture.workspace.id, + ownerType: "PERSONAL", ownerId: fixture.user.id, name: "mock deferred runs", kind: RuntimeKind.LOCAL_DAEMON, @@ -326,6 +330,7 @@ describe("runs dispatcher", () => { const runtime = await prisma.runtime.create({ data: { workspaceId: fixture.workspace.id, + ownerType: "PERSONAL", ownerId: fixture.user.id, name: "mock runs", kind: RuntimeKind.LOCAL_DAEMON, @@ -409,6 +414,7 @@ describe("runs dispatcher", () => { const runtime = await prisma.runtime.create({ data: { workspaceId: fixture.workspace.id, + ownerType: "PERSONAL", ownerId: fixture.user.id, name: "legacy runtime", kind: RuntimeKind.LOCAL_DAEMON, @@ -542,6 +548,7 @@ describe("runs dispatcher", () => { const runtime = await prisma.runtime.create({ data: { workspaceId: fixture.workspace.id, + ownerType: "PERSONAL", ownerId: fixture.user.id, name: "mock runs", kind: RuntimeKind.LOCAL_DAEMON, @@ -654,6 +661,7 @@ describe("runs dispatcher", () => { const runtime = await prisma.runtime.create({ data: { workspaceId: fixture.workspace.id, + ownerType: "PERSONAL", ownerId: fixture.user.id, name: "mock runs", kind: RuntimeKind.LOCAL_DAEMON, @@ -739,6 +747,7 @@ describe("runs dispatcher", () => { const runtime = await prisma.runtime.create({ data: { workspaceId: fixture.workspace.id, + ownerType: "PERSONAL", ownerId: fixture.user.id, name: "mock runs", kind: RuntimeKind.LOCAL_DAEMON, @@ -796,6 +805,7 @@ describe("runs dispatcher", () => { const runtime = await prisma.runtime.create({ data: { workspaceId: fixture.workspace.id, + ownerType: "PERSONAL", ownerId: fixture.user.id, name: "mock runs", kind: RuntimeKind.LOCAL_DAEMON, diff --git a/src/server/services/__tests__/runtime-mcp-ownership.test.ts b/src/server/services/__tests__/runtime-mcp-ownership.test.ts new file mode 100644 index 00000000..3dda4c8e --- /dev/null +++ b/src/server/services/__tests__/runtime-mcp-ownership.test.ts @@ -0,0 +1,127 @@ +import { afterAll, afterEach, describe, expect, it } from "vitest"; +import { mcpTools, type McpContext } from "@/server/services/mcp"; +import { + createWorkspaceFixture, + disconnectPrisma, + getPrisma, + type TestFixture, +} from "@/server/routers/__tests__/helpers"; + +const fixtures: TestFixture[] = []; +afterEach(async () => { + while (fixtures.length) await fixtures.pop()!.cleanup(); +}); +afterAll(disconnectPrisma); + +function context(f: TestFixture, service = false): McpContext { + return { + workspaceId: f.workspace.id, + userId: f.user.id, + pluginId: null, + apiKey: { + keyId: "ownership-test", + workspaceId: f.workspace.id, + userId: f.user.id, + kind: service ? "AGENT" : "PERSONAL", + principalType: service ? "AGENT" : "USER", + pluginId: null, + linkedAgentId: null, + scopes: ["ADMIN"], + projectIds: [], + labelIds: [], + initiativeIds: [], + }, + }; +} +async function call(name: keyof typeof mcpTools, input: unknown, ctx: McpContext) { + const tool = mcpTools[name]; + return tool.run(tool.input.parse(input) as never, ctx); +} + +describe("MCP runtime ownership boundaries", () => { + it("records personal ownership separately from a service key issuer", async () => { + const f = await createWorkspaceFixture(); + fixtures.push(f); + const human = (await call( + "runtimes.register", + { name: "Personal fixture", kind: "REMOTE_HTTP" }, + context(f), + )) as { id: string }; + const service = (await call( + "runtimes.register", + { name: "Service fixture", kind: "REMOTE_HTTP" }, + context(f, true), + )) as { id: string }; + expect(await getPrisma().runtime.findUnique({ where: { id: human.id } })).toMatchObject({ + ownerType: "PERSONAL", + ownerId: f.user.id, + createdById: f.user.id, + }); + expect(await getPrisma().runtime.findUnique({ where: { id: service.id } })).toMatchObject({ + ownerType: "UNASSIGNED", + ownerId: null, + createdById: f.user.id, + }); + }); + + it("does not lend an issuing instance admin's authority to service credentials", async () => { + const f = await createWorkspaceFixture(); + fixtures.push(f); + await getPrisma().user.update({ + where: { id: f.user.id }, + data: { instanceRole: "INSTANCE_ADMIN" }, + }); + const runtime = await getPrisma().runtime.create({ + data: { + workspaceId: f.workspace.id, + name: "Service host", + kind: "REMOTE_HTTP", + adapterKey: "hermes", + ownerType: "SERVICE", + }, + }); + await expect( + call("runtimes.configure", { runtimeId: runtime.id, config: {} }, context(f, true)), + ).rejects.toThrow(/human administrator|runtime owner/); + await expect( + call("runtimes.archive", { runtimeId: runtime.id }, context(f, true)), + ).rejects.toThrow(/human administrator|runtime owner/); + await call("runtimes.configure", { runtimeId: runtime.id, config: {} }, context(f)); + await call("runtimes.archive", { runtimeId: runtime.id }, context(f)); + expect( + await getPrisma().instanceAuditLog.count({ + where: { actorId: f.user.id, action: { in: ["runtime.configured", "runtime.archived"] } }, + }), + ).toBe(2); + }); + + it("denies workspace-only management of shared service resources", async () => { + const f = await createWorkspaceFixture(); + fixtures.push(f); + const runtime = await getPrisma().runtime.create({ + data: { + workspaceId: f.workspace.id, + name: "Shared host", + kind: "REMOTE_HTTP", + adapterKey: "hermes", + ownerType: "SERVICE", + instanceShared: true, + }, + }); + await expect( + call("runtimes.configure", { runtimeId: runtime.id, config: {} }, context(f)), + ).rejects.toThrow(/instance administrator/); + await expect(call("runtimes.archive", { runtimeId: runtime.id }, context(f))).rejects.toThrow( + /instance administrator/, + ); + const other = await createWorkspaceFixture(); + fixtures.push(other); + await getPrisma().runtime.update({ + where: { id: runtime.id }, + data: { workspaceId: other.workspace.id }, + }); + await expect(call("runtimes.archive", { runtimeId: runtime.id }, context(f))).rejects.toThrow( + /not found in this workspace/, + ); + }); +}); diff --git a/src/server/services/__tests__/storage.test.ts b/src/server/services/__tests__/storage.test.ts index 7ddf458b..0f544717 100644 --- a/src/server/services/__tests__/storage.test.ts +++ b/src/server/services/__tests__/storage.test.ts @@ -2,9 +2,11 @@ import { it, expect, afterAll, afterEach, beforeAll } from "vitest"; import { createWorkspaceFixture, createIssue, + buildContext, disconnectPrisma, type TestFixture, } from "@/server/routers/__tests__/helpers"; +import { workspaceRouter } from "@/server/routers/workspace"; import { describeIfMinio } from "@/server/routers/__tests__/minio-probe"; import { ALLOWED_MIME_TYPES, @@ -210,4 +212,32 @@ describe("storage service", () => { ), ).rejects.toThrow(); }); + + it("workspace deletion preserves the storage bucket when a runtime blocks deletion", async () => { + const fixture = await createWorkspaceFixture({ keyPrefix: "SRG" }); + fixtures.push(fixture); + const context = await buildContext(fixture); + const bucket = await ensureWorkspaceBucket(fixture.workspace.id); + await context.db.runtime.create({ + data: { workspaceId: fixture.workspace.id, name: "Retained host", kind: "LOCAL_DAEMON" }, + }); + await expect(workspaceRouter.createCaller(context).delete({ + confirmName: fixture.workspace.name, + })).rejects.toMatchObject({ code: "CONFLICT" }); + const { HeadBucketCommand } = await import("@aws-sdk/client-s3"); + await expect(getS3Client().send(new HeadBucketCommand({ Bucket: bucket }))).resolves.toBeDefined(); + }); + + it("workspace deletion cleans up the bucket and removes the database row", async () => { + const fixture = await createWorkspaceFixture({ keyPrefix: "SPD" }); + fixtures.push(fixture); + const context = await buildContext(fixture); + const bucket = await ensureWorkspaceBucket(fixture.workspace.id); + await workspaceRouter.createCaller(context).delete({ confirmName: fixture.workspace.name }); + expect(await context.db.workspace.findUnique({ where: { id: fixture.workspace.id } })).toBeNull(); + const { HeadBucketCommand } = await import("@aws-sdk/client-s3"); + await expect(getS3Client().send(new HeadBucketCommand({ Bucket: bucket }))).rejects.toMatchObject({ + $metadata: expect.objectContaining({ httpStatusCode: 404 }), + }); + }); }); diff --git a/src/server/services/__tests__/workspace-deletion.test.ts b/src/server/services/__tests__/workspace-deletion.test.ts new file mode 100644 index 00000000..790def09 --- /dev/null +++ b/src/server/services/__tests__/workspace-deletion.test.ts @@ -0,0 +1,269 @@ +import { afterAll, afterEach, beforeEach, describe, expect, it } from "vitest"; +import { RuntimeKind } from "@prisma/client"; +import { workspaceRouter } from "@/server/routers/workspace"; +import { + buildContext, + createWorkspaceFixture, + disconnectPrisma, + getPrisma, + type TestFixture, +} from "@/server/routers/__tests__/helpers"; +import { deleteWorkspacePreservingRuntimes } from "@/server/services/workspace-deletion"; + +const db = getPrisma(); +let fixture: TestFixture; +let other: TestFixture; + +function deferred() { + let resolve!: () => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +async function waitForDatabaseLock(applicationName: string) { + const deadline = Date.now() + 5_000; + while (Date.now() < deadline) { + const rows = await db.$queryRaw>` + SELECT "wait_event_type" = 'Lock' AS waiting + FROM pg_stat_activity WHERE "application_name" = ${applicationName} + `; + if (rows.some((row) => row.waiting)) return; + await new Promise((resolve) => setTimeout(resolve, 20)); + } + throw new Error(`Expected database lock wait for ${applicationName}`); +} + +function removeWorkspace() { + return db.$transaction((tx) => + deleteWorkspacePreservingRuntimes(tx, fixture.workspace.id, fixture.workspace.name), + ); +} + +describe("workspace deletion runtime safety (Postgres)", () => { + beforeEach(async () => { + fixture = await createWorkspaceFixture(); + other = await createWorkspaceFixture(); + }); + afterEach(async () => { + // These fixtures intentionally place another user's runtime in this + // workspace; remove that test resource before deleting its owner. + await db.runtime.deleteMany({ where: { workspaceId: fixture.workspace.id } }); + await other.cleanup(); + await fixture.cleanup(); + }); + afterAll(disconnectPrisma); + + it.each([ + { label: "personal", instanceShared: false, archivedAt: null }, + { label: "instance-shared", instanceShared: true, archivedAt: null }, + { label: "archived", instanceShared: false, archivedAt: new Date() }, + ])( + "preserves an unbound $label runtime and the workspace", + async ({ instanceShared, archivedAt }) => { + const runtime = await db.runtime.create({ + data: { + workspaceId: fixture.workspace.id, + ownerId: other.user.id, + ownerType: "PERSONAL", + name: "Preserved host", + kind: RuntimeKind.REMOTE_HTTP, + instanceShared, + archivedAt, + }, + }); + const caller = workspaceRouter.createCaller(await buildContext(fixture)); + await expect(caller.delete({ confirmName: fixture.workspace.name })).rejects.toMatchObject({ + code: "CONFLICT", + message: expect.stringContaining("Archive the workspace instead"), + }); + expect(await db.runtime.findUnique({ where: { id: runtime.id } })).not.toBeNull(); + expect(await db.workspace.findUnique({ where: { id: fixture.workspace.id } })).not.toBeNull(); + }, + ); + + it("preserves a legacy ownerless disabled host", async () => { + const runtime = await db.runtime.create({ + data: { + workspaceId: fixture.workspace.id, + name: "Legacy host", + kind: RuntimeKind.LOCAL_DAEMON, + disabledAt: new Date(), + }, + }); + await expect(removeWorkspace()).rejects.toMatchObject({ code: "CONFLICT" }); + expect(await db.runtime.findUnique({ where: { id: runtime.id } })).not.toBeNull(); + }); + + it("preserves cross-workspace agents and unbound global profile references", async () => { + const runtime = await db.runtime.create({ + data: { + workspaceId: fixture.workspace.id, + name: "Shared host", + kind: RuntimeKind.REMOTE_HTTP, + }, + }); + const profile = await db.agentProfile.create({ + data: { + ownerId: other.user.id, + ownerType: "PERSONAL", + profileKey: "global", + name: "Global profile", + runtimeId: runtime.id, + }, + }); + const agent = await db.agent.create({ + data: { + workspaceId: other.workspace.id, + profileKey: "remote", + name: "Remote agent", + runtimeId: runtime.id, + }, + }); + await expect(removeWorkspace()).rejects.toMatchObject({ code: "CONFLICT" }); + expect((await db.agentProfile.findUniqueOrThrow({ where: { id: profile.id } })).runtimeId).toBe( + runtime.id, + ); + expect((await db.agent.findUniqueOrThrow({ where: { id: agent.id } })).runtimeId).toBe( + runtime.id, + ); + }); + + it("allows workspace archival while preserving its runtime", async () => { + const runtime = await db.runtime.create({ + data: { + workspaceId: fixture.workspace.id, + name: "Retained host", + kind: RuntimeKind.LOCAL_DAEMON, + }, + }); + const caller = workspaceRouter.createCaller(await buildContext(fixture)); + expect((await caller.archive()).deletedAt).not.toBeNull(); + expect(await db.runtime.findUnique({ where: { id: runtime.id } })).not.toBeNull(); + }); + + it("allows deletion without registered runtimes, regardless of other workspaces", async () => { + const runtime = await db.runtime.create({ + data: { workspaceId: other.workspace.id, name: "Other host", kind: RuntimeKind.LOCAL_DAEMON }, + }); + expect((await removeWorkspace()).id).toBe(fixture.workspace.id); + expect(await db.workspace.findUnique({ where: { id: fixture.workspace.id } })).toBeNull(); + expect(await db.runtime.findUnique({ where: { id: runtime.id } })).not.toBeNull(); + }); + + it("validates the confirmation name under the deletion lock", async () => { + await expect( + db.$transaction((tx) => + deleteWorkspacePreservingRuntimes(tx, fixture.workspace.id, "wrong name"), + ), + ).rejects.toMatchObject({ code: "BAD_REQUEST" }); + expect(await db.workspace.findUnique({ where: { id: fixture.workspace.id } })).not.toBeNull(); + }); + + it("reserves the workspace slug until guarded cleanup has finished", async () => { + let cleanupFinished = false; + await db.$transaction((tx) => + deleteWorkspacePreservingRuntimes(tx, fixture.workspace.id, fixture.workspace.name, async () => { + expect(await db.workspace.findUnique({ where: { id: fixture.workspace.id } })).not.toBeNull(); + await expect(db.workspace.update({ + where: { id: other.workspace.id }, + data: { slug: fixture.workspace.slug }, + })).rejects.toMatchObject({ code: "P2002" }); + cleanupFinished = true; + }), + ); + expect(cleanupFinished).toBe(true); + await expect(db.workspace.update({ + where: { id: other.workspace.id }, + data: { slug: fixture.workspace.slug }, + })).resolves.toMatchObject({ slug: fixture.workspace.slug }); + }); + + it("observes a concurrent runtime registration before deleting", async () => { + const inserted = deferred(); + const releaseInsert = deferred(); + const applicationName = `delete-${fixture.workspace.id}`; + const creation = db.$transaction( + async (tx) => { + const runtime = await tx.runtime.create({ + data: { + workspaceId: fixture.workspace.id, + name: "Concurrent host", + kind: RuntimeKind.LOCAL_DAEMON, + }, + }); + inserted.resolve(); + await releaseInsert.promise; + return runtime; + }, + { timeout: 15_000 }, + ); + await inserted.promise; + const deletion = db + .$transaction( + async (tx) => { + await tx.$queryRaw`SELECT set_config('application_name', ${applicationName}, true)`; + return deleteWorkspacePreservingRuntimes( + tx, + fixture.workspace.id, + fixture.workspace.name, + ); + }, + { timeout: 15_000 }, + ) + .then( + () => null, + (error: unknown) => error, + ); + try { + await waitForDatabaseLock(applicationName); + } finally { + releaseInsert.resolve(); + } + const runtime = await creation; + expect(await deletion).toMatchObject({ code: "CONFLICT" }); + expect(await db.runtime.findUnique({ where: { id: runtime.id } })).not.toBeNull(); + }); + + it("rejects runtime registration racing after the guarded deletion", async () => { + const deleted = deferred(); + const releaseDelete = deferred(); + const applicationName = `create-${fixture.workspace.id}`; + const deletion = db.$transaction( + async (tx) => { + await deleteWorkspacePreservingRuntimes(tx, fixture.workspace.id, fixture.workspace.name); + deleted.resolve(); + await releaseDelete.promise; + }, + { timeout: 15_000 }, + ); + await deleted.promise; + const creation = db + .$transaction( + async (tx) => { + await tx.$queryRaw`SELECT set_config('application_name', ${applicationName}, true)`; + return tx.runtime.create({ + data: { + workspaceId: fixture.workspace.id, + name: "Too late", + kind: RuntimeKind.LOCAL_DAEMON, + }, + }); + }, + { timeout: 15_000 }, + ) + .then( + () => null, + (error: unknown) => error, + ); + try { + await waitForDatabaseLock(applicationName); + } finally { + releaseDelete.resolve(); + } + await deletion; + expect(await creation).toMatchObject({ code: "P2003" }); + expect(await db.runtime.count({ where: { workspaceId: fixture.workspace.id } })).toBe(0); + }); +}); diff --git a/src/server/services/agent-profile-access.ts b/src/server/services/agent-profile-access.ts new file mode 100644 index 00000000..c50578c1 --- /dev/null +++ b/src/server/services/agent-profile-access.ts @@ -0,0 +1,84 @@ +import { TRPCError } from "@trpc/server"; +import type { Prisma } from "@prisma/client"; + +type ProfileAccess = { + ownerId: string | null; + ownerType: "PERSONAL" | "SERVICE" | "UNASSIGNED"; + instanceShared: boolean; + archivedAt: Date | null; + disabledAt: Date | null; + requestedById: string | null; + approvedAt: Date | null; +}; + +/** Read surfaces expose credential presence, never the dispatch credential. */ +export function publicAgentDefinition(definition: T) { + const { webhookSecret, ...safe } = definition; + return { ...safe, hasWebhookSecret: Boolean(webhookSecret) }; +} + +export function canViewAgentWorkspace( + workspaceId: string, + membershipIds: Set, + isInstanceAdmin: boolean, +) { + return isInstanceAdmin || membershipIds.has(workspaceId); +} + +/** Availability is catalog eligibility; it never grants workspace access. */ +export function agentProfileAvailability(profile: ProfileAccess) { + return { + scope: profile.instanceShared + ? ("INSTANCE_CATALOG" as const) + : profile.ownerType === "SERVICE" + ? ("ADMIN_ONLY" as const) + : ("OWNER_ONLY" as const), + state: profile.archivedAt + ? ("ARCHIVED" as const) + : profile.disabledAt + ? ("DISABLED" as const) + : profile.requestedById && !profile.approvedAt + ? ("PENDING_APPROVAL" as const) + : ("AVAILABLE" as const), + }; +} + +export function availableAgentProfilesWhere( + userId: string, + isInstanceAdmin: boolean, +): Prisma.AgentProfileWhereInput { + return { + archivedAt: null, + disabledAt: null, + AND: [ + ...(isInstanceAdmin ? [] : [{ OR: [{ ownerId: userId }, { instanceShared: true }] }]), + { OR: [{ requestedById: null }, { approvedAt: { not: null } }] }, + ], + }; +} + +/** Both setup entrypoints enforce the same lifecycle and availability policy. */ +export function assertAgentProfileBindable( + profile: ProfileAccess | null, + userId: string, + isInstanceAdmin: boolean, +): asserts profile is ProfileAccess { + if (!profile || profile.archivedAt) { + throw new TRPCError({ code: "NOT_FOUND", message: "Profile not found." }); + } + if (profile.ownerId !== userId && !profile.instanceShared && !isInstanceAdmin) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "That profile isn't available to this workspace.", + }); + } + if (profile.disabledAt) { + throw new TRPCError({ code: "FORBIDDEN", message: "That profile is disabled." }); + } + if (profile.requestedById && !profile.approvedAt) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "That profile is pending instance-admin approval.", + }); + } +} diff --git a/src/server/services/agent-profile-policy.ts b/src/server/services/agent-profile-policy.ts new file mode 100644 index 00000000..d0b8cc0a --- /dev/null +++ b/src/server/services/agent-profile-policy.ts @@ -0,0 +1,110 @@ +import { TRPCError } from "@trpc/server"; +import { Prisma, type PrismaClient } from "@prisma/client"; + +export type ProfileOwnerType = "PERSONAL" | "SERVICE"; +export type AgentPolicyMode = "DISABLED" | "APPROVAL_REQUIRED" | "SELF_SERVICE"; +export type ProfileDb = PrismaClient | Prisma.TransactionClient; + +export async function readAgentPolicy(db: ProfileDb) { + const row = await db.instanceAgentPolicy.findUnique({ where: { id: "default" } }); + return { personalAgentCreation: row?.personalAgentCreation ?? ("APPROVAL_REQUIRED" as const) }; +} + +export async function assertActiveProfileOwner(db: ProfileDb, ownerId: string) { + const owner = await db.user.findFirst({ + where: { id: ownerId, status: "ACTIVE", deletedAt: null, disabledAt: null }, + select: { id: true }, + }); + if (!owner) + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Choose an active user as the personal agent owner.", + }); +} + +export async function assertProfileRuntimeAvailable( + db: ProfileDb, + runtimeId: string | null | undefined, + ownerType: ProfileOwnerType, + ownerId: string | null, +) { + if (!runtimeId) return; + const runtime = await db.runtime.findFirst({ + where: { + id: runtimeId, + ...profileRuntimeOptionsWhere(ownerType, ownerId), + }, + select: { id: true }, + }); + if (!runtime) + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "The runtime is not available to this owner. Clear the runtime before transferring ownership, or choose an authorized runtime.", + }); +} + +export function profileRuntimeOptionsWhere( + ownerType: ProfileOwnerType, + ownerId: string | null, +): Prisma.RuntimeWhereInput { + return { + archivedAt: null, + disabledAt: null, + ownerType: { not: "UNASSIGNED" }, + OR: [ + { instanceShared: true }, + ...(ownerType === "SERVICE" + ? [{ ownerType: "SERVICE" as const }] + : ownerId + ? [{ ownerType: "PERSONAL" as const, ownerId }] + : []), + ], + }; +} + +export async function auditAgentProfile( + db: ProfileDb, + actor: { actorId: string; ip: string | null; userAgent: string | null }, + action: string, + metadata: Prisma.InputJsonObject, +) { + await db.instanceAuditLog.create({ + data: { + actorId: actor.actorId, + action, + metadata, + ipAddress: actor.ip, + userAgent: actor.userAgent, + }, + }); +} + +/** A stale policy, owner, or duplicate key must never silently widen authority. */ +export async function profileTransaction( + db: PrismaClient, + work: (tx: Prisma.TransactionClient) => Promise, + isolationLevel: Prisma.TransactionIsolationLevel = Prisma.TransactionIsolationLevel.Serializable, +) { + for (let attempt = 0; ; attempt += 1) { + try { + return await db.$transaction(work, { isolationLevel }); + } catch (error) { + // Serializable policy/ownership operations have only transactional DB + // effects. Retry predicate conflicts; the caller's expected snapshot is + // still rechecked and never refreshed automatically. Binding operations + // publish activity, so their explicit row-lock transaction is not retried. + if (error instanceof Prisma.PrismaClientKnownRequestError) { + if (error.code === "P2034" && isolationLevel === "Serializable" && attempt < 2) continue; + if (error.code === "P2034" || error.code === "P2002") { + throw new TRPCError({ + code: "CONFLICT", + message: + "The agent or its ownership policy changed, or this owner already has that agent key. Refresh and try again.", + }); + } + } + throw error; + } + } +} diff --git a/src/server/services/mcp.ts b/src/server/services/mcp.ts index 0f7a9339..66eefc76 100644 --- a/src/server/services/mcp.ts +++ b/src/server/services/mcp.ts @@ -110,6 +110,7 @@ import { assertSafeExternalUrl, isSafeExternalUrl, safeExternalUrlMessage } from import { hydrateEntityRefs } from "@/server/services/entity-hydration"; import { computeAlignment, type AlignItem } from "@/server/services/canvas-alignment"; import { validateRuntimeConfig } from "@/server/services/runtime-config"; +import { lockManagedRuntime } from "@/server/services/runtime-lifecycle"; import { runtimeInfoCreateData, runtimeInfoUpdateData, @@ -207,6 +208,16 @@ export interface McpContext { connectionId?: string | null; } +/** Service keys never inherit management authority from their issuing human. */ +function runtimeManagementUser(ctx: McpContext): string | null { + if (!ctx.apiKey) return ctx.userId; + return ctx.apiKey.principalType === "USER" || + ctx.apiKey.kind === "PERSONAL" || + ctx.apiKey.kind === "SESSION" + ? ctx.apiKey.userId + : null; +} + /** * Shim to satisfy `assertKeyScope`/`buildKeyScopeWhere` which both expect a * tRPC-ish ctx shape (`{ apiKey, db }`). We wrap `McpContext` here so the @@ -6560,40 +6571,59 @@ export const mcpTools = { }, ctx: McpContext, ) { - // ownerId attribution: prefer the user the API key belongs to, - // otherwise fall back to null. AGENT-kind keys often have no - // userId; treating that as "owned by the workspace" is correct. - const ownerId = ctx.apiKey?.userId ?? ctx.userId ?? null; + // A service credential's issuing human is provenance, not its owner. + // Ownerless registrations require explicit administrator adoption. + const ownerId = runtimeManagementUser(ctx); const now = new Date(); - return db.runtime.create({ - data: { - workspaceId: ctx.workspaceId, - name: input.name, - kind: input.kind, - endpoint: input.endpoint || null, - providersAvailable: input.providersAvailable, - ownerId, - // ownerKeyPrefix is left null from MCP — set later via the - // tRPC `runtime.update` path or backfilled from `ApiKey.prefix` - // by a follow-up sweep when we wire the daemon's auth flow. - ownerKeyPrefix: null, - connectedAt: input.kind === RuntimeKind.LOCAL_DAEMON ? now : null, - heartbeatAt: input.kind === RuntimeKind.LOCAL_DAEMON ? now : null, - ...(input.info ? runtimeInfoCreateData(input.info, now) : {}), - }, - select: { - id: true, - name: true, - kind: true, - endpoint: true, - providersAvailable: true, - ownerId: true, - connectedAt: true, - heartbeatAt: true, - runtimeInfo: true, - lastInfoAt: true, - createdAt: true, - }, + return db.$transaction(async (tx) => { + const runtime = await tx.runtime.create({ + data: { + workspaceId: ctx.workspaceId, + name: input.name, + kind: input.kind, + endpoint: input.endpoint || null, + providersAvailable: input.providersAvailable, + ownerId, + ownerType: ownerId ? "PERSONAL" : "UNASSIGNED", + createdById: ctx.userId ?? ctx.apiKey?.userId ?? null, + // ownerKeyPrefix is left null from MCP — set later via the + // tRPC `runtime.update` path or backfilled from `ApiKey.prefix` + // by a follow-up sweep when we wire the daemon's auth flow. + ownerKeyPrefix: null, + connectedAt: input.kind === RuntimeKind.LOCAL_DAEMON ? now : null, + heartbeatAt: input.kind === RuntimeKind.LOCAL_DAEMON ? now : null, + ...(input.info ? runtimeInfoCreateData(input.info, now) : {}), + }, + select: { + id: true, + name: true, + kind: true, + endpoint: true, + providersAvailable: true, + ownerId: true, + ownerType: true, + createdById: true, + connectedAt: true, + heartbeatAt: true, + runtimeInfo: true, + lastInfoAt: true, + createdAt: true, + }, + }); + await tx.instanceAuditLog.create({ + data: { + actorId: ctx.userId ?? ctx.apiKey?.userId ?? null, + action: "runtime.registered", + metadata: { + runtimeId: runtime.id, + workspaceId: ctx.workspaceId, + ownerType: runtime.ownerType, + ownerId: runtime.ownerId, + source: "MCP", + }, + }, + }); + return runtime; }); }, }, @@ -6616,7 +6646,7 @@ export const mcpTools = { if (row.archivedAt) throw new Error("Runtime is archived; cannot heartbeat."); const now = new Date(); return db.runtime.update({ - where: { id: row.id }, + where: { id: row.id, workspaceId: ctx.workspaceId, archivedAt: null }, data: { heartbeatAt: now, ...(input.info ? runtimeInfoUpdateData(input.info, now) : {}), @@ -6676,7 +6706,7 @@ export const mcpTools = { } const now = new Date(); const updated = await db.runtime.update({ - where: { id: runtime.id }, + where: { id: runtime.id, workspaceId: ctx.workspaceId, archivedAt: null }, data: { runtimeInfo: sanitized, lastInfoAt: now }, select: { id: true, runtimeInfo: true, lastInfoAt: true }, }); @@ -6707,34 +6737,39 @@ export const mcpTools = { input: { runtimeId: string; config: Record; merge: boolean }, ctx: McpContext, ) { - const row = await db.runtime.findFirst({ - where: { id: input.runtimeId, workspaceId: ctx.workspaceId }, - select: { - id: true, - name: true, - adapterKey: true, - config: true, - archivedAt: true, - }, - }); - if (!row) throw new Error("Runtime not found in this workspace."); - if (row.archivedAt) throw new Error("Runtime is archived; restore it before configuring."); - const current = - row.config && typeof row.config === "object" && !Array.isArray(row.config) - ? (row.config as Record) - : {}; - const next = input.merge ? { ...current, ...input.config } : input.config; - const validated = validateRuntimeConfig(row.adapterKey, next); - return db.runtime.update({ - where: { id: row.id }, - data: { config: validated }, - select: { - id: true, - name: true, - adapterKey: true, - config: true, - updatedAt: true, - }, + return db.$transaction(async (tx) => { + const row = await lockManagedRuntime( + tx, + input.runtimeId, + ctx.workspaceId, + runtimeManagementUser(ctx), + ); + if (row.archivedAt) throw new Error("Runtime is archived; restore it before configuring."); + const current = + row.config && typeof row.config === "object" && !Array.isArray(row.config) + ? (row.config as Record) + : {}; + const next = input.merge ? { ...current, ...input.config } : input.config; + const validated = validateRuntimeConfig(row.adapterKey, next); + const updated = await tx.runtime.update({ + where: { id: row.id }, + data: { config: validated }, + select: { + id: true, + name: true, + adapterKey: true, + config: true, + updatedAt: true, + }, + }); + await tx.instanceAuditLog.create({ + data: { + actorId: runtimeManagementUser(ctx), + action: "runtime.configured", + metadata: { runtimeId: row.id, workspaceId: ctx.workspaceId, source: "MCP" }, + }, + }); + return updated; }); }, }, @@ -6750,19 +6785,29 @@ export const mcpTools = { runtimeId: z.string().min(1).max(40).describe("Runtime.id to archive (deregister)."), }), async run(input: { runtimeId: string }, ctx: McpContext) { - const row = await db.runtime.findFirst({ - where: { id: input.runtimeId, workspaceId: ctx.workspaceId }, - select: { id: true, name: true, archivedAt: true }, - }); - if (!row) throw new Error("Runtime not found in this workspace."); - if (row.archivedAt) { - return { id: row.id, name: row.name, alreadyArchived: true }; - } - await db.runtime.update({ - where: { id: row.id }, - data: { archivedAt: new Date() }, + return db.$transaction(async (tx) => { + const row = await lockManagedRuntime( + tx, + input.runtimeId, + ctx.workspaceId, + runtimeManagementUser(ctx), + ); + if (row.archivedAt) { + return { id: row.id, name: row.name, alreadyArchived: true }; + } + await tx.runtime.update({ + where: { id: row.id }, + data: { archivedAt: new Date() }, + }); + await tx.instanceAuditLog.create({ + data: { + actorId: runtimeManagementUser(ctx), + action: "runtime.archived", + metadata: { runtimeId: row.id, workspaceId: ctx.workspaceId, source: "MCP" }, + }, + }); + return { id: row.id, name: row.name, alreadyArchived: false }; }); - return { id: row.id, name: row.name, alreadyArchived: false }; }, }, diff --git a/src/server/services/runtime-lifecycle.ts b/src/server/services/runtime-lifecycle.ts new file mode 100644 index 00000000..31a209f5 --- /dev/null +++ b/src/server/services/runtime-lifecycle.ts @@ -0,0 +1,428 @@ +import { Prisma, type PrismaClient } from "@prisma/client"; +import { TRPCError } from "@trpc/server"; + +type Database = PrismaClient | Prisma.TransactionClient; +export type RuntimeOwnerType = "PERSONAL" | "SERVICE" | "UNASSIGNED"; +export type RuntimeLifecycleExpected = { + id: string; + expectedWorkspaceId: string; + expectedOwnerType: RuntimeOwnerType; + expectedOwnerId: string | null; + expectedUpdatedAt: Date; + confirmName: string; +}; +export type RuntimeTransferInput = RuntimeLifecycleExpected & { + targetWorkspaceId: string; + ownerType: "PERSONAL" | "SERVICE"; + ownerId: string | null; +}; + +/** Shared browser/MCP policy. Caller must lock/recheck the row before writing. */ +export async function assertCanManageRuntime(input: { + db: Database; + runtime: { + workspaceId: string; + ownerType: RuntimeOwnerType; + ownerId: string | null; + instanceShared: boolean; + }; + userId: string | null; +}) { + if (!input.userId) + throw new TRPCError({ + code: "FORBIDDEN", + message: "A human administrator or runtime owner must manage this registration.", + }); + const user = await input.db.user.findUnique({ + where: { id: input.userId }, + select: { instanceRole: true, deletedAt: true, disabledAt: true, status: true }, + }); + if (!user || user.deletedAt || user.disabledAt || user.status !== "ACTIVE") + throw new TRPCError({ code: "FORBIDDEN", message: "Active user authority is required." }); + if (user.instanceRole === "INSTANCE_ADMIN") return; + if (input.runtime.instanceShared || input.runtime.ownerType !== "PERSONAL") { + throw new TRPCError({ + code: "FORBIDDEN", + message: + "Only an instance administrator can change shared, service-owned, or unassigned runtimes.", + }); + } + const membership = await input.db.membership.findUnique({ + where: { userId_workspaceId: { userId: input.userId, workspaceId: input.runtime.workspaceId } }, + select: { role: true }, + }); + if ( + !membership || + (input.runtime.ownerId !== input.userId && !["OWNER", "ADMIN"].includes(membership.role)) + ) { + throw new TRPCError({ + code: "FORBIDDEN", + message: + "Only the runtime owner or a workspace administrator can manage this personal runtime.", + }); + } +} + +/** Use for scoped mutations, including writes to runtime child configuration. */ +export async function lockManagedRuntime( + tx: Prisma.TransactionClient, + id: string, + workspaceId: string, + userId: string | null, +) { + await tx.$queryRaw`SELECT "id" FROM "Runtime" WHERE "id" = ${id} FOR UPDATE`; + const runtime = await tx.runtime.findFirst({ where: { id, workspaceId } }); + if (!runtime) + throw new TRPCError({ + code: "NOT_FOUND", + message: "Runtime not found in this workspace. Refresh before editing.", + }); + await assertCanManageRuntime({ db: tx, runtime, userId }); + // Child configuration writes must invalidate a reviewed lifecycle snapshot + // too. This helper is only used on mutation paths, inside their transaction. + return tx.runtime.update({ where: { id }, data: { + updatedAt: new Date(Math.max(Date.now(), runtime.updatedAt.getTime() + 1)), + } }); +} + +const runtimeSnapshot = { + id: true, + name: true, + workspaceId: true, + ownerType: true, + ownerId: true, + instanceShared: true, + archivedAt: true, + disabledAt: true, + updatedAt: true, + owner: { select: { id: true, name: true, email: true } }, + workspace: { select: { id: true, name: true, slug: true, key: true } }, +} satisfies Prisma.RuntimeSelect; + +/** Read-only, metadata-only impact. Mutations repeat this inside locked transactions. */ +export async function runtimeLifecycleImpact(db: Database, id: string, targetWorkspaceId?: string) { + const runtime = await db.runtime.findUnique({ where: { id }, select: runtimeSnapshot }); + if (!runtime) throw new TRPCError({ code: "NOT_FOUND", message: "Runtime not found." }); + const agentWhere: Prisma.AgentWhereInput = { + OR: [{ runtimeId: id }, { profile: { runtimeId: id } }], + }; + const runWhere: Prisma.AgentRunWhereInput = { + OR: [{ agent: agentWhere }, { connection: { runtimeId: id } }], + }; + const deliveryWhere: Prisma.WorkSessionWhereInput = { + OR: [ + { ownerAgent: agentWhere }, + { ownerConnection: { runtimeId: id } }, + { participants: { some: { connection: { runtimeId: id } } } }, + ], + }; + const [ + agents, + profiles, + connections, + connectorSessions, + diagnostics, + secrets, + repos, + runHistory, + deliveryHistory, + activeRuns, + activeDelivery, + activeConnections, + activeConnectorSessions, + pendingDiagnostics, + sensitive, + ] = await Promise.all([ + db.agent.count({ where: agentWhere }), + db.agentProfile.count({ where: { runtimeId: id } }), + db.agentConnection.count({ where: { runtimeId: id } }), + db.connectorSession.count({ where: { runtimeId: id } }), + db.runtimeDiagnosticAttempt.count({ where: { runtimeId: id } }), + db.runtimeSecret.count({ where: { runtimeId: id } }), + db.runtimeRepo.count({ where: { runtimeId: id } }), + db.agentRun.count({ where: runWhere }), + db.workSession.count({ where: deliveryWhere }), + db.agentRun.count({ where: { ...runWhere, status: { notIn: ["COMPLETED", "ABANDONED"] } } }), + db.workSession.count({ where: { ...deliveryWhere, endedAt: null } }), + db.agentConnection.count({ + where: { runtimeId: id, revokedAt: null, status: { notIn: ["REVOKED", "DISCONNECTED"] } }, + }), + db.connectorSession.count({ where: { runtimeId: id, lifecycle: { not: "CLOSED" } } }), + db.runtimeDiagnosticAttempt.count({ where: { runtimeId: id, completedAt: null } }), + db.runtime.findUniqueOrThrow({ + where: { id }, + select: { secret: true, config: true, githubAppId: true, endpoint: true }, + }), + ]); + const counts = { + agents, + profiles, + connections, + connectorSessions, + diagnostics, + secrets, + repos, + runHistory, + deliveryHistory, + }; + const common: string[] = []; + if (!runtime.archivedAt || !runtime.disabledAt) + common.push( + "Archive and disable this runtime before changing ownership, moving it, or removing its registration.", + ); + if (agents) + common.push( + `${agents} workspace agent association(s) still use this runtime. Reconfigure those agents first; archived associations also count.`, + ); + if (profiles) + common.push( + `${profiles} agent definition(s) still use this runtime. Choose another execution host or clear their runtime first.`, + ); + if (activeRuns) + common.push( + `${activeRuns} nonterminal run(s) remain. Complete or explicitly abandon them before proceeding.`, + ); + if (activeDelivery) + common.push( + `${activeDelivery} active delivery session(s) remain. Resolve their ownership and disposition first.`, + ); + if (activeConnections) + common.push( + `${activeConnections} connection(s) are active or unconfirmed. Disconnect or revoke them explicitly; silence is not completion.`, + ); + if (activeConnectorSessions) + common.push( + `${activeConnectorSessions} connector session(s) are not closed. Close them before proceeding.`, + ); + if (pendingDiagnostics) + common.push( + `${pendingDiagnostics} diagnostic request(s) are unfinished. Wait for completion or resolve the requests first.`, + ); + const transferBlockers = [...common]; + const hasAdapterConfig = !!sensitive.config && Object.keys(sensitive.config).length > 0; + let hasEndpointCredentialHints = false; + if (sensitive.endpoint) { + try { + const endpoint = new URL(sensitive.endpoint); + hasEndpointCredentialHints = !!(endpoint.username || endpoint.password || endpoint.search); + } catch { + hasEndpointCredentialHints = true; + } + } + if ( + secrets || + repos || + sensitive.secret || + hasAdapterConfig || + sensitive.githubAppId || + hasEndpointCredentialHints + ) { + transferBlockers.push( + "Credential or execution configuration is still attached (secrets, repositories, GitHub App, inline secret, adapter configuration, or endpoint credentials/query parameters). Review and remove it in the current home before transfer; no configuration or credentials are moved implicitly.", + ); + } + if ( + targetWorkspaceId && + targetWorkspaceId !== runtime.workspaceId && + (connections || connectorSessions || diagnostics || runHistory || deliveryHistory) + ) { + transferBlockers.push( + "Historical workspace-scoped connections, sessions, diagnostics, runs, or delivery records remain. Keep this registration in its original home; create a new runtime in the target workspace instead.", + ); + } + const removeBlockers = [...common]; + if (connections || connectorSessions || diagnostics || runHistory || deliveryHistory) { + removeBlockers.push( + "This runtime has connection, session, diagnostic, run, or delivery history. Keep it archived to preserve evidence; permanent removal is only available for unused registrations.", + ); + } + return { + runtime, + counts, + transferBlockers, + removeBlockers, + configuration: { + hasInlineSecret: !!sensitive.secret, + hasAdapterConfig, + hasEndpointCredentialHints, + hasGithubApp: !!sensitive.githubAppId, + }, + }; +} + +/** Lock parent resources first, then the runtime and reference rows in stable order. */ +async function lockRuntimeLifecycle( + tx: Prisma.TransactionClient, + input: RuntimeLifecycleExpected, + targetWorkspaceId?: string, +) { + const workspaceIds = [ + ...new Set([input.expectedWorkspaceId, targetWorkspaceId].filter((id): id is string => !!id)), + ].sort(); + await tx.$queryRaw`SELECT "id" FROM "Workspace" WHERE "id" IN (${Prisma.join(workspaceIds)}) ORDER BY "id" FOR UPDATE`; + const locked = await tx.$queryRaw< + Array<{ id: string }> + >`SELECT "id" FROM "Runtime" WHERE "id" = ${input.id} FOR UPDATE`; + if (!locked.length) throw new TRPCError({ code: "NOT_FOUND", message: "Runtime not found." }); + const runtime = await tx.runtime.findUniqueOrThrow({ + where: { id: input.id }, + select: runtimeSnapshot, + }); + if ( + runtime.workspaceId !== input.expectedWorkspaceId || + runtime.ownerType !== input.expectedOwnerType || + runtime.ownerId !== input.expectedOwnerId || + runtime.updatedAt.getTime() !== input.expectedUpdatedAt.getTime() + ) { + throw new TRPCError({ + code: "CONFLICT", + message: + "Runtime ownership or configuration changed. Refresh the impact preview and confirm again.", + }); + } + if (runtime.name !== input.confirmName) + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Type the exact runtime name to confirm.", + }); + // Parent FK locks stop new references. Lock retained history too, so a + // concurrent resume cannot turn a terminal connection/session/run live + // between the impact read and the ownership change. + await tx.$queryRaw`SELECT "id" FROM "AgentConnection" WHERE "runtimeId" = ${input.id} ORDER BY "id" FOR UPDATE`; + await tx.$queryRaw`SELECT "id" FROM "ConnectorSession" WHERE "runtimeId" = ${input.id} ORDER BY "id" FOR UPDATE`; + await tx.$queryRaw`SELECT "id" FROM "RuntimeDiagnosticAttempt" WHERE "runtimeId" = ${input.id} ORDER BY "id" FOR UPDATE`; + await tx.$queryRaw`SELECT r."id" FROM "AgentRun" r JOIN "AgentConnection" c ON c."id" = r."connectionId" WHERE c."runtimeId" = ${input.id} ORDER BY r."id" FOR UPDATE OF r`; + await tx.$queryRaw`SELECT w."id" FROM "WorkSession" w WHERE w."ownerConnectionId" IN (SELECT "id" FROM "AgentConnection" WHERE "runtimeId" = ${input.id}) OR w."id" IN (SELECT p."workSessionId" FROM "WorkSessionParticipant" p JOIN "AgentConnection" c ON c."id" = p."connectionId" WHERE c."runtimeId" = ${input.id}) ORDER BY w."id" FOR UPDATE OF w`; + return runtime; +} + +export async function transferRuntime( + tx: Prisma.TransactionClient, + input: RuntimeTransferInput, + actorId: string, +) { + const before = await lockRuntimeLifecycle(tx, input, input.targetWorkspaceId); + const target = await tx.workspace.findUnique({ + where: { id: input.targetWorkspaceId }, + select: { id: true, deletedAt: true }, + }); + if (!target || target.deletedAt) + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Choose an active destination workspace.", + }); + if ((input.ownerType === "PERSONAL") !== !!input.ownerId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + "Personal ownership requires a user; service ownership must not have a personal owner.", + }); + } + if (input.ownerId) { + await tx.$queryRaw`SELECT "id" FROM "User" WHERE "id" = ${input.ownerId} FOR UPDATE`; + const owner = await tx.user.findUnique({ + where: { id: input.ownerId }, + select: { id: true, deletedAt: true, disabledAt: true, status: true }, + }); + const member = await tx.membership.findUnique({ + where: { + userId_workspaceId: { userId: input.ownerId, workspaceId: input.targetWorkspaceId }, + }, + }); + if (!owner || owner.deletedAt || owner.disabledAt || owner.status !== "ACTIVE" || !member) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "The personal owner must be an active member of the destination workspace.", + }); + } + } + const impact = await runtimeLifecycleImpact(tx, input.id, input.targetWorkspaceId); + if (impact.transferBlockers.length) + throw new TRPCError({ code: "CONFLICT", message: impact.transferBlockers.join(" ") }); + const updated = await tx.runtime.update({ + where: { id: input.id }, + data: { + workspaceId: input.targetWorkspaceId, + ownerType: input.ownerType, + ownerId: input.ownerId, + }, + select: runtimeSnapshot, + }); + await tx.instanceAuditLog.create({ + data: { + actorId, + action: "runtime.lifecycle.transfer", + metadata: { + runtimeId: input.id, + before: { + workspaceId: before.workspaceId, + ownerType: before.ownerType, + ownerId: before.ownerId, + }, + after: { + workspaceId: updated.workspaceId, + ownerType: updated.ownerType, + ownerId: updated.ownerId, + }, + }, + }, + }); + return updated; +} + +export async function removeRuntime( + tx: Prisma.TransactionClient, + input: RuntimeLifecycleExpected, + actorId: string, +) { + const before = await lockRuntimeLifecycle(tx, input); + const impact = await runtimeLifecycleImpact(tx, input.id); + if (impact.removeBlockers.length) + throw new TRPCError({ code: "CONFLICT", message: impact.removeBlockers.join(" ") }); + await tx.runtime.delete({ where: { id: input.id } }); + await tx.instanceAuditLog.create({ + data: { + actorId, + action: "runtime.lifecycle.remove", + metadata: { + runtimeId: before.id, + name: before.name, + workspaceId: before.workspaceId, + ownerType: before.ownerType, + ownerId: before.ownerId, + removedConfiguration: impact.counts, + externalHostStopped: false, + }, + }, + }); + return { removed: true as const, id: input.id, externalHostStopped: false as const }; +} + +export async function prepareRuntimeLifecycle( + tx: Prisma.TransactionClient, + input: RuntimeLifecycleExpected, + actorId: string, +) { + const before = await lockRuntimeLifecycle(tx, input); + const now = new Date(); + const runtime = await tx.runtime.update({ + where: { id: input.id }, + data: { + archivedAt: before.archivedAt ?? now, + disabledAt: before.disabledAt ?? now, + }, + select: runtimeSnapshot, + }); + await tx.instanceAuditLog.create({ + data: { + actorId, + action: "runtime.lifecycle.prepare", + metadata: { + runtimeId: input.id, + workspaceId: before.workspaceId, + externalHostStopped: false, + }, + }, + }); + return { runtime, externalHostStopped: false as const }; +} diff --git a/src/server/services/workspace-deletion.ts b/src/server/services/workspace-deletion.ts new file mode 100644 index 00000000..41dcb692 --- /dev/null +++ b/src/server/services/workspace-deletion.ts @@ -0,0 +1,51 @@ +import type { Prisma } from "@prisma/client"; +import { TRPCError } from "@trpc/server"; + +/** + * Must run inside an interactive transaction. The parent-row lock conflicts + * with the FK key-share lock used by concurrent runtime registrations, so a + * runtime cannot be inserted between the safety check and workspace deletion. + * Run optional cleanup only after these guards, while the workspace still + * reserves its unique slug. Buckets are slug-named: post-commit cleanup could + * delete a replacement workspace's bucket if that slug has been reused. + * Storage and Postgres remain non-atomic: a later DB failure cannot restore + * removed objects. This preserves the existing cleanup-before-delete contract. + */ +export async function deleteWorkspacePreservingRuntimes( + tx: Prisma.TransactionClient, + workspaceId: string, + confirmName: string, + beforeDelete?: () => Promise, +) { + const rows = await tx.$queryRaw>` + SELECT "id", "name" FROM "Workspace" WHERE "id" = ${workspaceId} FOR UPDATE + `; + const workspace = rows[0]; + if (!workspace) { + throw new TRPCError({ code: "NOT_FOUND", message: "Workspace no longer exists." }); + } + if (confirmName !== workspace.name) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Confirmation name does not match workspace name.", + }); + } + + // Runtime.workspaceId is still a cascading registration-home FK, while + // ownership and profile use can extend beyond this workspace. Include + // archived, disabled, unbound, and legacy ownerless hosts: archival does + // not abandon their configuration or invalidate references elsewhere. + const runtime = await tx.runtime.findFirst({ + where: { workspaceId }, + select: { id: true }, + }); + if (runtime) { + throw new TRPCError({ + code: "CONFLICT", + message: + "This workspace is the registration home of a runtime, including possibly an archived runtime. Deleting it would also remove that runtime and could affect agents elsewhere. Archive the workspace instead, or ask an instance administrator to review runtime relocation before deletion. Archiving a runtime does not remove this protection.", + }); + } + await beforeDelete?.(); + return tx.workspace.delete({ where: { id: workspaceId } }); +} diff --git a/tests/unit/agent-ownership-ui.test.ts b/tests/unit/agent-ownership-ui.test.ts new file mode 100644 index 00000000..ef20ed4b --- /dev/null +++ b/tests/unit/agent-ownership-ui.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { agentOwnerLabel, personalAgentCreationCopy } from "@/lib/agent-ownership-ui"; +import { matchesAgentView } from "@/lib/agent-setup"; + +describe("agent ownership presentation", () => { + it("shows service administration independently of a human owner", () => { + expect(agentOwnerLabel({ ownerType: "SERVICE", owner: null })).toBe( + "Service-owned · Instance administrators", + ); + }); + it("does not label missing personal ownership as service-owned or owned by the viewer", () => { + expect(agentOwnerLabel({ ownerType: "PERSONAL", owner: null })).toBe("Unavailable owner"); + expect( + agentOwnerLabel({ ownerType: "PERSONAL", owner: { name: "Bailey" }, ownedByMe: true }), + ).toBe("Bailey (you)"); + }); + it("offers personal creation only under an explicit supported policy", () => { + expect(personalAgentCreationCopy().enabled).toBe(false); + expect(personalAgentCreationCopy("DISABLED").enabled).toBe(false); + expect(personalAgentCreationCopy("APPROVAL_REQUIRED").label).toBe("Request my agent"); + expect(personalAgentCreationCopy("SELF_SERVICE").label).toBe("Create my agent"); + }); + it("does not confuse service ownership with instance sharing", () => { + const service = { + id: "service", + ownerType: "SERVICE", + ownedByMe: false, + instanceShared: false, + availability: { state: "AVAILABLE" }, + bindings: [], + }; + expect(matchesAgentView(service, "mine", true)).toBe(false); + expect(matchesAgentView(service, "available", true)).toBe(true); + expect(matchesAgentView(service, "available", false)).toBe(false); + expect(matchesAgentView({ ...service, instanceShared: true }, "available", false)).toBe(true); + }); +}); diff --git a/tests/unit/agent-setup.test.ts b/tests/unit/agent-setup.test.ts new file mode 100644 index 00000000..e2c21e4b --- /dev/null +++ b/tests/unit/agent-setup.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from "vitest"; +import { agentSetupDestination, matchesAgentView } from "@/lib/agent-setup"; + +const workspace = { id: "ws-one", slug: "my-workspace", role: "ADMIN" }; +const profile = { + id: "profile-one", + ownedByMe: true, + instanceShared: false, + availability: { state: "AVAILABLE" }, + bindings: [{ id: "agent-one", workspace: { id: workspace.id } }], +}; + +describe("agent setup navigation", () => { + it("requires an explicitly selected workspace", () => { + expect(agentSetupDestination({ mode: "external" })).toBeNull(); + }); + it("sends personal external clients to scoped personal token setup without a runtime", () => { + expect( + agentSetupDestination({ mode: "external", workspace: { ...workspace, role: "MEMBER" } }), + ).toBe("/w/my-workspace/settings/access?create=personal"); + }); + it("sends an available agent in the selected workspace to linked MCP setup", () => { + expect(agentSetupDestination({ mode: "external", workspace, profile })).toBe( + "/w/my-workspace/settings/access?create=agent&agentId=agent-one", + ); + }); + it("preserves selected workspace when an agent needs workspace access first", () => { + expect( + agentSetupDestination({ mode: "external", workspace, profile: { ...profile, bindings: [] } }), + ).toBe("/agents/profile-one?workspace=ws-one&setup=external"); + }); + it("never issues a key against a different workspace binding", () => { + expect( + agentSetupDestination({ + mode: "external", + workspace: { ...workspace, id: "ws-two" }, + profile, + }), + ).toBe("/agents/profile-one?workspace=ws-two&setup=external"); + }); + it("routes members to the agent detail rather than administrator credential setup", () => { + expect( + agentSetupDestination({ + mode: "external", + workspace: { ...workspace, role: "MEMBER" }, + profile, + }), + ).toBe("/agents/profile-one?workspace=ws-one&setup=external"); + }); + it("keeps managed execution and external credential routes distinct", () => { + expect(agentSetupDestination({ mode: "managed", workspace, profile })).toBe( + "/agents/profile-one?workspace=ws-one&setup=managed", + ); + expect( + agentSetupDestination({ + mode: "managed", + workspace: { ...workspace, role: "MEMBER" }, + profile, + }), + ).toBeNull(); + }); + it("blocks unavailable profiles and guests", () => { + for (const state of ["PENDING_APPROVAL", "DISABLED", "ARCHIVED"]) { + expect( + agentSetupDestination({ + mode: "external", + workspace, + profile: { ...profile, availability: { state } }, + }), + ).toBeNull(); + } + expect( + agentSetupDestination({ mode: "external", workspace: { ...workspace, role: "GUEST" } }), + ).toBeNull(); + }); +}); + +describe("agent catalog views", () => { + it("separates ownership from catalog sharing", () => { + const shared = { ...profile, ownedByMe: false, instanceShared: true }; + expect(matchesAgentView(shared, "mine", false)).toBe(false); + expect(matchesAgentView(shared, "available", false)).toBe(true); + expect(matchesAgentView(profile, "mine", false)).toBe(true); + expect(matchesAgentView(profile, "available", false)).toBe(true); + }); + it("reserves all-profiles view for instance admins and excludes unavailable agents from setup", () => { + const unavailable = { ...profile, availability: { state: "PENDING_APPROVAL" } }; + expect(matchesAgentView(unavailable, "mine", false)).toBe(true); + expect(matchesAgentView(unavailable, "available", false)).toBe(false); + expect(matchesAgentView(unavailable, "all", false)).toBe(false); + expect(matchesAgentView(unavailable, "all", true)).toBe(true); + expect(matchesAgentView({ ...profile, ownedByMe: false }, "available", true)).toBe(false); + }); +}); diff --git a/tests/unit/global-mission-control-operations.test.ts b/tests/unit/global-mission-control-operations.test.ts index 0e5b55d1..a391cf84 100644 --- a/tests/unit/global-mission-control-operations.test.ts +++ b/tests/unit/global-mission-control-operations.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { deriveGlobalOperationsPosture } from "@/components/global-shell/global-operations-model"; describe("deriveGlobalOperationsPosture", () => { - it("prioritizes missing runtime setup over idle queue state", () => { + it("does not require managed runtime setup for external MCP work", () => { expect( deriveGlobalOperationsPosture({ activeRuns: 0, @@ -11,12 +11,18 @@ describe("deriveGlobalOperationsPosture", () => { runtimesOnline: 0, }), ).toMatchObject({ - tone: "warning", - label: "Setup required", - actionLabel: "Register a runtime", + tone: "neutral", + label: "Managed execution optional", + actionLabel: "Set up an agent", + actionHref: "/agents", }); }); + it("keeps active external work visible without a managed runtime", () => { + expect(deriveGlobalOperationsPosture({ activeRuns: 1, agentsOnline: 1, runtimeCount: 0, runtimesOnline: 0 })) + .toMatchObject({ tone: "success", label: "Work in motion" }); + }); + it("surfaces degraded runtime coverage before active execution", () => { expect( deriveGlobalOperationsPosture({ diff --git a/tests/unit/personal-home.test.ts b/tests/unit/personal-home.test.ts new file mode 100644 index 00000000..9e3320e4 --- /dev/null +++ b/tests/unit/personal-home.test.ts @@ -0,0 +1,76 @@ +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const query = vi.hoisted(() => ({ + data: [] as unknown[], + isLoading: false, + isError: false, + refetch: vi.fn(), +})); +vi.mock("@/lib/trpc", () => ({ trpc: { global: { work: { useQuery: () => query } } } })); +vi.mock("@/components/global-shell/global-shell", () => ({ WorkspaceBadge: () => null })); +(globalThis as typeof globalThis & { React: typeof React }).React = React; +const { PersonalHome } = await import("@/components/global-shell/personal-home"); + +const workspace = { id: "ws-1", slug: "forge", name: "Forge", key: "FRG" }; +const render = () => + renderToStaticMarkup(React.createElement(PersonalHome, { workspaces: [workspace] })); + +describe("Personal Home", () => { + beforeEach(() => { + query.data = []; + query.isLoading = false; + query.isError = false; + }); + + it("offers everyday work without managed runtime setup", () => { + const html = render(); + expect(html).toContain("No open assignments"); + expect(html).toContain('href="/workspaces"'); + expect(html).toContain('href="/w/forge"'); + expect(html).toContain('href="/inbox"'); + expect(html).not.toContain("Register runtime"); + }); + + it("renders the full issue key, title and owning workspace destination", () => { + query.data = [ + { + id: "issue-1", + number: 20, + title: "Improve onboarding", + workspace, + status: { name: "In progress" }, + }, + ]; + const html = render(); + expect(html).toContain("FRG-20"); + expect(html).toContain("Improve onboarding"); + expect(html).toContain('href="/w/forge/issues/issue-1"'); + expect(html).toContain("In progress"); + }); + + it("caps the initial assignment list and offers expansion", () => { + query.data = Array.from({ length: 10 }, (_, index) => ({ + id: `issue-${index}`, + number: index, + title: `Task ${index}`, + workspace, + status: { name: "Todo" }, + })); + const html = render(); + expect(html).toContain("Task 7"); + expect(html).not.toContain("Task 8"); + expect(html).toContain("Show 2 more"); + expect(html).toContain('aria-expanded="false"'); + }); + + it("keeps workspace navigation available if assignments fail", () => { + query.isError = true; + const html = render(); + expect(html).toContain("Assignments are unavailable"); + expect(html).toContain("Try again"); + expect(html).toContain('href="/w/forge"'); + expect(html).not.toContain("No open assignments"); + }); +}); diff --git a/tests/unit/runtime-lifecycle-ui.test.ts b/tests/unit/runtime-lifecycle-ui.test.ts new file mode 100644 index 00000000..7fb431f2 --- /dev/null +++ b/tests/unit/runtime-lifecycle-ui.test.ts @@ -0,0 +1,27 @@ +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/trpc", () => ({ trpc: {} })); +(globalThis as typeof globalThis & { React: typeof React }).React = React; +const { RuntimeLifecycleActions } = await import("@/components/settings/runtime-lifecycle"); + +describe("runtime lifecycle entry points", () => { + it("does not render management actions without instance-admin capability", () => { + expect( + renderToStaticMarkup( + React.createElement(RuntimeLifecycleActions, { id: "runtime-1", canManage: false }), + ), + ).toBe(""); + }); + + it("makes archive, transfer and permanent removal distinct actions", () => { + const html = renderToStaticMarkup( + React.createElement(RuntimeLifecycleActions, { id: "runtime-1", canManage: true }), + ); + expect(html).toContain("Archive & disable"); + expect(html).toContain("Transfer ownership / home"); + expect(html).toContain("Remove registration"); + expect(html).not.toContain('role="dialog"'); + }); +}); diff --git a/tests/unit/runtime-ownership.test.ts b/tests/unit/runtime-ownership.test.ts new file mode 100644 index 00000000..6cfee312 --- /dev/null +++ b/tests/unit/runtime-ownership.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { canConfirmRuntimeLifecycle, runtimeOwnershipLabel } from "@/lib/runtime-ownership"; + +describe("runtime ownership presentation", () => { + it("distinguishes personal, service-owned, and unassigned without inferring from workspace", () => { + expect(runtimeOwnershipLabel({ ownerType: "PERSONAL", owner: { name: "Bailey" } })).toBe( + "Personal · Bailey", + ); + expect(runtimeOwnershipLabel({ ownerType: "SERVICE", owner: null })).toBe( + "Service-owned · instance managed", + ); + expect(runtimeOwnershipLabel({ ownerType: "UNASSIGNED", owner: null })).toBe( + "Unassigned · needs an ownership decision", + ); + }); + + it("does not disguise a missing personal owner as service-owned", () => { + expect(runtimeOwnershipLabel({ ownerType: "PERSONAL", owner: null })).toBe( + "Personal · Owner unavailable", + ); + }); +}); + +describe("runtime lifecycle confirmation", () => { + const ready = { + name: "Local runner", + confirmation: "Local runner", + blockers: [], + fetching: false, + pending: false, + }; + it("requires an exact typed name", () => { + expect(canConfirmRuntimeLifecycle(ready)).toBe(true); + expect(canConfirmRuntimeLifecycle({ ...ready, confirmation: "local runner" })).toBe(false); + expect(canConfirmRuntimeLifecycle({ ...ready, confirmation: "Local runner " })).toBe(false); + expect(canConfirmRuntimeLifecycle({ ...ready, name: "", confirmation: "" })).toBe(false); + }); + it("blocks during refresh and mutation or while dependencies prevent the action", () => { + expect(canConfirmRuntimeLifecycle({ ...ready, fetching: true })).toBe(false); + expect(canConfirmRuntimeLifecycle({ ...ready, pending: true })).toBe(false); + expect( + canConfirmRuntimeLifecycle({ ...ready, blockers: ["Active agent still uses this runtime."] }), + ).toBe(false); + }); + it("requires an explicit personal owner", () => { + expect(canConfirmRuntimeLifecycle({ ...ready, ownerRequired: true, ownerId: null })).toBe( + false, + ); + expect(canConfirmRuntimeLifecycle({ ...ready, ownerRequired: true, ownerId: "owner-1" })).toBe( + true, + ); + expect(canConfirmRuntimeLifecycle({ ...ready, ownerRequired: false, ownerId: null })).toBe( + true, + ); + }); +}); From a8a3b99896acb2371c585fe834f2d9eb2594c5de Mon Sep 17 00:00:00 2001 From: Bailey Dixon Date: Wed, 14 Oct 2026 11:35:10 -0400 Subject: [PATCH 2/7] feat: clarify item capture and personal task flow (AXI-203) --- DEVLOG.md | 45 + docs/guide/issues.md | 11 +- docs/guide/quickstart.md | 27 +- docs/guide/slash-commands.md | 14 +- src/app/(app)/w/[slug]/issues/page.tsx | 2 +- src/app/(app)/w/[slug]/projects/[id]/page.tsx | 1 + src/components/cycles/new-cycle-dialog.tsx | 4 +- .../dashboard/personal-dashboard.tsx | 33 +- .../initiatives/new-initiative-dialog.tsx | 4 +- .../projects/new-project-dialog.tsx | 4 +- src/components/quick-create.tsx | 1153 ++++++++--------- src/components/quick-notes-widget.tsx | 1 + src/components/sidebar.tsx | 20 +- src/components/top-bar.tsx | 9 +- src/components/ui/combobox.tsx | 12 +- src/components/ui/work-item-kind-glyph.tsx | 6 +- src/components/workspace-lazy-surfaces.tsx | 8 +- src/lib/workspace-surface-requests.ts | 23 + tests/e2e/issue-flow.spec.ts | 56 + tests/e2e/personal-workspace.spec.ts | 27 +- tests/unit/quick-create-modes.test.ts | 36 +- 21 files changed, 840 insertions(+), 656 deletions(-) diff --git a/DEVLOG.md b/DEVLOG.md index 4eae02af..fde4eb0b 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -14678,3 +14678,48 @@ publishes those prevalidated assets after tagging. Verification: focused runtime-diagnostic, MCP-execution, and helper-distribution tests passed (11/11); TypeScript passed; lint completed with only the existing repository warnings; helper tarballs and `SHA256SUMS` verified successfully. + +## 2026-09-25 — AXI-203 New Item and personal-task capture (in progress) + +Explicit New issue/New task controls now carry their destination into quick +create, so an issue-detail route cannot silently turn them into a comment. +Personal workspace capture and the Today composer persist `TASK` work items; +note-to-issue conversion remains an explicit issue. Quick create preserves Tab +focus navigation while Enter accepts autocomplete suggestions. Today separates +due-today items from unscheduled Someday work, exposes the due preset on mobile, +and links to the full Issues list instead of presenting its summary as the +entire task inventory. Quickstart and Issues guidance now describe the actual +capture behavior. + +First-class per-user reminders need a separate model and worker path rather +than the existing ScheduledTask create-issue automation; tracked in AXI-204. +Focused quick-create unit tests, TypeScript, and lint passed. The local Docker +engine and production browser sign-in were unavailable, so local Playwright, +integration checks, and rendered desktop/mobile UX review remain pending. + +## 2026-10-14 — AXI-203 local creation-flow review and verification + +Replaced the dense capture bar with one item-type selector, visible title and +description, direct status/priority/project/human-assignee/date controls, and +expandable agent/label options. Keyboard slash shortcuts remain available. +Explicit named creation controls preserve their intent through lazy loading, +including project actions on detail pages. Task creation uses the existing +TASK kind, now labeled Task whether standalone or nested. Draft restoration +retains content and selected fields within the workspace/issue context, and +planning-item full forms preserve the entered name. Date selection persists +atomically with creation. Fixed a shared Combobox callback that updated its +parent from inside a state updater. + +Personal Today keeps undated work in Someday, exposes date presets on mobile, +and makes its bounded recent-work summary explicit. The full reminders feature +remains AXI-204; due dates and issue snoozing do not imply reminders. + +Verification: focused lint and 12 quick-create unit checks passed. A production +build and three focused Playwright flows passed against disposable forge_e2e, +covering draft restore, saved TASK fields, named issue creation, keyboard focus, +status transitions, and mobile Today/Someday behavior. Captured and inspected +desktop, dark, and mobile UI evidence under output/playwright. The local preview +uses Node 20 with the standard Next compiler: Turbopack reproduced an existing +recordChange import failure on creation, and the workstation's Node 24 also +failed loading the Tailwind TypeScript config through the alternate compiler. +No production deployment or release was performed. diff --git a/docs/guide/issues.md b/docs/guide/issues.md index f0a2eab2..27444fc9 100644 --- a/docs/guide/issues.md +++ b/docs/guide/issues.md @@ -11,7 +11,7 @@ queue concept, bulk operations, and how relations link issues together. | ----------------- | -------- | ----------------------------------------------------------------------------- | | `id` | uuid | Internal identifier | | `key` | string | Public key, e.g. `WRK-42`. Workspace-prefixed and unique within the workspace | -| `kind` | enum | `ISSUE` or `TASK`. Tasks are lighter-weight; defaults are typically tighter | +| `kind` | enum | `EPIC`, `ISSUE`, or `TASK`. Tasks can stand alone or belong to a parent. | | `title` | string | Required, single line | | `description` | markdown | Optional, free-form | | `statusId` | fk | Status row in this workspace's status set | @@ -141,10 +141,13 @@ re-route. Two paths. **Quick create.** Press ⇧C from anywhere. The dialog covers title, -description, priority, project, and kind. ⌘ Enter submits. Creating +optional description, priority, project, assignee, labels, and due date. +Enter creates; ⌘ Enter creates and opens. Creating from a board column or status group also preserves that originating status; -creating from the command palette always opens issue mode, even while viewing -an existing issue. This is the primary creation path. +explicit **New issue** actions always open issue mode, even while viewing an +existing issue. On an issue detail page, ⇧C opens contextual comment +capture. A personal workspace's **New task** creates `TASK` work items rather +than `ISSUE` items. The QuickCreate input also recognises [slash commands](/guide/slash-commands.html) at the start of the line: diff --git a/docs/guide/quickstart.md b/docs/guide/quickstart.md index 8f2943d5..40cba173 100644 --- a/docs/guide/quickstart.md +++ b/docs/guide/quickstart.md @@ -63,23 +63,30 @@ accidentally triggering navigation. ## 4. Create your first issue -From the Issues page, press ⇧C to open the quick-create dialog. +From the Issues page, press ⇧C to open quick create. You can also use +**New issue** in the sidebar or top bar. Those explicit actions create an issue +even while you are viewing another issue; ⇧C on an issue detail page +opens a contextual comment instead. Fill in: - **Title** — required. Keep it short and verb-led ("Wire up onboarding email"). -- **Description** — optional. Markdown supported. -- **Status** — defaults to the workspace's default status. -- **Priority** — `NONE`, `LOW`, `MEDIUM`, `HIGH`, or `URGENT`. -- **Project** — optional. Skip for now. +- **Description** — optional and visible below the title. +- **Status**, **Priority**, **Project**, **Assignee**, and **Due date** — direct + field controls. Agent assignment and labels are under **More options**. -Press ⌘ Enter to create. The issue lands in the list with a fresh -key (e.g. `WRK-1`). +The item-type selector also offers **Task**, **Note**, and planning items. +Escape saves the content and selected fields as a workspace-scoped draft. +Tab moves through fields; slash commands remain optional keyboard shortcuts. -For richer creation (sub-issues, attachments, relations), use the full-page -form at `/w//issues/new`. The quick-create dialog covers ~95% of -day-to-day creation. +Press Enter to create, or ⌘ Enter to create and open. The +issue gets a fresh key (e.g. `WRK-1`). + +Add sub-issues, attachments, relations, and sprint membership on the issue +detail page after capture. In a personal workspace, **New task** and the Today +composer create lighter-weight tasks; a due date makes a task appear in Today +or Upcoming, but does not itself send a reminder. ## 5. Drop it into a project diff --git a/docs/guide/slash-commands.md b/docs/guide/slash-commands.md index 6865cf1d..9fced86d 100644 --- a/docs/guide/slash-commands.md +++ b/docs/guide/slash-commands.md @@ -68,22 +68,22 @@ without posting a comment: ## Autocomplete -Both the QuickCreate input (issue / sub-issue modes) and the comment -composer surface a small dropdown when the cursor sits on a -top-of-body line that starts with `/`. The dropdown lists the seven -command keywords + a one-line example for each. +QuickCreate offers optional slash shortcuts at the end of the title (after a +space). Its visible field controls work without typing commands. The comment +composer offers its dropdown in a top-of-body line that starts with `/`. Keys: | Key | Effect | |---|---| | ↓ / ↑ | Move the active selection | -| Enter / Tab | Insert the active command's stub (e.g. `/assign `) | +| Enter | Insert the active command's stub (e.g. `/assign `) | +| Tab | Move to the next field in QuickCreate; insert the suggestion in the comment composer | | Esc | Dismiss without inserting | | Click | Inserts as if Enter were pressed | -So typing `/a` then Tab expands to `/assign ` with the -caret right after the trailing space — ready for the argument. The +So typing `/a` then Enter expands to `/assign ` with the +caret right after the trailing space — ready for the argument. In comments, the dropdown only opens when the line is in the contiguous top-of-body slash block; once you've typed prose below, it stops auto-opening on later slashes. diff --git a/src/app/(app)/w/[slug]/issues/page.tsx b/src/app/(app)/w/[slug]/issues/page.tsx index 5fcf6c71..e5574669 100644 --- a/src/app/(app)/w/[slug]/issues/page.tsx +++ b/src/app/(app)/w/[slug]/issues/page.tsx @@ -515,7 +515,7 @@ export default function IssuesPage() { } action={ - } diff --git a/src/app/(app)/w/[slug]/projects/[id]/page.tsx b/src/app/(app)/w/[slug]/projects/[id]/page.tsx index a5f74e7e..dec631e1 100644 --- a/src/app/(app)/w/[slug]/projects/[id]/page.tsx +++ b/src/app/(app)/w/[slug]/projects/[id]/page.tsx @@ -154,6 +154,7 @@ export default function ProjectDetailPage({ params }: { params: Promise<{ id: st variant="ember" size="sm" data-quick-create + data-quick-create-mode="issue" data-quick-create-project={project.id} > New issue diff --git a/src/components/cycles/new-cycle-dialog.tsx b/src/components/cycles/new-cycle-dialog.tsx index 501902c6..aa80be83 100644 --- a/src/components/cycles/new-cycle-dialog.tsx +++ b/src/components/cycles/new-cycle-dialog.tsx @@ -23,14 +23,16 @@ export function NewCycleDialog({ onCreated, defaultLengthDays, activeCycleName, + initialName = "", }: { open: boolean; onClose: () => void; onCreated?: () => void; defaultLengthDays?: number; activeCycleName?: string | null; + initialName?: string; }) { - const [name, setName] = useState(""); + const [name, setName] = useState(initialName); const [startsAt, setStartsAt] = useState(""); const [lengthDays, setLengthDays] = useState(""); const [status, setStatus] = useState(CycleStatus.PLANNED); diff --git a/src/components/dashboard/personal-dashboard.tsx b/src/components/dashboard/personal-dashboard.tsx index 8d802c81..900b92da 100644 --- a/src/components/dashboard/personal-dashboard.tsx +++ b/src/components/dashboard/personal-dashboard.tsx @@ -3,6 +3,7 @@ import Link from "next/link"; import { useEffect, useMemo, useState } from "react"; import type { inferRouterOutputs } from "@trpc/server"; +import { WorkItemKind } from "@prisma/client"; import { ArrowRight, Bot, @@ -108,8 +109,9 @@ export function PersonalDashboard() { (b.dueDate?.getTime() ?? Number.MAX_SAFE_INTEGER); return { overdue: overdue.sort(byDue), - today: [...today.sort(byDue), ...unscheduled], + today: today.sort(byDue), upcoming: upcoming.sort(byDue), + someday: unscheduled, }; }, [active.data?.items, endToday, startToday]); @@ -127,6 +129,7 @@ export function PersonalDashboard() { if (duePreset === "TOMORROW") dueDate.setDate(dueDate.getDate() + 1); createTask.mutate({ title, + kind: WorkItemKind.TASK, dueDate: duePreset === "SOMEDAY" ? undefined : dueDate, }); } @@ -171,8 +174,8 @@ export function PersonalDashboard() {

    {buckets.today.length === 0 - ? "Your day is clear. Capture what matters next." - : `${buckets.today.length} ${buckets.today.length === 1 ? "task" : "tasks"} in view for today.`} + ? "No tasks due today in this view. Capture what matters next." + : `${buckets.today.length} ${buckets.today.length === 1 ? "task" : "tasks"} due today in this view.`}

    {railCollapsed && ( @@ -208,7 +211,7 @@ export function PersonalDashboard() { { value: "SOMEDAY", label: "Someday" }, ]} ariaLabel="Task due date" - className="hidden h-8 text-xs text-muted-foreground sm:inline-flex" + className="h-8 text-xs text-muted-foreground" />
    + {buckets.someday.length > 0 && ( + doneStatus && completeTask.mutate({ id, statusId: doneStatus.id })} + canComplete={Boolean(doneStatus)} + /> + )} + + View all work + + {active.data?.nextCursor && ( +

    + Showing the 100 most recently updated open items. View all work for the full list. +

    + )}
    )} diff --git a/src/components/initiatives/new-initiative-dialog.tsx b/src/components/initiatives/new-initiative-dialog.tsx index 257bf6e2..0f50faa8 100644 --- a/src/components/initiatives/new-initiative-dialog.tsx +++ b/src/components/initiatives/new-initiative-dialog.tsx @@ -27,12 +27,14 @@ export function NewInitiativeDialog({ open, onClose, onCreated, + initialName = "", }: { open: boolean; onClose: () => void; onCreated?: () => void; + initialName?: string; }) { - const [name, setName] = useState(""); + const [name, setName] = useState(initialName); const [slug, setSlug] = useState(""); const [description, setDescription] = useState(""); const [targetDate, setTargetDate] = useState(""); diff --git a/src/components/projects/new-project-dialog.tsx b/src/components/projects/new-project-dialog.tsx index 12cb5290..359e249e 100644 --- a/src/components/projects/new-project-dialog.tsx +++ b/src/components/projects/new-project-dialog.tsx @@ -36,12 +36,14 @@ export function NewProjectDialog({ open, onClose, onCreated, + initialName = "", }: { open: boolean; onClose: () => void; onCreated?: (project: { id: string; name: string; key: string }) => void; + initialName?: string; }) { - const [name, setName] = useState(""); + const [name, setName] = useState(initialName); const [key, setKey] = useState(""); const [description, setDescription] = useState(""); const [color, setColor] = useState(DEFAULT_COLORS[0]); diff --git a/src/components/quick-create.tsx b/src/components/quick-create.tsx index 94d3b922..5f416ebc 100644 --- a/src/components/quick-create.tsx +++ b/src/components/quick-create.tsx @@ -10,23 +10,17 @@ import { } from "react"; import { createPortal } from "react-dom"; import { useRouter, usePathname } from "next/navigation"; -import { - AlertCircle, - Calendar, - ChevronDown, - CornerDownLeft, - Github, - GitPullRequest, - Loader2, - Plus, - X, -} from "lucide-react"; +import { AlertCircle, Calendar, Github, GitPullRequest, Loader2, X } from "lucide-react"; import { toast } from "sonner"; -import { ArtifactType, NotificationSeverity } from "@prisma/client"; +import { ArtifactType, NotificationSeverity, WorkItemKind } from "@prisma/client"; import { cn } from "@/lib/utils"; import { parseGitHubIssueOrPrRef } from "@/lib/github-ref"; import { MOTION } from "@/lib/motion"; import { Kbd } from "@/components/ui/kbd"; +import { Combobox } from "@/components/ui/combobox"; +import { Button } from "@/components/ui/button"; +import { useModKeyLabel } from "@/lib/platform"; +import { quickCreateRequestFromDataset } from "@/lib/workspace-surface-requests"; import { useHotkey } from "@/lib/keyboard"; import { trpc } from "@/lib/trpc"; import { useMaybeWorkspace } from "@/hooks/use-workspace"; @@ -224,15 +218,25 @@ export type QuickCreateOverride = { body?: string; archiveNoteId?: string; /** Bypass pathname-aware capture when the caller promises a new issue. */ - mode?: "issue"; + mode?: Exclude | "task"; }; +export function resolveWorkItemKind( + experienceProfile: string | null | undefined, + override?: QuickCreateOverride, +): WorkItemKind { + if (override?.mode === "task") return WorkItemKind.TASK; + if (override?.mode === "issue") return WorkItemKind.ISSUE; + return experienceProfile === "PERSONAL" ? WorkItemKind.TASK : WorkItemKind.ISSUE; +} + /** Resolve the initial capture mode from the current route and caller intent. */ export function resolveQuickCreateMode(path: string | null, override?: QuickCreateOverride): Mode { // Explicit issue actions (for example the command palette) must stay // issues even on an issue-detail route, where contextual capture defaults // to a comment. Seeded note conversions have the same one-shot intent. - if (override?.mode === "issue" || override?.title || override?.body) { + if (override?.mode) return { kind: override.mode === "task" ? "issue" : override.mode }; + if (override?.title || override?.body) { return { kind: "issue" }; } if (!path) return { kind: "issue" }; @@ -258,7 +262,7 @@ export function resolveQuickCreateMode(path: string | null, override?: QuickCrea const DRAFT_KEY = "quickCreate"; -// Cyclable mode order for Tab / Shift+Tab / ⌘1..⌘7 mode jumping. +// Mode order for ⌘1..⌘7 shortcuts. // Ordered by how often operators reach for each capture target — // issue first, then notes, then the planning surfaces, then artifacts // and action-requests. `issue-context` is excluded because it's @@ -280,53 +284,28 @@ const CYCLABLE_MODES: readonly CyclableMode[] = [ "artifact", "action-request", ] as const; -const CYCLABLE_MODE_LABEL: Record = { - issue: "issue", - note: "note", - project: "project", - initiative: "initiative", - cycle: "sprint", - artifact: "artifact", - "action-request": "ask", -}; type DraftShape = { text: string; mode?: Mode["kind"]; + issueKind?: WorkItemKind; + description?: string; + priority?: Priority; + projectId?: string; + statusId?: string; + assigneeId?: string; + artifactType?: ArtifactType; + severity?: NotificationSeverity; + commands?: Array | { kind: "due"; date: string }>; }; /** - * Context-aware capture. `⇧C` opens a Linear-style floating input at the - * top of the viewport — single line, fast, keyboard-first. The container - * is NOT a modal: it doesn't dim the page, and clicking outside simply - * closes. The mode dropdown spans the full agentic-work-OS surface so any - * intent (issue / sprint / project / initiative / note / artifact / - * action request) can be captured without navigating first. - * - * For issue capture the title field is a TOKENIZED bar: type a `/` - * command and the value autocomplete surfaces real projects / agents / - * labels / priorities / due dates. Tab (or Enter / click) "catches" the - * pick as a coloured badge inside the box and strips the `/command arg` - * text from the title. Backspace at the start of an empty caret removes - * the last badge. The autocomplete popover is PORTALED to `document.body` - * so it's never clipped by the card. - * - * Pathname determines the *initial* mode (set when the overlay opens): - * - * /w/*\/cycles → "cycle" (⏎ create · ⌘⏎ full form) - * /w/*\/initiatives → "initiative" (⏎ create · ⌘⏎ full form) - * /w/*\/projects → "project" (⏎ create · ⌘⏎ full form) - * /w/*\/artifacts → "artifact" (⏎ create · ⌘⏎ create + open) - * /w/*\/issues/:id → "issue-context" (comment | sub-issue tabs) - * anywhere else → "issue" (⏎ create · ⌘⏎ create + open) - * - * Draft behavior: if the user has typed anything and dismisses with `⎋`, - * the text is persisted under `forge.draft.quickCreate` for 24h. The next - * `⇧C` open restores it with a small "Restored" pill. - * - * For modes where a single line isn't enough (initiative wants slug + - * description + color, project wants key + color), `⌘⏎` escalates to the - * existing full-form dialog with the typed value pre-filled as the name. + * Workspace capture with visible fields and an explicit item-type selector. + * Named creation actions preserve their caller's intent; Shift+C can use the + * current route for contextual comments or planning items. Slash commands + * remain an optional title shortcut. Drafts retain content and fields, scoped + * to the workspace and issue context. Planning items can open their full form + * with the entered name preserved. */ export function QuickCreate({ initialOpen }: { initialOpen?: QuickCreateOverride }) { // A lazy-mounted first request must render the dialog in its initial @@ -334,11 +313,15 @@ export function QuickCreate({ initialOpen }: { initialOpen?: QuickCreateOverride // data through openFor, but does not leave the first click waiting on it. const [open, setOpen] = useState(Boolean(initialOpen)); const [mode, setMode] = useState({ kind: "issue" }); + const [issueKind, setIssueKind] = useState(WorkItemKind.ISSUE); const [text, setText] = useState(""); const [priority, setPriority] = useState("NONE"); const [projectId, setProjectId] = useState(""); // Contextual board/list adds pin the new issue to the originating status. const [statusId, setStatusId] = useState(""); + const [assigneeId, setAssigneeId] = useState(""); + const [pickerOpen, setPickerOpen] = useState(false); + const [submissionError, setSubmissionError] = useState(null); // Slash commands the operator has "committed" into chips: assign / due / // label / watch / unwatch (and any /project whose key didn't resolve to a // loaded project). Priority + a resolved project sync onto the native @@ -380,6 +363,8 @@ export function QuickCreate({ initialOpen }: { initialOpen?: QuickCreateOverride const router = useRouter(); const pathname = usePathname(); const ws = useMaybeWorkspace(); + const mod = useModKeyLabel(); + const draftKey = `${DRAFT_KEY}.${ws?.id ?? "global"}.${pathname?.match(/\/issues\/([^/?#]+)/)?.[1] ?? "new"}`; const utils = trpc.useUtils(); const invalidateIssueViews = async () => { @@ -403,6 +388,12 @@ export function QuickCreate({ initialOpen }: { initialOpen?: QuickCreateOverride const { data: labels } = trpc.label.list.useQuery(undefined, { enabled: open && isIssueLike, }); + const { data: statuses } = trpc.status.list.useQuery(undefined, { + enabled: open && isIssueLike, + }); + const { data: members } = trpc.workspace.members.useQuery(undefined, { + enabled: open && isIssueLike, + }); // Lookups so committed assign/label badges can render an avatar / colour. const agentByHandle = useMemo(() => { @@ -514,14 +505,30 @@ export function QuickCreate({ initialOpen }: { initialOpen?: QuickCreateOverride // Don't persist the draft when we were seeded from an external // event (note convert) — the user's intent was a one-shot, and // restoring "From note: …" later would be confusing. - if (persistDraft && text.trim().length > 0 && !archiveNoteId) { - saveDraft(DRAFT_KEY, { text, mode: mode.kind }); + if (persistDraft && (text.trim() || seedDescription.trim()) && !archiveNoteId) { + saveDraft(draftKey, { + text, + mode: mode.kind, + issueKind, + description: seedDescription, + priority, + projectId, + statusId, + assigneeId, + artifactType, + severity, + commands: committed.map((command) => + command.kind === "due" ? { kind: "due", date: command.date.toISOString() } : command, + ), + }); } setOpen(false); setText(""); setPriority("NONE"); setProjectId(""); setStatusId(""); + setAssigneeId(""); + setPickerOpen(false); setCommitted([]); setRestored(false); setAcActive(0); @@ -533,12 +540,34 @@ export function QuickCreate({ initialOpen }: { initialOpen?: QuickCreateOverride setArtifactType(ArtifactType.DOCUMENT); setSeverity(NotificationSeverity.INFO); }, - [text, mode.kind, archiveNoteId], + [ + text, + mode.kind, + archiveNoteId, + seedDescription, + issueKind, + priority, + projectId, + statusId, + assigneeId, + committed, + draftKey, + artifactType, + severity, + ], ); const openFor = useCallback( (override?: QuickCreateOverride) => { const effectiveMode = resolveQuickCreateMode(pathname, override); + setSubmissionError(null); + setIssueKind(resolveWorkItemKind(ws?.experienceProfile, override)); + setShowDescription( + effectiveMode.kind === "issue" || + effectiveMode.kind === "note" || + effectiveMode.kind === "artifact" || + effectiveMode.kind === "action-request", + ); const seeded = !!(override?.title || override?.body); setMode(effectiveMode); if (override?.projectId) setProjectId(override.projectId); @@ -553,10 +582,28 @@ export function QuickCreate({ initialOpen }: { initialOpen?: QuickCreateOverride setRestored(false); } else { // Hydrate a draft if present + mode matches. - const draft = readDraft(DRAFT_KEY); - if (draft && draft.text) { - if (!draft.mode || draft.mode === effectiveMode.kind) { + const draft = readDraft(draftKey); + if (draft && (draft.text || draft.description)) { + if ( + (!draft.mode || draft.mode === effectiveMode.kind) && + (!draft.issueKind || + draft.issueKind === resolveWorkItemKind(ws?.experienceProfile, override)) + ) { setText(draft.text); + setSeedDescription(draft.description ?? ""); + setPriority(draft.priority ?? "NONE"); + setProjectId(override?.projectId ?? draft.projectId ?? ""); + setStatusId(override?.statusId ?? draft.statusId ?? ""); + setAssigneeId(draft.assigneeId ?? ""); + setArtifactType(draft.artifactType ?? ArtifactType.DOCUMENT); + setSeverity(draft.severity ?? NotificationSeverity.INFO); + setCommitted( + (draft.commands ?? []).flatMap((command): SlashCommand[] => { + if (command.kind !== "due") return [command]; + const date = new Date(command.date); + return Number.isNaN(date.getTime()) ? [] : [{ kind: "due", date }]; + }), + ); setRestored(true); } else { setRestored(false); @@ -567,7 +614,7 @@ export function QuickCreate({ initialOpen }: { initialOpen?: QuickCreateOverride } setOpen(true); }, - [pathname], + [pathname, ws?.experienceProfile, draftKey], ); const initialOpenRef = useRef(initialOpen); @@ -588,8 +635,7 @@ export function QuickCreate({ initialOpen }: { initialOpen?: QuickCreateOverride const clickHandler = (e: MouseEvent) => { const el = (e.target as HTMLElement).closest("[data-quick-create]") as HTMLElement | null; if (!el) return; - const pid = el.dataset.quickCreateProject; - openFor(pid ? { projectId: pid } : undefined); + openFor(quickCreateRequestFromDataset(el.dataset)); }; document.addEventListener("click", clickHandler); const evtHandler = (e: Event) => { @@ -611,12 +657,14 @@ export function QuickCreate({ initialOpen }: { initialOpen?: QuickCreateOverride if (!open) return; const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") { + if (pickerOpen) return; e.preventDefault(); close(true); } }; const onDocClick = (e: MouseEvent) => { const target = e.target as Node; + if (pickerOpen) return; if (containerRef.current?.contains(target)) return; if (acPopoverRef.current?.contains(target)) return; close(true); @@ -627,7 +675,7 @@ export function QuickCreate({ initialOpen }: { initialOpen?: QuickCreateOverride window.removeEventListener("keydown", onKey); document.removeEventListener("mousedown", onDocClick); }; - }, [open, close]); + }, [open, close, pickerOpen]); // Autofocus input when we open. useEffect(() => { @@ -639,7 +687,7 @@ export function QuickCreate({ initialOpen }: { initialOpen?: QuickCreateOverride const modeLabel = useMemo(() => { switch (mode.kind) { case "issue": - return "Issue"; + return issueKind === WorkItemKind.TASK ? "Task" : "Issue"; case "cycle": return "Sprint"; case "initiative": @@ -655,12 +703,12 @@ export function QuickCreate({ initialOpen }: { initialOpen?: QuickCreateOverride case "issue-context": return mode.intent === "comment" ? "Comment" : "Sub-issue"; } - }, [mode]); + }, [mode, issueKind]); const placeholder = useMemo(() => { switch (mode.kind) { case "issue": - return "Issue title… (⌘⏎ create + open)"; + return `${issueKind === WorkItemKind.TASK ? "Task" : "Issue"} title… (⌘⏎ create + open)`; case "cycle": return "Sprint name… (⌘⏎ more options)"; case "initiative": @@ -668,7 +716,7 @@ export function QuickCreate({ initialOpen }: { initialOpen?: QuickCreateOverride case "project": return "Project name… (⌘⏎ more options)"; case "note": - return "Note (one-liner; ⌘⏎ to add description)"; + return "Note title or quick note…"; case "artifact": return "Artifact title… (⌘⏎ create + open)"; case "action-request": @@ -678,18 +726,11 @@ export function QuickCreate({ initialOpen }: { initialOpen?: QuickCreateOverride ? "Write a comment… (⏎ post)" : "Sub-issue title… (⏎ create)"; } - }, [mode]); + }, [mode, issueKind]); // Some modes escalate on ⌘⏎; others have a real secondary action. const hasEscalation = mode.kind === "cycle" || mode.kind === "initiative" || mode.kind === "project"; - const secondaryHint = hasEscalation - ? "⌘⏎ more options" - : mode.kind === "issue" || mode.kind === "artifact" - ? "⌘⏎ create + open" - : mode.kind === "note" || mode.kind === "action-request" - ? "⌘⏎ add description" - : null; // Compose the final issue input from the raw title + committed chips + // any command still trailing in the title. Returns the cleaned title, @@ -736,11 +777,12 @@ export function QuickCreate({ initialOpen }: { initialOpen?: QuickCreateOverride const value = text.trim(); if (!value) return; if (busy) return; + setSubmissionError(null); // Shared success: toast + close + clear draft + invalidate. const done = (msg: string) => { toast.success(msg); - clearDraft(DRAFT_KEY); + clearDraft(draftKey); close(false); }; @@ -806,12 +848,15 @@ export function QuickCreate({ initialOpen }: { initialOpen?: QuickCreateOverride const archiveTargetNoteId = archiveNoteId && archiveOnCreate ? archiveNoteId : null; const issue = await createIssue.mutateAsync({ title: finalTitle, + kind: issueKind, + assigneeIds: assigneeId ? [assigneeId] : [], description: seededDesc, projectId: projectIdValue || undefined, statusId: statusId || undefined, priority: priorityValue, labelIds: [], - applyCommands, + dueDate: applyCommands?.find((command) => command.kind === "due")?.date, + applyCommands: applyCommands?.filter((command) => command.kind !== "due"), }); await invalidateIssueViews(); if (archiveTargetNoteId) { @@ -882,8 +927,8 @@ export function QuickCreate({ initialOpen }: { initialOpen?: QuickCreateOverride setShowDescription(true); return; } - const titleVal = showDescription ? value : null; - const bodyVal = showDescription ? seedDescription.trim() : value; + const titleVal = seedDescription.trim() ? value : null; + const bodyVal = seedDescription.trim() || value; if (!bodyVal) { toast.error("Note body required."); return; @@ -941,6 +986,10 @@ export function QuickCreate({ initialOpen }: { initialOpen?: QuickCreateOverride } const issue = await createIssue.mutateAsync({ title: resolved.finalTitle, + description: seedDescription.trim() || undefined, + kind: contextIssue?.kind ?? issueKind, + assigneeIds: assigneeId ? [assigneeId] : [], + statusId: statusId || undefined, // Sub-issues inherit the parent's project; a resolved // /project on the sub-issue is ignored, but an unresolved one // still rides along in applyCommands for the server to apply. @@ -948,7 +997,8 @@ export function QuickCreate({ initialOpen }: { initialOpen?: QuickCreateOverride parentId: mode.issueId, priority: resolved.priorityValue, labelIds: [], - applyCommands: resolved.applyCommands, + dueDate: resolved.applyCommands?.find((command) => command.kind === "due")?.date, + applyCommands: resolved.applyCommands?.filter((command) => command.kind !== "due"), }); await invalidateIssueViews(); done(`Created sub-issue #${issue.number}`); @@ -956,8 +1006,12 @@ export function QuickCreate({ initialOpen }: { initialOpen?: QuickCreateOverride return; } } - } catch { - // mutation's onError already surfaced a toast + } catch (error) { + setSubmissionError( + error instanceof Error + ? error.message + : "Could not create this item. Your draft is preserved.", + ); } } @@ -1130,18 +1184,6 @@ export function QuickCreate({ initialOpen }: { initialOpen?: QuickCreateOverride [text, applyCommand, focusInputEnd], ); - // Click an "add" pill: prime the matching slash stub + reopen the - // popover so mouse users get the same value picker as `/`. - const primeSlash = useCallback( - (keyword: string) => { - const base = text.replace(/\s+$/, ""); - setText((base ? base + " " : "") + "/" + keyword + " "); - setAcDismissed(false); - focusInputEnd(); - }, - [text, focusInputEnd], - ); - // Commit a trailing `/command arg` in the title into a chip, stripping // it (and the whitespace before it) from the title. Returns true when a // command was committed. Bound to Enter in the title input. @@ -1163,43 +1205,63 @@ export function QuickCreate({ initialOpen }: { initialOpen?: QuickCreateOverride setCommitted((prev) => prev.map((c) => (c.kind === "assign" ? { ...c, mode: assignMode } : c))); }, []); - const hasBadges = priority !== "NONE" || !!projectId || committed.length > 0; - - // Backspace at caret-start pops the most-recent badge (committed → - // project → priority). - const removeLastBadge = useCallback(() => { - if (committed.length > 0) { - setCommitted((prev) => prev.slice(0, -1)); - return; - } - if (projectId) { - setProjectId(""); - return; - } - if (priority !== "NONE") setPriority("NONE"); - }, [committed.length, projectId, priority]); - - const switchMode = useCallback((kind: CyclableMode) => { - setMode({ kind } as Mode); - setPriority("NONE"); - setProjectId(""); - setCommitted([]); - setAcActive(0); - setAcDismissed(false); - inputRef.current?.focus(); - }, []); + const switchMode = useCallback( + (kind: CyclableMode, requestedKind?: WorkItemKind) => { + const nextIssueKind = requestedKind ?? resolveWorkItemKind(ws?.experienceProfile); + setMode({ kind } as Mode); + setShowDescription(["issue", "note", "artifact", "action-request"].includes(kind)); + if (kind === "issue") { + setIssueKind(nextIssueKind); + } + if (kind !== mode.kind) { + setPriority("NONE"); + setProjectId(""); + setStatusId(""); + setAssigneeId(""); + setCommitted([]); + } + const draft = readDraft(draftKey); + if ( + !text.trim() && + !seedDescription.trim() && + draft?.mode === kind && + (kind !== "issue" || !draft.issueKind || draft.issueKind === nextIssueKind) + ) { + setText(draft.text); + setSeedDescription(draft.description ?? ""); + setPriority(draft.priority ?? "NONE"); + setProjectId(draft.projectId ?? ""); + setStatusId(draft.statusId ?? ""); + setAssigneeId(draft.assigneeId ?? ""); + setArtifactType(draft.artifactType ?? ArtifactType.DOCUMENT); + setSeverity(draft.severity ?? NotificationSeverity.INFO); + setCommitted( + (draft.commands ?? []).flatMap((command): SlashCommand[] => { + if (command.kind !== "due") return [command]; + const date = new Date(command.date); + return Number.isNaN(date.getTime()) ? [] : [{ kind: "due", date }]; + }), + ); + setRestored(true); + } + setAcActive(0); + setAcDismissed(false); + inputRef.current?.focus(); + }, + [ws?.experienceProfile, mode.kind, draftKey, text, seedDescription], + ); // Live "this tail is a valid command" detection — drives the inline // "↵ apply …" hint so the operator sees the command is recognised // before committing it. Suppressed while the autocomplete is open - // (the popover already shows what ⏎/Tab will do). + // (the popover already shows what ⏎ will do). const pendingCommand = useMemo( () => (isIssueLike && !acVisible ? matchTrailingCommand(text) : null), [isIssueLike, acVisible, text], ); function onKeyDown(e: React.KeyboardEvent) { - // Autocomplete owns Arrow / Enter / Tab / Esc while it's open. + // Autocomplete owns Arrow / Enter / Esc while it's open. Tab exits it. if (acVisible) { if (e.key === "ArrowDown") { e.preventDefault(); @@ -1211,7 +1273,12 @@ export function QuickCreate({ initialOpen }: { initialOpen?: QuickCreateOverride setAcActive((a) => (a - 1 + suggestions.length) % suggestions.length); return; } - if (e.key === "Enter" || e.key === "Tab") { + if (e.key === "Tab") { + // Tab moves focus through the form; Enter accepts a suggestion. + setAcDismissed(true); + return; + } + if (e.key === "Enter") { e.preventDefault(); const s = suggestions[acActive]; if (s) applySuggestion(s); @@ -1237,27 +1304,6 @@ export function QuickCreate({ initialOpen }: { initialOpen?: QuickCreateOverride return; } - // Backspace at the very start of an empty caret removes the last badge. - if (e.key === "Backspace" && hasBadges) { - const el = inputRef.current; - if (el && el.selectionStart === 0 && el.selectionEnd === 0) { - e.preventDefault(); - removeLastBadge(); - return; - } - } - - // Tab / Shift+Tab cycles modes — only when the title is empty, so it - // never fights tokenization mid-type. issue-context is sticky. - if (e.key === "Tab" && mode.kind !== "issue-context" && text.trim() === "") { - e.preventDefault(); - const dir = e.shiftKey ? -1 : 1; - const idx = CYCLABLE_MODES.indexOf(mode.kind as CyclableMode); - const next = CYCLABLE_MODES[(idx + dir + CYCLABLE_MODES.length) % CYCLABLE_MODES.length]; - switchMode(next); - return; - } - // ⌘1..⌘7 jump straight to a mode without dragging through the // dropdown — fastest path for muscle memory. if ((e.metaKey || e.ctrlKey) && /^[1-7]$/.test(e.key) && mode.kind !== "issue-context") { @@ -1275,321 +1321,405 @@ export function QuickCreate({ initialOpen }: { initialOpen?: QuickCreateOverride // Full-form escalation takes over when open. if (fullForm) { if (fullForm.kind === "cycle") { - return setFullForm(null)} />; + return ( + setFullForm(null)} /> + ); } if (fullForm.kind === "initiative") { - return setFullForm(null)} />; + return ( + setFullForm(null)} + /> + ); } if (fullForm.kind === "project") { - return setFullForm(null)} />; + return ( + setFullForm(null)} + /> + ); } } if (!open) return null; - const isEmpty = text.trim() === ""; - const showModesLegend = mode.kind !== "issue-context" && isEmpty; + const selectedAgent = committed.find((command) => command.kind === "assign"); + const selectedDue = committed.find((command) => command.kind === "due"); + const dueValue = + selectedDue?.kind === "due" + ? [ + selectedDue.date.getFullYear(), + String(selectedDue.date.getMonth() + 1).padStart(2, "0"), + String(selectedDue.date.getDate()).padStart(2, "0"), + ].join("-") + : ""; + const typeValue = mode.kind === "issue" && issueKind === WorkItemKind.TASK ? "task" : mode.kind; return ( -
    +
    -
    -
    -
    - Create {modeLabel.toLowerCase()} -
    -

    - Pick the target, add context, then press Enter to create. -

    -
    -
    - {restored && ( - - Restored - - )} - -
    -
    - - {/* Capture row: the tokenized box + Create button */} -
    -
    -
    inputRef.current?.focus()} - className="flex flex-wrap items-center gap-1.5 rounded-lg border border-input bg-background/40 px-2 py-1.5 transition-colors focus-within:border-ember/60" - > - { - if (mode.kind !== "issue-context") return; - setMode({ - ...mode, - intent: mode.intent === "comment" ? "sub-issue" : "comment", - }); - }} - onSwitch={(kind) => { - if (mode.kind === "issue-context") return; - switchMode(kind as CyclableMode); - }} - /> - {isIssueLike && ( - setPriority("NONE")} - onRemoveProject={() => setProjectId("")} - onRemoveCommitted={removeCommitted} - onSetAssignMode={setAssignMode} - /> - )} - { - setText(e.target.value); - setAcDismissed(false); +
    +
    + {mode.kind === "issue-context" ? ( +
    + + +
    + ) : ( + item.kind !== "issue").map((item) => ({ + value: item.kind, + label: item.label, + })), + ]} + onChange={(value) => { + if (!value) return; + switchMode( + (value === "task" ? "issue" : value) as CyclableMode, + value === "task" + ? WorkItemKind.TASK + : value === "issue" + ? WorkItemKind.ISSUE + : undefined, + ); }} - onKeyDown={onKeyDown} - placeholder={placeholder} - aria-label={`Quick-create ${modeLabel}`} - autoComplete="off" - className="min-w-[10rem] flex-1 bg-transparent px-1 py-0.5 text-base text-foreground placeholder:text-muted-foreground focus:outline-none" + onOpenChange={setPickerOpen} + className="h-8 text-sm font-medium" /> - {pendingCommand && ( - - - apply - {commandChipLabel(pendingCommand.command)} - - )} -
    + )} + {ws?.name} + {restored && Draft restored}
    - {githubImportRef && ( - - )} - - {/* Add pills (mouse path) — issue / sub-issue only. Each primes - the matching slash so it shares the keyboard value picker. */} - {isIssueLike && ( -
    - add - primeSlash("priority")} /> - primeSlash("project")} /> - primeSlash("assign")} /> - primeSlash("label")} /> - primeSlash("due")} /> - - - / - or - Tab - to add - +
    + {contextIssue && ( +

    On {contextIssue.title}

    + )} +
    + + { + setText(event.target.value); + setAcDismissed(false); + }} + onKeyDown={onKeyDown} + placeholder={placeholder.replace(/….*$/, "…")} + aria-label={`Quick-create ${modeLabel}`} + autoComplete="off" + maxLength={300} + className="focus-ring w-full rounded-md border border-input bg-background/40 px-3 py-2.5 text-base placeholder:text-muted-foreground" + /> + {pendingCommand && ( +

    + Enter to apply {commandChipLabel(pendingCommand.command)} +

    + )}
    - )} - {/* Mode pill row — only while the title is empty (teach, then get - out of the way). Tab / Shift+Tab cycle, ⌘1..⌘7 jump. */} - {showModesLegend && ( -
    - modes - {CYCLABLE_MODES.map((m, idx) => ( - - ))} - - Tab - cycle - -
    - )} + {showDescription && ( +