+
requestQuickCreate()}
+ data-quick-create-mode={ws?.experienceProfile === "PERSONAL" ? "task" : "issue"}
+ aria-label={ws?.experienceProfile === "PERSONAL" ? "New task" : "New issue"}
+ title={`${ws?.experienceProfile === "PERSONAL" ? "New task" : "New issue"} (Shift+C)`}
+ onClick={() =>
+ requestQuickCreate({ mode: ws?.experienceProfile === "PERSONAL" ? "task" : "issue" })
+ }
className={cn(
"focus-ring inline-flex h-10 items-center gap-1.5 rounded-md bg-ember px-2.5 text-[0.6875rem] font-medium text-ember-foreground hover:bg-ember/90 sm:h-7 sm:px-2",
MOTION.fast,
)}
>
- New
- ⇧C
+ New
+ ⇧C
{/* Single notification surface for the workspace shell. Opens
diff --git a/src/components/ui/combobox.tsx b/src/components/ui/combobox.tsx
index 935890ca..bdf04de8 100644
--- a/src/components/ui/combobox.tsx
+++ b/src/components/ui/combobox.tsx
@@ -145,13 +145,11 @@ export function Combobox({
aria-expanded={open}
aria-controls={listboxId}
aria-label={ariaLabel}
- onClick={() =>
- setOpen((value) => {
- const next = !value;
- onOpenChange?.(next);
- return next;
- })
- }
+ onClick={() => {
+ const next = !open;
+ setOpen(next);
+ onOpenChange?.(next);
+ }}
className={cn(
"focus-ring inline-flex items-center gap-1.5 rounded-md border border-input bg-background/40 px-2 py-1 text-sm disabled:opacity-50",
className,
diff --git a/src/components/ui/modal/center-modal.tsx b/src/components/ui/modal/center-modal.tsx
index ecc9920b..b5913c38 100644
--- a/src/components/ui/modal/center-modal.tsx
+++ b/src/components/ui/modal/center-modal.tsx
@@ -83,7 +83,7 @@ export function CenterModal({
aria-labelledby="center-modal-title"
aria-describedby={description ? "center-modal-desc" : undefined}
className={cn(
- "flex h-full w-full flex-col overflow-hidden border-border bg-card shadow-xl md:h-auto md:max-h-[88vh] md:rounded-lg md:border",
+ "flex h-full w-full flex-col overflow-hidden border-border bg-card text-foreground shadow-xl md:h-auto md:max-h-[88vh] md:rounded-lg md:border",
maxWidth,
MOTION.slideUp,
)}
diff --git a/src/components/ui/work-item-kind-glyph.tsx b/src/components/ui/work-item-kind-glyph.tsx
index 342a6253..7d9e63ee 100644
--- a/src/components/ui/work-item-kind-glyph.tsx
+++ b/src/components/ui/work-item-kind-glyph.tsx
@@ -8,7 +8,7 @@
* Kinds (see CLAUDE.md "Primitives"):
* EPIC — a parent issue whose children are its scope (ember, layers icon)
* ISSUE — the default unit of work
- * TASK — a sub-task / child checklist item (surfaced as "Sub-task")
+ * TASK — lightweight work, standalone or nested through parentId
*
* Tones come from the warm-earthy tokens (no hardcoded colors).
*/
@@ -32,13 +32,13 @@ const TONE: Record
= {
export const KIND_LABEL: Record = {
EPIC: "Epic",
ISSUE: "Issue",
- TASK: "Sub-task",
+ TASK: "Task",
};
export const KIND_SUBTITLE: Record = {
EPIC: "A parent that groups child issues",
ISSUE: "The standard unit of work",
- TASK: "A sub-task under a parent issue",
+ TASK: "A lightweight task, standalone or under a parent",
};
/** Canonical ordering for pickers / segmented controls. */
diff --git a/src/components/workspace-lazy-surfaces.tsx b/src/components/workspace-lazy-surfaces.tsx
index 501642f7..20acf03f 100644
--- a/src/components/workspace-lazy-surfaces.tsx
+++ b/src/components/workspace-lazy-surfaces.tsx
@@ -2,7 +2,7 @@
import { lazy, Suspense, useEffect, useLayoutEffect, useState } from "react";
import type { QuickCreateOverride } from "@/components/quick-create";
-import { requestQuickCreate, subscribeQuickCreate } from "@/lib/workspace-surface-requests";
+import { requestQuickCreate, subscribeQuickCreate, quickCreateRequestFromDataset } from "@/lib/workspace-surface-requests";
const LazyCommandPalette = lazy(() =>
import("@/components/command-palette").then((module) => ({ default: module.CommandPalette })),
@@ -87,11 +87,7 @@ export function WorkspaceLazySurfaces() {
if (!quickCreateRequest) {
const trigger = target?.closest("[data-quick-create]");
if (trigger) {
- requestQuickCreate(
- trigger.dataset.quickCreateProject
- ? { projectId: trigger.dataset.quickCreateProject }
- : {},
- );
+ requestQuickCreate(quickCreateRequestFromDataset(trigger.dataset));
}
}
};
diff --git a/src/components/workspace-switcher.tsx b/src/components/workspace-switcher.tsx
index 1ac99c7c..e2034fbd 100644
--- a/src/components/workspace-switcher.tsx
+++ b/src/components/workspace-switcher.tsx
@@ -139,8 +139,8 @@ export function WorkspaceSwitcher({ collapsed = false }: { collapsed?: boolean }
-
Mission Control
-
All workspaces · home
+
Home
+
My work across workspaces
@@ -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-request-draft.ts b/src/lib/agent-request-draft.ts
new file mode 100644
index 00000000..78491221
--- /dev/null
+++ b/src/lib/agent-request-draft.ts
@@ -0,0 +1,46 @@
+import type { EngagementMode } from "@prisma/client";
+import {
+ parseAgentRequestsFromBody,
+ parseAgentRequestTokensFromBody,
+} from "@/lib/agent-request-parser";
+
+export type AgentRequestOverrides = Record;
+
+/** Update every mode for the selected agent, preserving prose and other agents. */
+export function prepareAgentRequestDraft(
+ body: string,
+ profileKey: string,
+ mode: EngagementMode,
+ insertion: string,
+) {
+ const key = profileKey.trim().replace(/^@+/, "").toLowerCase();
+ const tokens = parseAgentRequestTokensFromBody(body).filter((token) => token.profileKey === key);
+ let next = body;
+ if (tokens.length === 0) {
+ next = body.trim().length ? `${body.replace(/\s+$/, "")}\n\n${insertion}` : insertion;
+ }
+ // Include newly inserted mentions and edit right-to-left so offsets stay valid.
+ for (const token of parseAgentRequestTokensFromBody(next)
+ .filter((item) => item.profileKey === key)
+ .reverse()) {
+ if (token.modeStart != null && token.modeEnd != null) {
+ next = `${next.slice(0, token.modeStart)}/${mode.toLowerCase()}${next.slice(token.modeEnd)}`;
+ } else {
+ next = `${next.slice(0, token.mentionEnd)} /${mode.toLowerCase()}${next.slice(token.mentionEnd)}`;
+ }
+ }
+ return { body: next, profileKey: key, override: { mode, assignIssue: false } };
+}
+
+/** The same explicit requests drive visible chips and the submitted payload. */
+export function resolveComposerAgentRequests(body: string, overrides: AgentRequestOverrides) {
+ return parseAgentRequestsFromBody(body).map((request) => {
+ const override = overrides[request.profileKey.toLowerCase()];
+ const mode = override?.mode ?? request.mode;
+ return {
+ ...request,
+ mode,
+ assignIssue: mode === "EXECUTE" && (override?.assignIssue ?? request.assignIssue) === true,
+ };
+ });
+}
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/issue-timeline.ts b/src/lib/issue-timeline.ts
new file mode 100644
index 00000000..c989337d
--- /dev/null
+++ b/src/lib/issue-timeline.ts
@@ -0,0 +1,128 @@
+export type IssueTimelineFilter = "discussion" | "updates" | "all";
+
+export type TimelineCommentSource = {
+ id: string;
+ createdAt: Date | string;
+ kind?: "BODY" | "STATUS" | "SYSTEM";
+ run?: { status: string } | null;
+};
+
+export type TimelineEventSource = {
+ id: string;
+ kind: string;
+ createdAt: Date | string;
+ payload?: unknown;
+ actor?: { id: string } | null;
+ actorAgent?: { id: string } | null;
+};
+
+export type IssueTimelineEntry =
+ | { type: "comment"; id: string; comment: C }
+ | { type: "event"; id: string; event: E }
+ | { type: "changes"; id: string; events: E[] };
+
+const ROUTINE_EVENTS = new Set([
+ "ISSUE_UPDATED",
+ "ISSUE_ASSIGNED",
+ "ISSUE_PRIORITY_CHANGED",
+ "ISSUE_QUEUED",
+ "ISSUE_SNOOZED",
+ "ISSUE_UNSNOOZED",
+]);
+
+const OUTCOME_EVENTS = new Set([
+ "ISSUE_STATUS_CHANGED",
+ "ISSUE_STALLED",
+ "ISSUE_SLA_BREACH",
+ "AGENT_NOACK",
+ "AGENT_RUN_COMPLETED",
+ "AGENT_RUN_BLOCKED",
+ "AGENT_RUN_STALLED",
+ "AGENT_RUN_CLEARED",
+ "AGENT_RUN_BUDGET_BREACHED",
+ "PLUGIN_ERROR",
+]);
+
+export function activityCommentId(event: Pick) {
+ if (event.kind !== "COMMENT_CREATED" && event.kind !== "COMMENT_UPDATED") return null;
+ const payload = event.payload;
+ if (!payload || typeof payload !== "object") return null;
+ const record = payload as Record;
+ const id = record.commentId ?? record.sourceCommentId;
+ return typeof id === "string" && id ? id : null;
+}
+
+export function isRoutineIssueEvent(kind: string) {
+ return ROUTINE_EVENTS.has(kind);
+}
+
+function time(value: Date | string) {
+ const result = new Date(value).getTime();
+ return Number.isFinite(result) ? result : 0;
+}
+
+function actorKey(event: TimelineEventSource) {
+ if (event.actorAgent) return `agent:${event.actorAgent.id}`;
+ if (event.actor) return `user:${event.actor.id}`;
+ // System events can have different connectors; avoid inventing a shared actor.
+ return `event:${event.id}`;
+}
+
+/** Merge the loaded windows, retaining full comment cards and audit ordering. */
+export function buildIssueTimeline(
+ comments: readonly C[],
+ events: readonly E[],
+ filter: IssueTimelineFilter,
+ linkedCommentId?: string | null,
+): Array> {
+ const loadedCommentIds = new Set(comments.map((comment) => comment.id));
+ const entries: Array & { at: number }> = [];
+ for (const comment of comments) {
+ const linked = comment.id === linkedCommentId;
+ const live =
+ comment.kind === "STATUS" &&
+ (comment.run?.status === "ACTIVE" || comment.run?.status === "WAITING");
+ if (live && !linked) continue;
+ const body = !comment.kind || comment.kind === "BODY";
+ if (
+ !linked &&
+ ((filter === "updates" && body) || (filter === "discussion" && comment.kind === "SYSTEM"))
+ )
+ continue;
+ entries.push({
+ type: "comment",
+ id: `comment-${comment.id}`,
+ comment,
+ at: time(comment.createdAt),
+ });
+ }
+ for (const event of events) {
+ const commentId = activityCommentId(event);
+ // Never render a second summary of a full comment already loaded, even
+ // when that comment belongs to a different filter or the live workstream.
+ if (commentId && loadedCommentIds.has(commentId)) continue;
+ if (filter === "discussion" && !OUTCOME_EVENTS.has(event.kind)) continue;
+ entries.push({ type: "event", id: `activity-${event.id}`, event, at: time(event.createdAt) });
+ }
+ entries.sort((a, b) => a.at - b.at || a.id.localeCompare(b.id));
+ const result: Array> = [];
+ for (const entry of entries) {
+ if (entry.type !== "event" || !isRoutineIssueEvent(entry.event.kind)) {
+ result.push(entry);
+ continue;
+ }
+ const previous = result.at(-1);
+ const previousEvent = previous?.type === "changes" ? previous.events.at(-1) : undefined;
+ if (
+ previous?.type === "changes" &&
+ previousEvent &&
+ actorKey(previousEvent) === actorKey(entry.event) &&
+ time(entry.event.createdAt) - time(previous.events[0].createdAt) <= 30 * 60_000
+ ) {
+ previous.events.push(entry.event);
+ } else {
+ result.push({ type: "changes", id: entry.id, events: [entry.event] });
+ }
+ }
+ return result;
+}
diff --git a/src/lib/realtime-targets.ts b/src/lib/realtime-targets.ts
index c7baa3f1..65ec6257 100644
--- a/src/lib/realtime-targets.ts
+++ b/src/lib/realtime-targets.ts
@@ -4,6 +4,22 @@ function stringValue(value: unknown): string | null {
return typeof value === "string" && value.length > 0 ? value : null;
}
+/** Reciprocal relation changes refresh only their two issue-context queries. */
+export function relationRealtimeTargets(event: RealtimeEventShape): string[] {
+ const payload =
+ event.payload && typeof event.payload === "object"
+ ? (event.payload as Record)
+ : {};
+ if (event.subjectType !== "issue" || !stringValue(payload.relationId)) return [];
+ return [
+ ...new Set(
+ [stringValue(event.subjectId), stringValue(payload.toIssueId)].filter((id): id is string =>
+ Boolean(id),
+ ),
+ ),
+ ];
+}
+
/** Resolve exact cache keys carried by a realtime event. */
export function realtimeTargets(event: RealtimeEventShape): {
issueId: string | null;
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/lib/workspace-surface-requests.ts b/src/lib/workspace-surface-requests.ts
index fdd8d076..ea24ce3c 100644
--- a/src/lib/workspace-surface-requests.ts
+++ b/src/lib/workspace-surface-requests.ts
@@ -5,6 +5,29 @@ type QuickCreateListener = (request: QuickCreateOverride) => void;
const quickCreateListeners = new Set();
let pendingQuickCreate: QuickCreateOverride | null = null;
+/** Read named creation intent consistently before and after lazy hydration. */
+export function quickCreateRequestFromDataset(dataset: {
+ quickCreateProject?: string;
+ quickCreateMode?: string;
+}): QuickCreateOverride {
+ const modes = [
+ "issue",
+ "task",
+ "project",
+ "cycle",
+ "initiative",
+ "note",
+ "artifact",
+ "action-request",
+ ];
+ return {
+ ...(dataset.quickCreateProject ? { projectId: dataset.quickCreateProject } : {}),
+ ...(dataset.quickCreateMode && modes.includes(dataset.quickCreateMode)
+ ? { mode: dataset.quickCreateMode as QuickCreateOverride["mode"] }
+ : {}),
+ };
+}
+
/** Preserve a first-open request even when the lazy surface has not hydrated yet. */
export function requestQuickCreate(request: QuickCreateOverride = {}): void {
if (quickCreateListeners.size === 0) {
diff --git a/src/server/audit.ts b/src/server/audit.ts
index 644b12ad..a6c4788d 100644
--- a/src/server/audit.ts
+++ b/src/server/audit.ts
@@ -650,6 +650,25 @@ export async function recordChange(
userAgent?: string | null;
},
): Promise {
+ if (
+ params.subjectType === "issue" &&
+ (params.eventKind === EventKind.ISSUE_STATUS_CHANGED || params.eventKind === EventKind.ISSUE_DELETED)
+ ) {
+ await tx.issueReminder.updateMany({
+ where: {
+ workspaceId: params.workspaceId,
+ issueId: params.subjectId,
+ dismissedAt: null,
+ issue: {
+ OR: [
+ { deletedAt: { not: null } },
+ { status: { category: { in: ["DONE", "CANCELED"] } } },
+ ],
+ },
+ },
+ data: { dismissedAt: new Date() },
+ });
+ }
await tx.auditLog.create({
data: {
workspaceId: params.workspaceId,
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__/notification.test.ts b/src/server/routers/__tests__/notification.test.ts
index e5d7bcf6..d484dc18 100644
--- a/src/server/routers/__tests__/notification.test.ts
+++ b/src/server/routers/__tests__/notification.test.ts
@@ -163,7 +163,7 @@ describe("notificationRouter", () => {
const setupData = await setup();
const event = await createStalledEvent(setupData, new Date("2026-04-26T12:30:00Z"));
- await expect(setupData.caller.globalUnreadCount()).resolves.toEqual({ count: 1 });
+ await expect(setupData.caller.globalUnreadCount()).resolves.toEqual({ count: 1, alerts: 1, reminders: 0 });
const alerts = await setupData.caller.globalList();
expect(alerts).toHaveLength(1);
expect(alerts[0]).toMatchObject({
@@ -176,7 +176,7 @@ describe("notificationRouter", () => {
});
await expect(setupData.caller.globalMarkRead()).resolves.toMatchObject({ count: 1 });
- await expect(setupData.caller.globalUnreadCount()).resolves.toEqual({ count: 0 });
+ await expect(setupData.caller.globalUnreadCount()).resolves.toEqual({ count: 0, alerts: 0, reminders: 0 });
await expect(
setupData.prisma.user.findUniqueOrThrow({
where: { id: setupData.fixture.user.id },
diff --git a/src/server/routers/__tests__/reminder.test.ts b/src/server/routers/__tests__/reminder.test.ts
new file mode 100644
index 00000000..f894ba24
--- /dev/null
+++ b/src/server/routers/__tests__/reminder.test.ts
@@ -0,0 +1,195 @@
+import { afterAll, afterEach, describe, expect, it } from "vitest";
+import { reminderRouter } from "@/server/routers/reminder";
+import { issueRouter } from "@/server/routers/issue";
+import { notificationRouter } from "@/server/routers/notification";
+import {
+ countDueReminders,
+ markDueRemindersRead,
+ sweepIssueReminders,
+} from "@/server/services/issue-reminder";
+import {
+ buildContext,
+ createIssue,
+ createWorkspaceFixture,
+ disconnectPrisma,
+ getPrisma,
+ type TestFixture,
+} from "./helpers";
+
+const fixtures: TestFixture[] = [];
+afterEach(async () => {
+ while (fixtures.length) await fixtures.pop()!.cleanup();
+});
+afterAll(disconnectPrisma);
+const future = () => new Date(Date.now() + 3600_000);
+
+async function setup() {
+ const fixture = await createWorkspaceFixture({ keyPrefix: "RM" });
+ fixtures.push(fixture);
+ const issue = await createIssue(fixture);
+ const owner = reminderRouter.createCaller(await buildContext(fixture));
+ const member = reminderRouter.createCaller(
+ await buildContext(fixture, { asUserId: fixture.secondUser.id }),
+ );
+ return { fixture, issue, owner, member, db: getPrisma() };
+}
+
+describe("private issue reminders", () => {
+ it("reschedules one personal reminder without changing work or emitting agent activity", async () => {
+ const { fixture, issue, owner, db } = await setup();
+ const before = await db.activityEvent.count({ where: { workspaceId: fixture.workspace.id } });
+ const first = await owner.set({
+ issueId: issue.id,
+ remindAt: future(),
+ timezone: "America/New_York",
+ });
+ const second = await owner.set({
+ issueId: issue.id,
+ remindAt: new Date(Date.now() + 7200_000),
+ timezone: "UTC",
+ });
+ expect(second.id).toBe(first.id);
+ const concurrent = await Promise.all([
+ owner.set({ issueId: issue.id, remindAt: new Date(Date.now() + 10800_000), timezone: "UTC" }),
+ owner.set({ issueId: issue.id, remindAt: new Date(Date.now() + 14400_000), timezone: "UTC" }),
+ ]);
+ expect(new Set(concurrent.map((row) => row.id)).size).toBe(1);
+ expect(await db.issueReminder.count({ where: { issueId: issue.id } })).toBe(1);
+ expect(await db.activityEvent.count({ where: { workspaceId: fixture.workspace.id } })).toBe(
+ before,
+ );
+ expect((await db.issue.findUniqueOrThrow({ where: { id: issue.id } })).dueDate).toBeNull();
+ await expect(
+ owner.set({ issueId: issue.id, remindAt: new Date(0), timezone: "UTC" }),
+ ).rejects.toThrow(/future/);
+ await expect(
+ owner.set({ issueId: issue.id, remindAt: future(), timezone: "Invalid/Zone" }),
+ ).rejects.toThrow(/timezone/);
+ });
+
+ it("isolates reminders by recipient and workspace, including unread lifecycle", async () => {
+ const { fixture, issue, owner, member, db } = await setup();
+ const reminder = await owner.set({ issueId: issue.id, remindAt: future(), timezone: "UTC" });
+ await db.issueReminder.update({
+ where: { id: reminder.id },
+ data: { remindAt: new Date(Date.now() - 1000) },
+ });
+ expect((await owner.due({})).items.map((row) => row.id)).toEqual([reminder.id]);
+ expect((await member.due({})).items).toEqual([]);
+ expect(await member.forIssue({ issueId: issue.id })).toBeNull();
+ await expect(member.dismiss({ id: reminder.id })).rejects.toMatchObject({ code: "NOT_FOUND" });
+ await expect(member.snooze({ id: reminder.id, remindAt: future() })).rejects.toMatchObject({
+ code: "NOT_FOUND",
+ });
+ expect(await countDueReminders(db, fixture.user.id)).toBe(1);
+ await markDueRemindersRead(db, fixture.user.id, fixture.workspace.id);
+ expect(await countDueReminders(db, fixture.user.id)).toBe(0);
+ expect((await owner.due({})).items).toHaveLength(1);
+ await owner.snooze({ id: reminder.id, remindAt: future() });
+ expect((await owner.due({})).items).toEqual([]);
+ const other = await setup();
+ expect((await owner.due({ workspaceId: other.fixture.workspace.id })).items).toEqual([]);
+ await expect(
+ owner.set({ issueId: other.issue.id, remindAt: future(), timezone: "UTC" }),
+ ).rejects.toMatchObject({ code: "NOT_FOUND" });
+ });
+
+ it("rechecks project access before surfacing or delivering a reminder", async () => {
+ const { fixture, issue, member, db } = await setup();
+ const project = await db.project.create({
+ data: {
+ workspaceId: fixture.workspace.id,
+ key: "RM",
+ name: "Restricted later",
+ createdById: fixture.user.id,
+ },
+ });
+ await db.issue.update({ where: { id: issue.id }, data: { projectId: project.id } });
+ const reminder = await member.set({ issueId: issue.id, remindAt: future(), timezone: "UTC" });
+ await db.issueReminder.update({
+ where: { id: reminder.id },
+ data: { remindAt: new Date(0), browserPush: true },
+ });
+ await db.project.update({ where: { id: project.id }, data: { visibility: "RESTRICTED" } });
+ expect((await member.due({})).items).toEqual([]);
+ expect(await countDueReminders(db, fixture.secondUser.id)).toBe(0);
+ await sweepIssueReminders(db);
+ expect(
+ (await db.issueReminder.findUniqueOrThrow({ where: { id: reminder.id } })).dismissedAt,
+ ).not.toBeNull();
+ });
+
+ it("completion cancels reminders permanently even if the issue is reopened", async () => {
+ const { fixture, issue, owner, db } = await setup();
+ const reminder = await owner.set({ issueId: issue.id, remindAt: future(), timezone: "UTC" });
+ const done = await db.status.findFirstOrThrow({
+ where: { workspaceId: fixture.workspace.id, category: "DONE" },
+ });
+ const todo = await db.status.findFirstOrThrow({
+ where: { workspaceId: fixture.workspace.id, category: "TODO" },
+ });
+ const issues = issueRouter.createCaller(await buildContext(fixture));
+ await issues.update({ id: issue.id, statusId: done.id });
+ expect(
+ (await db.issueReminder.findUniqueOrThrow({ where: { id: reminder.id } })).dismissedAt,
+ ).not.toBeNull();
+ await expect(
+ owner.set({ issueId: issue.id, remindAt: future(), timezone: "UTC" }),
+ ).rejects.toThrow(/open items/);
+ await issues.update({ id: issue.id, statusId: todo.id });
+ expect(await owner.forIssue({ issueId: issue.id })).toBeNull();
+ });
+
+ it("claims optional push only once across concurrent sweeps and respects rescheduling", async () => {
+ const { fixture, issue, owner, db } = await setup();
+ const reminder = await owner.set({ issueId: issue.id, remindAt: future(), timezone: "UTC" });
+ await db.issueReminder.update({
+ where: { id: reminder.id },
+ data: { remindAt: new Date(0), browserPush: true },
+ });
+ const results = await Promise.all([sweepIssueReminders(db), sweepIssueReminders(db)]);
+ expect(results.reduce((sum, result) => sum + result.claimed, 0)).toBe(1);
+ expect(await countDueReminders(db, fixture.user.id)).toBe(1);
+ expect((await sweepIssueReminders(db)).claimed).toBe(0);
+ await owner.snooze({ id: reminder.id, remindAt: future() });
+ expect((await sweepIssueReminders(db)).claimed).toBe(0);
+ expect(
+ (await db.issueReminder.findUniqueOrThrow({ where: { id: reminder.id } })).notifiedAt,
+ ).toBeNull();
+ });
+
+ it("cancels archived work and suppresses suspended recipients", async () => {
+ const { fixture, issue, owner, db } = await setup();
+ const reminder = await owner.set({ issueId: issue.id, remindAt: future(), timezone: "UTC" });
+ const issues = issueRouter.createCaller(await buildContext(fixture));
+ await issues.archive({ id: issue.id });
+ expect(
+ (await db.issueReminder.findUniqueOrThrow({ where: { id: reminder.id } })).dismissedAt,
+ ).not.toBeNull();
+ await issues.restore({ id: issue.id });
+ await owner.set({ issueId: issue.id, remindAt: future(), timezone: "UTC" });
+ await db.issueReminder.update({
+ where: { id: reminder.id },
+ data: { remindAt: new Date(0), browserPush: true },
+ });
+ await db.user.update({ where: { id: fixture.user.id }, data: { status: "SUSPENDED" } });
+ expect((await owner.due({})).items).toEqual([]);
+ expect((await sweepIssueReminders(db)).claimed).toBe(0);
+ });
+
+ it("includes private reminders in both bells and marks them read without dismissing", async () => {
+ const { fixture, issue, owner, db } = await setup();
+ const reminder = await owner.set({ issueId: issue.id, remindAt: future(), timezone: "UTC" });
+ await db.issueReminder.update({ where: { id: reminder.id }, data: { remindAt: new Date(0) } });
+ const notifications = notificationRouter.createCaller(await buildContext(fixture));
+ expect((await notifications.unreadCount()).count).toBe(1);
+ expect(await notifications.globalUnreadCount()).toMatchObject({
+ count: 1,
+ alerts: 0,
+ reminders: 1,
+ });
+ expect((await notifications.globalMarkRead()).count).toBe(1);
+ expect((await notifications.unreadCount()).count).toBe(0);
+ expect((await owner.due({})).items).toHaveLength(1);
+ });
+});
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/_app.ts b/src/server/routers/_app.ts
index 8b684846..de5a23b5 100644
--- a/src/server/routers/_app.ts
+++ b/src/server/routers/_app.ts
@@ -1,4 +1,5 @@
import { router } from "@/server/trpc";
+import { reminderRouter } from "./reminder";
import { actionRequestRouter } from "./action-request";
import { agentRouter } from "./agent";
import { agentProfileRouter } from "./agent-profile";
@@ -112,6 +113,7 @@ export const appRouter = router({
projectAccess: projectAccessRouter,
projectTemplate: projectTemplateRouter,
recurring: recurringRouter,
+ reminder: reminderRouter,
scheduledTask: scheduledTaskRouter,
relation: relationRouter,
runtime: runtimeRouter,
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/notification.ts b/src/server/routers/notification.ts
index 2957b0eb..8e60762c 100644
--- a/src/server/routers/notification.ts
+++ b/src/server/routers/notification.ts
@@ -1,4 +1,5 @@
import { z } from "zod";
+import { countDueReminders, markDueRemindersRead } from "@/server/services/issue-reminder";
import { TRPCError } from "@trpc/server";
import {
EventKind,
@@ -193,14 +194,18 @@ export const notificationRouter = router({
},
include: notificationStateInclude(),
});
- return { count: (await buildNotificationListItems(ctx.db, ctx.workspaceId, rows)).length };
+ return {
+ count:
+ (await buildNotificationListItems(ctx.db, ctx.workspaceId, rows)).length +
+ (await countDueReminders(ctx.db, ctx.session.user.id, ctx.workspaceId)),
+ };
}),
/** Shared unread alert count for Mission Control's cross-workspace bell. */
globalUnreadCount: protectedProcedure.query(async ({ ctx }) => {
const userId = ctx.session.user.id;
const workspaces = await memberWorkspaces(ctx.db, userId);
- if (workspaces.length === 0) return { count: 0 };
+ if (workspaces.length === 0) return { count: 0, alerts: 0, reminders: 0 };
await materializeGlobalNotifications(ctx.db, userId, workspaces);
const rows = await ctx.db.notificationState.findMany({
where: {
@@ -221,7 +226,9 @@ export const notificationRouter = router({
buildNotificationListItems(ctx.db, workspaceId, group),
),
);
- return { count: visible.reduce((sum, group) => sum + group.length, 0) };
+ const alerts = visible.reduce((sum, group) => sum + group.length, 0);
+ const reminders = await countDueReminders(ctx.db, userId);
+ return { count: alerts + reminders, alerts, reminders };
}),
/** Recent actionable alerts across every workspace the caller belongs to. */
@@ -290,21 +297,23 @@ export const notificationRouter = router({
}),
)
).flat();
- const [result] = await ctx.db.$transaction([
- ctx.db.notificationState.updateMany({
+ const count = await ctx.db.$transaction(async (tx) => {
+ const remindersRead = await markDueRemindersRead(tx, userId, undefined, visitedAt);
+ const result = await tx.notificationState.updateMany({
where: {
id: { in: visibleIds },
userId,
status: NotificationStatus.UNREAD,
},
data: { status: NotificationStatus.READ, readAt: visitedAt },
- }),
- ctx.db.user.update({
+ });
+ await tx.user.update({
where: { id: userId },
data: { lastInboxVisitAt: visitedAt },
- }),
- ]);
- return { count: result.count, visitedAt };
+ });
+ return result.count + remindersRead.count;
+ });
+ return { count, visitedAt };
}),
upsert: workspaceProcedure
@@ -347,14 +356,23 @@ export const notificationRouter = router({
const visibleIds = (
await buildNotificationListItems(ctx.db, ctx.workspaceId, candidates)
).map((row) => row.id);
- const result = await ctx.db.notificationState.updateMany({
- where: { id: { in: visibleIds }, userId: ctx.session.user.id },
- data: {
- status: NotificationStatus.READ,
- readAt: now,
- },
+ const count = await ctx.db.$transaction(async (tx) => {
+ const remindersRead = await markDueRemindersRead(
+ tx,
+ ctx.session.user.id,
+ ctx.workspaceId,
+ now,
+ );
+ const result = await tx.notificationState.updateMany({
+ where: { id: { in: visibleIds }, userId: ctx.session.user.id },
+ data: {
+ status: NotificationStatus.READ,
+ readAt: now,
+ },
+ });
+ return result.count + remindersRead.count;
});
- return { count: result.count };
+ return { count };
}
const item = await updateNotificationState(ctx, input.id!, {
diff --git a/src/server/routers/reminder.ts b/src/server/routers/reminder.ts
new file mode 100644
index 00000000..ded8e0af
--- /dev/null
+++ b/src/server/routers/reminder.ts
@@ -0,0 +1,212 @@
+import { z } from "zod";
+import { TRPCError } from "@trpc/server";
+import { router, protectedProcedure, workspaceProcedure } from "@/server/trpc";
+import { assertIssueForViewer } from "@/server/services/project-access";
+import { visibleReminderWhere } from "@/server/services/issue-reminder";
+import { isWebPushConfigured } from "@/server/services/web-push";
+
+const futureDate = z
+ .date()
+ .refine((date) => date.getTime() > Date.now(), "Choose a future reminder time.");
+const timezone = z
+ .string()
+ .max(100)
+ .refine((value) => {
+ try {
+ new Intl.DateTimeFormat("en", { timeZone: value });
+ return true;
+ } catch {
+ return false;
+ }
+ }, "Choose a valid timezone.");
+
+export const reminderRouter = router({
+ deliveryOptions: protectedProcedure.query(async ({ ctx }) => ({
+ browserPushAvailable:
+ isWebPushConfigured() &&
+ (await ctx.db.pushSubscription.count({
+ where: { userId: ctx.session.user.id, revokedAt: null },
+ })) > 0,
+ })),
+ forIssue: workspaceProcedure
+ .input(z.object({ issueId: z.string().cuid() }))
+ .query(async ({ ctx, input }) => {
+ await assertIssueForViewer(ctx.db, ctx, input.issueId, "READ");
+ return ctx.db.issueReminder.findFirst({
+ where: {
+ ...(await visibleReminderWhere(ctx.db, ctx.session.user.id, ctx.workspaceId)),
+ issueId: input.issueId,
+ },
+ });
+ }),
+
+ set: workspaceProcedure
+ .input(
+ z.object({
+ issueId: z.string().cuid(),
+ remindAt: futureDate,
+ timezone,
+ browserPush: z.boolean().default(false),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ return ctx.db.$transaction(async (tx) => {
+ await assertIssueForViewer(tx, ctx, input.issueId, "READ");
+ const issue = await tx.issue.findFirst({
+ where: {
+ id: input.issueId,
+ workspaceId: ctx.workspaceId,
+ deletedAt: null,
+ status: { category: { notIn: ["DONE", "CANCELED"] } },
+ },
+ });
+ if (!issue)
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message:
+ "Reminders are available on open items. Reopen this item before scheduling one.",
+ });
+ if (
+ input.browserPush &&
+ (!isWebPushConfigured() ||
+ !(await tx.pushSubscription.count({
+ where: { userId: ctx.session.user.id, revokedAt: null },
+ })))
+ ) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Enable browser notifications before choosing browser delivery.",
+ });
+ }
+ const data = {
+ remindAt: input.remindAt,
+ timezone: input.timezone,
+ browserPush: input.browserPush,
+ readAt: null,
+ dismissedAt: null,
+ notifiedAt: null,
+ };
+ const result = await tx.issueReminder.upsert({
+ where: { userId_issueId: { userId: ctx.session.user.id, issueId: input.issueId } },
+ create: {
+ ...data,
+ workspaceId: ctx.workspaceId,
+ userId: ctx.session.user.id,
+ issueId: input.issueId,
+ },
+ update: data,
+ });
+ // Personal scheduling is audited without a workspace ActivityEvent or agent wake.
+ await tx.auditLog.create({
+ data: {
+ workspaceId: ctx.workspaceId,
+ actorId: ctx.session.user.id,
+ entity: "IssueReminder",
+ entityId: result.id,
+ action: "reminder-scheduled",
+ after: {
+ issueId: input.issueId,
+ remindAt: input.remindAt.toISOString(),
+ timezone: input.timezone,
+ browserPush: input.browserPush,
+ },
+ },
+ });
+ return result;
+ });
+ }),
+
+ due: protectedProcedure
+ .input(
+ z
+ .object({ workspaceId: z.string().cuid().optional(), cursor: z.string().cuid().optional() })
+ .default({}),
+ )
+ .query(async ({ ctx, input }) => {
+ const where = {
+ ...(await visibleReminderWhere(ctx.db, ctx.session.user.id, input.workspaceId)),
+ remindAt: { lte: new Date() },
+ };
+ const [items, total] = await Promise.all([
+ ctx.db.issueReminder.findMany({
+ where,
+ orderBy: [{ remindAt: "desc" }, { id: "desc" }],
+ take: 21,
+ ...(input.cursor ? { cursor: { id: input.cursor }, skip: 1 } : {}),
+ include: {
+ issue: { select: { id: true, number: true, title: true } },
+ workspace: { select: { slug: true, key: true } },
+ },
+ }),
+ ctx.db.issueReminder.count({ where }),
+ ]);
+ const hasMore = items.length > 20;
+ const page = items.slice(0, 20);
+ return { items: page, total, nextCursor: hasMore ? page[page.length - 1]?.id : undefined };
+ }),
+
+ read: protectedProcedure
+ .input(z.object({ id: z.string().cuid() }))
+ .mutation(async ({ ctx, input }) => {
+ return ctx.db.issueReminder.updateMany({
+ where: {
+ id: input.id,
+ userId: ctx.session.user.id,
+ remindAt: { lte: new Date() },
+ readAt: null,
+ },
+ data: { readAt: new Date() },
+ });
+ }),
+
+ dismiss: protectedProcedure
+ .input(z.object({ id: z.string().cuid() }))
+ .mutation(async ({ ctx, input }) => {
+ return ctx.db.$transaction(async (tx) => {
+ const reminder = await tx.issueReminder.findFirst({
+ where: { id: input.id, userId: ctx.session.user.id },
+ });
+ if (!reminder) throw new TRPCError({ code: "NOT_FOUND" });
+ const result = await tx.issueReminder.update({
+ where: { id: reminder.id },
+ data: { dismissedAt: new Date() },
+ });
+ await tx.auditLog.create({
+ data: {
+ workspaceId: reminder.workspaceId,
+ actorId: ctx.session.user.id,
+ entity: "IssueReminder",
+ entityId: reminder.id,
+ action: "reminder-dismissed",
+ },
+ });
+ return result;
+ });
+ }),
+
+ snooze: protectedProcedure
+ .input(z.object({ id: z.string().cuid(), remindAt: futureDate }))
+ .mutation(async ({ ctx, input }) => {
+ return ctx.db.$transaction(async (tx) => {
+ const reminder = await tx.issueReminder.findFirst({
+ where: { ...(await visibleReminderWhere(tx, ctx.session.user.id)), id: input.id },
+ });
+ if (!reminder) throw new TRPCError({ code: "NOT_FOUND" });
+ const result = await tx.issueReminder.update({
+ where: { id: reminder.id },
+ data: { remindAt: input.remindAt, readAt: null, notifiedAt: null },
+ });
+ await tx.auditLog.create({
+ data: {
+ workspaceId: reminder.workspaceId,
+ actorId: ctx.session.user.id,
+ entity: "IssueReminder",
+ entityId: reminder.id,
+ action: "reminder-rescheduled",
+ after: { remindAt: input.remindAt.toISOString() },
+ },
+ });
+ return result;
+ });
+ }),
+});
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/issue-reminder.ts b/src/server/services/issue-reminder.ts
new file mode 100644
index 00000000..60168908
--- /dev/null
+++ b/src/server/services/issue-reminder.ts
@@ -0,0 +1,136 @@
+import "server-only";
+import type { Prisma, PrismaClient } from "@prisma/client";
+import { issueWhereForViewer } from "@/server/services/project-access";
+import { sendBrowserPushToUser } from "@/server/services/web-push";
+
+type DbClient = PrismaClient | Prisma.TransactionClient;
+
+/** Re-check membership and project access on every read and delivery. */
+export async function visibleReminderWhere(
+ db: DbClient,
+ userId: string,
+ workspaceId?: string,
+): Promise {
+ const memberships = await db.membership.findMany({
+ where: {
+ userId,
+ ...(workspaceId ? { workspaceId } : {}),
+ user: { disabledAt: null, deletedAt: null, status: "ACTIVE" },
+ },
+ select: { id: true, role: true, workspaceId: true },
+ });
+ return {
+ userId,
+ user: { disabledAt: null, deletedAt: null, status: "ACTIVE" },
+ dismissedAt: null,
+ OR: memberships.map((membership) => ({
+ workspaceId: membership.workspaceId,
+ user: { memberships: { some: { id: membership.id, role: membership.role } } },
+ issue: {
+ AND: [issueWhereForViewer({ workspaceId: membership.workspaceId, membership })],
+ deletedAt: null,
+ status: { category: { notIn: ["DONE", "CANCELED"] } },
+ },
+ })),
+ };
+}
+
+export async function countDueReminders(
+ db: DbClient,
+ userId: string,
+ workspaceId?: string,
+ now = new Date(),
+) {
+ return db.issueReminder.count({
+ where: {
+ ...(await visibleReminderWhere(db, userId, workspaceId)),
+ remindAt: { lte: now },
+ readAt: null,
+ },
+ });
+}
+
+export async function markDueRemindersRead(
+ db: DbClient,
+ userId: string,
+ workspaceId?: string,
+ now = new Date(),
+) {
+ return db.issueReminder.updateMany({
+ where: {
+ ...(await visibleReminderWhere(db, userId, workspaceId)),
+ remindAt: { lte: now },
+ readAt: null,
+ },
+ data: { readAt: now },
+ });
+}
+
+/** In-app reminders are durable and time-driven even when this worker is down.
+ * This sweep only handles optional browser push, with one atomic claim per schedule.
+ */
+export async function sweepIssueReminders(db: PrismaClient, now = new Date()) {
+ await db.issueReminder.updateMany({
+ where: {
+ dismissedAt: null,
+ issue: {
+ OR: [{ deletedAt: { not: null } }, { status: { category: { in: ["DONE", "CANCELED"] } } }],
+ },
+ },
+ data: { dismissedAt: now },
+ });
+ const due = await db.issueReminder.findMany({
+ where: {
+ browserPush: true,
+ notifiedAt: null,
+ readAt: null,
+ dismissedAt: null,
+ remindAt: { lte: now },
+ },
+ orderBy: [{ remindAt: "asc" }, { id: "asc" }],
+ take: 100,
+ });
+ let claimed = 0;
+ let sent = 0;
+ for (const reminder of due) {
+ const accessWhere = await visibleReminderWhere(db, reminder.userId, reminder.workspaceId);
+ const visible = await db.issueReminder.findFirst({
+ where: {
+ ...accessWhere,
+ id: reminder.id,
+ },
+ include: {
+ issue: { select: { title: true, number: true } },
+ workspace: { select: { slug: true, key: true } },
+ },
+ });
+ if (!visible) {
+ await db.issueReminder.updateMany({
+ where: { id: reminder.id, updatedAt: reminder.updatedAt },
+ data: { dismissedAt: now },
+ });
+ continue;
+ }
+ const claim = await db.issueReminder.updateMany({
+ where: {
+ ...accessWhere,
+ id: reminder.id,
+ updatedAt: reminder.updatedAt,
+ notifiedAt: null,
+ readAt: null,
+ dismissedAt: null,
+ },
+ data: { notifiedAt: now },
+ });
+ if (!claim.count) continue;
+ claimed += 1;
+ const result = await sendBrowserPushToUser(db, reminder.userId, {
+ title: `Reminder: ${visible.workspace.key}-${visible.issue.number}`,
+ body: visible.issue.title,
+ url: `/w/${visible.workspace.slug}/issues/${reminder.issueId}`,
+ tag: `forge-reminder-${reminder.id}`,
+ });
+ sent += result.sent;
+ }
+ return { claimed, sent };
+}
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/src/server/worker.ts b/src/server/worker.ts
index bb2eb86c..cd87be7a 100644
--- a/src/server/worker.ts
+++ b/src/server/worker.ts
@@ -36,6 +36,7 @@ import {
} from "@/server/services/github/resource-sync";
import type { GitHubResourceReconcileJob } from "@/server/services/github/reconciliation-queue";
import { sweepScheduledTasks } from "@/server/services/scheduled-task";
+import { sweepIssueReminders } from "@/server/services/issue-reminder";
import { sweepStaleWorkSessions } from "@/server/services/work-session";
import { sweepHermesConnectorRetries } from "@/server/services/hermes-connector-retry";
import {
@@ -413,7 +414,11 @@ export const maintenanceWorker = new Worker(
});
}
case "scheduled-task-sweep": {
- return sweepScheduledTasks();
+ const [tasks, reminders] = await Promise.all([
+ sweepScheduledTasks(),
+ sweepIssueReminders(db),
+ ]);
+ return { tasks, reminders };
}
case "work-session-stale-sweep": {
return sweepStaleWorkSessions(db);
diff --git a/tests/e2e/agent-studio-audit.spec.ts b/tests/e2e/agent-studio-audit.spec.ts
index c3cd629c..4fd39ddf 100644
--- a/tests/e2e/agent-studio-audit.spec.ts
+++ b/tests/e2e/agent-studio-audit.spec.ts
@@ -20,11 +20,14 @@ test.describe.serial("Agent Studio audit evidence", () => {
await page.goto("/settings/agents", { waitUntil: "domcontentloaded" });
await expect(page).toHaveURL(/\/agents$/);
- await expect(page.getByRole("heading", { name: "Agent fleet" })).toBeVisible();
+ await expect(page.getByRole("heading", { name: "Agents", exact: true })).toBeVisible();
await expect(page.getByText("Victor").first()).toBeVisible();
await capture(page, "01-global-agent-profiles.png");
- await page.getByRole("button", { name: "New profile" }).click();
+ await page.getByRole("button", { name: "Connect via MCP", exact: true }).click();
+ await page.getByRole("combobox", { name: "Setup workspace" }).click();
+ await page.getByRole("option", { name: /Forge\s+FRG/ }).click();
+ await page.getByRole("button", { name: "Create a new agent", exact: true }).click();
await expect(page.getByRole("dialog", { name: "Create agent profile" })).toBeVisible();
await capture(page, "02-create-agent-profile.png");
await page.keyboard.press("Escape");
@@ -52,7 +55,7 @@ test.describe.serial("Agent Studio audit evidence", () => {
await capture(page, "06-global-runtimes.png");
await page.goto("/w/forge/settings/agents", { waitUntil: "domcontentloaded" });
- await expect(page.getByText("Bound agents", { exact: true })).toBeVisible();
+ await expect(page.getByText("Workspace agents", { exact: true })).toBeVisible();
await expect(page.getByText("Victor", { exact: true }).first()).toBeVisible();
await capture(page, "07-workspace-agent-bindings.png");
@@ -72,7 +75,7 @@ test.describe.serial("Agent Studio audit evidence", () => {
await expect(page.getByText("Victor", { exact: true }).first()).toBeVisible();
await capture(page, "10-instance-agent-policy.png");
- await page.goto("/", { waitUntil: "domcontentloaded" });
+ await page.goto("/mission-control", { waitUntil: "domcontentloaded" });
await expect(page.getByRole("heading", { name: "Mission Control" })).toBeVisible();
await expect(page.getByText("Checking operations…", { exact: true })).toBeHidden();
await expect(page.getByText("Loading workspace queues…", { exact: true })).toBeHidden();
@@ -93,7 +96,7 @@ test.describe.serial("Agent Studio audit evidence", () => {
await capture(page, "13-mobile-agent-profile-detail.png");
await page.goto("/w/forge/settings/agents", { waitUntil: "domcontentloaded" });
- await expect(page.getByText("Bound agents", { exact: true })).toBeVisible();
+ await expect(page.getByText("Workspace agents", { exact: true })).toBeVisible();
await expect(page.getByText("Victor", { exact: true }).first()).toBeVisible();
await capture(page, "14-mobile-workspace-agent-bindings.png");
});
diff --git a/tests/e2e/daily-workflow.spec.ts b/tests/e2e/daily-workflow.spec.ts
new file mode 100644
index 00000000..4daaa85d
--- /dev/null
+++ b/tests/e2e/daily-workflow.spec.ts
@@ -0,0 +1,132 @@
+import { expect, test, type Page } from "@playwright/test";
+
+async function createIssue(page: Page, title: string) {
+ await page.goto("/w/forge/issues");
+ await page.getByRole("button", { name: "New issue ⇧C", exact: true }).click();
+ const input = page.getByRole("textbox", { name: "Quick-create Issue", exact: true });
+ await input.fill(title);
+ await input.press("Control+Enter");
+ await expect(page).toHaveURL(/\/w\/forge\/issues\//);
+ await expect(page.getByRole("heading", { level: 1, name: title, exact: true })).toBeVisible();
+ return page.url();
+}
+
+test("daily issue shows files, related work and filtered discussion together", async ({ page }) => {
+ const stamp = Date.now();
+ const relatedTitle = `Daily related ${stamp}`;
+ await createIssue(page, relatedTitle);
+ const title = `Daily home maintenance ${stamp}`;
+ await createIssue(page, title);
+ const attachments = page.getByRole("region", { name: "Attachments", exact: true });
+ const related = page.getByRole("region", { name: "Related work", exact: true });
+ const automation = page.getByRole("region", { name: "Automation and delivery", exact: true });
+ await expect(attachments).toBeVisible();
+ await expect(related).toBeVisible();
+ await expect(automation).toBeVisible();
+ await expect(page.getByRole("tab", { name: "Attachments", exact: true })).toHaveCount(0);
+
+ const files = Array.from({ length: 5 }, (_, index) => ({
+ name: `maintenance-note-${index + 1}.txt`,
+ mimeType: "text/plain",
+ buffer: Buffer.from(`Disposable local daily-workflow evidence ${stamp} / ${index}`),
+ }));
+ await attachments.locator('input[type="file"]').setInputFiles(files);
+ await expect(
+ attachments.getByRole("button", { name: "View all attachments (5)", exact: true }),
+ ).toBeVisible({ timeout: 60_000 });
+ await expect(attachments.getByRole("button", { name: /^Preview attachment:/ })).toHaveCount(4);
+ await attachments.getByRole("button", { name: "View all attachments (5)", exact: true }).click();
+ await expect(attachments.getByRole("button", { name: /^Preview attachment:/ })).toHaveCount(5);
+ await attachments
+ .getByRole("button", { name: "Preview attachment: maintenance-note-1.txt", exact: true })
+ .click();
+ await expect(page.getByRole("dialog")).toBeVisible();
+ await expect(
+ page.getByRole("dialog", { name: "Attachment preview: maintenance-note-1.txt", exact: true }),
+ ).toBeVisible();
+ await expect(
+ page
+ .getByRole("dialog")
+ .getByText(`Disposable local daily-workflow evidence ${stamp} / 0`, { exact: true }),
+ ).toBeVisible({ timeout: 60_000 });
+ await page.keyboard.press("Escape");
+ await attachments.getByRole("button", { name: "Show fewer files", exact: true }).click();
+
+ await related.getByRole("button", { name: "Add relation", exact: true }).first().click();
+ await page.getByRole("combobox", { name: "Relation type", exact: true }).click();
+ await page.getByRole("option", { name: "Related to", exact: true }).click();
+ await related.getByPlaceholder("Search by key or title…").fill(relatedTitle);
+ await related.getByRole("button", { name: new RegExp(relatedTitle) }).click();
+ await related.getByRole("button", { name: "Add", exact: true }).click();
+ await expect(related.getByRole("link", { name: new RegExp(relatedTitle) })).toBeVisible();
+
+ await page
+ .getByRole("textbox", { name: "Comment composer", exact: true })
+ .fill("Replacement part ordered; follow up tomorrow.");
+ await page.getByRole("button", { name: /^Comment/ }).click();
+ await expect(page.getByRole("textbox", { name: "Comment composer", exact: true })).toHaveValue(
+ "",
+ );
+ await expect(
+ page.getByText("Replacement part ordered; follow up tomorrow.", { exact: true }),
+ ).toBeVisible();
+ await page.getByRole("button", { name: "Updates", exact: true }).click();
+ await expect(
+ page.getByText("Replacement part ordered; follow up tomorrow.", { exact: true }),
+ ).toHaveCount(0);
+ await page.getByRole("button", { name: "All activity", exact: true }).click();
+ await expect(
+ page.getByText("Replacement part ordered; follow up tomorrow.", { exact: true }),
+ ).toHaveCount(1);
+ await page.getByRole("button", { name: "Discussion", exact: true }).click();
+ await page.keyboard.press("2");
+ await expect(attachments).toBeFocused();
+ await page.keyboard.press("3");
+ await expect(related).toBeFocused();
+ await page.getByRole("heading", { level: 1, name: title }).scrollIntoViewIfNeeded();
+ await page.locator("#issue-activity").evaluate((element) => {
+ const scroller = element.closest(".overflow-y-auto");
+ if (scroller) scroller.scrollTop = 0;
+ });
+ await page.screenshot({ path: "output/playwright/daily-workflow-desktop.png" });
+ await page.setViewportSize({ width: 390, height: 844 });
+ await page.getByRole("heading", { level: 1, name: title }).scrollIntoViewIfNeeded();
+ await page.locator("#issue-activity").evaluate((element) => {
+ const scroller = element.closest(".overflow-y-auto");
+ if (scroller) scroller.scrollTop = 0;
+ });
+ expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(
+ true,
+ );
+ await page.screenshot({ path: "output/playwright/daily-workflow-mobile.png" });
+});
+
+test("Ask an agent prepares the chosen mode without dispatching or claiming", async ({ page }) => {
+ await createIssue(page, `Daily request draft ${Date.now()}`);
+ const writes: string[] = [];
+ page.on("request", (request) => {
+ if (
+ request.method() === "POST" &&
+ /\/api\/trpc\/(?:issue\.update|comment\.create|workSession\.claim)/.test(request.url())
+ )
+ writes.push(request.url());
+ });
+ const composer = page.getByRole("textbox", { name: "Comment composer", exact: true });
+ await composer.fill("@victor /execute Existing draft context.");
+ const mode = page.getByRole("combobox", { name: "Mode for Victor", exact: true });
+ await expect(mode).toContainText("Execute");
+ await page.getByText("Add agent or delivery…", { exact: true }).click();
+ await page.getByRole("button", { name: /^Ask an agent/ }).click();
+ await expect(page.getByRole("radio", { name: "Discuss", exact: true })).toHaveAttribute(
+ "aria-checked",
+ "true",
+ );
+ await page.getByRole("dialog").getByRole("option").filter({ hasText: "Victor" }).click();
+ await expect(composer).toHaveValue(/@victor \/discuss Existing draft context\./);
+ await expect(mode).toContainText("Discuss");
+ expect(writes).toEqual([]);
+ await expect(page.getByRole("button", { name: "Discussion", exact: true })).toHaveAttribute(
+ "aria-pressed",
+ "true",
+ );
+});
diff --git a/tests/e2e/issue-flow.spec.ts b/tests/e2e/issue-flow.spec.ts
index 724766d1..034d3c85 100644
--- a/tests/e2e/issue-flow.spec.ts
+++ b/tests/e2e/issue-flow.spec.ts
@@ -7,6 +7,51 @@ import { test, expect } from "@playwright/test";
*/
test.describe("Issue flow", () => {
+ test("capture preserves task drafts and saves visible fields", async ({ page }) => {
+ await page.goto("/w/forge/issues");
+ await page.getByRole("button", { name: "New issue ⇧C", exact: true }).click();
+ const type = page.getByRole("combobox", { name: "Item type", exact: true });
+ await type.click();
+ await page.getByRole("option", { name: "Task", exact: true }).click();
+ const title = `E2E task capture ${Date.now()}`;
+ await page.getByRole("textbox", { name: "Quick-create Task", exact: true }).fill(title);
+ await page
+ .getByRole("textbox", { name: "Description optional", exact: true })
+ .fill("Keep this context when restoring the draft.");
+ await page.getByRole("combobox", { name: "New item priority", exact: true }).click();
+ await page.getByRole("option", { name: "High", exact: true }).click();
+ await page.getByRole("textbox", { name: "New item due date", exact: true }).fill("2027-01-20");
+ await expect(page.getByRole("textbox", { name: "Quick-create Task", exact: true })).toHaveValue(
+ title,
+ );
+ await page.keyboard.press("Escape");
+ await page.getByRole("button", { name: "New issue ⇧C", exact: true }).click();
+ await type.click();
+ await page.getByRole("option", { name: "Task", exact: true }).click();
+ await expect(
+ page.getByRole("textbox", { name: "Description optional", exact: true }),
+ ).toHaveValue("Keep this context when restoring the draft.");
+ await expect(
+ page.getByRole("combobox", { name: "New item priority", exact: true }),
+ ).toContainText("High");
+ await expect(page.getByRole("textbox", { name: "New item due date", exact: true })).toHaveValue(
+ "2027-01-20",
+ );
+ const createdResponse = page.waitForResponse(
+ (response) =>
+ response.url().includes("/api/trpc/issue.create") && response.request().method() === "POST",
+ );
+ await page.getByRole("button", { name: /Create & open/ }).click();
+ const response = await (await createdResponse).json();
+ const created = response[0].result.data.json;
+ expect(created.kind).toBe("TASK");
+ expect(created.priority).toBe("HIGH");
+ expect(created.dueDate).toMatch(/^2027-01-20T/);
+ expect(created.description).toBe("Keep this context when restoring the draft.");
+ await expect(page).toHaveURL(/\/w\/forge\/issues\//);
+ await expect(page.getByText(title, { exact: true }).first()).toBeVisible();
+ });
+
test("create and transition an issue", async ({ page, context }) => {
await context.grantPermissions(["clipboard-read", "clipboard-write"]);
await page.goto("/w/forge/inbox");
@@ -29,6 +74,17 @@ test.describe("Issue flow", () => {
await expect(page).toHaveURL(/\/w\/forge\/issues\//, { timeout: 20_000 });
await expect(page.getByText(title).first()).toBeVisible();
+ // Explicit New issue actions must not inherit the issue-detail page's
+ // contextual Comment mode. Tab should still move through the form.
+ await page.locator("header [data-quick-create-mode='issue']").click();
+ const createDialog = page.getByRole("dialog", { name: "Quick-create Issue" });
+ await expect(createDialog).toBeVisible();
+ const nextTitle = createDialog.getByRole("textbox", { name: "Quick-create Issue" });
+ await nextTitle.focus();
+ await nextTitle.press("Tab");
+ await expect(createDialog.getByRole("textbox", { name: "Description optional" })).toBeFocused();
+ await page.keyboard.press("Escape");
+
await page.getByRole("button", { name: "Copy local-agent handoff" }).click();
await expect(page.getByText("Local-agent handoff copied.")).toBeVisible();
const handoff = await page.evaluate(() => navigator.clipboard.readText());
@@ -44,14 +100,15 @@ test.describe("Issue flow", () => {
"Do not merge, release, or deploy without explicit operator approval.",
);
- // The bare issue URL opens recent Activity, and the rail itself does not
- // become a second vertical scroll container.
- const activityTab = page.getByRole("tab", { name: /Activity/ });
- const attachmentsTab = page.getByRole("tab", { name: "Attachments" });
- const tabPanel = page.getByRole("tabpanel");
- await expect(activityTab).toHaveAttribute("aria-selected", "true");
- await expect(activityTab).toHaveAttribute("tabindex", "0");
- await expect(tabPanel).toHaveAttribute("aria-labelledby", /activity-tab$/);
+ // Context remains visible without tab switching; routine updates are optional.
+ await expect(page.getByRole("button", { name: "Discussion", exact: true })).toHaveAttribute(
+ "aria-pressed",
+ "true",
+ );
+ const attachments = page.locator("#issue-attachments");
+ const relations = page.locator("#issue-relations");
+ await expect(attachments).toBeVisible();
+ await expect(relations).toBeVisible();
const railSurface = page.getByLabel("Issue detail rail").locator(":scope > div");
await expect(railSurface).toBeVisible();
const railMetrics = await railSurface.evaluate((el) => ({
@@ -60,21 +117,22 @@ test.describe("Issue flow", () => {
}));
expect(railMetrics.scrollHeight - railMetrics.clientHeight).toBeLessThanOrEqual(1);
- // Standard tab arrow navigation changes both focus and the deep-link URL.
- await activityTab.focus();
- await activityTab.press("ArrowRight");
- await expect(attachmentsTab).toHaveAttribute("aria-selected", "true");
- await expect(attachmentsTab).toBeFocused();
- await expect(page).toHaveURL(/\?tab=attachments$/);
-
- // Returning to the bare path restores Activity as the default.
- await page.goto(new URL(page.url()).pathname);
- await expect(activityTab).toHaveAttribute("aria-selected", "true");
+ await page.getByRole("button", { name: "Discussion", exact: true }).focus();
+ await page.keyboard.press("2");
+ await expect(attachments).toBeFocused();
+ await page.keyboard.press("3");
+ await expect(relations).toBeFocused();
+ await page.goto(`${new URL(page.url()).pathname}?tab=activity`);
+ await expect(page.getByRole("button", { name: "All activity", exact: true })).toHaveAttribute(
+ "aria-pressed",
+ "true",
+ );
// A browser claim is a manual UI invocation. It must not be guessed as a
// Codex Desktop/MCP connection or as runtime execution.
const delivery = page.getByRole("region", { name: "Code work coordination" });
- await delivery.getByRole("button", { name: "Start isolated work" }).click();
+ await page.getByText("Add agent or delivery…", { exact: true }).click();
+ await page.getByRole("button", { name: "Start delivery session", exact: true }).click();
await delivery.getByLabel("Repository").fill("Codename-11/Forge");
await delivery
.getByRole("textbox", { name: "Branch" })
diff --git a/tests/e2e/issue-reminders.spec.ts b/tests/e2e/issue-reminders.spec.ts
new file mode 100644
index 00000000..145f1cd5
--- /dev/null
+++ b/tests/e2e/issue-reminders.spec.ts
@@ -0,0 +1,78 @@
+import { expect, test } from "@playwright/test";
+import { PrismaClient } from "@prisma/client";
+
+const databaseUrl =
+ process.env.E2E_MANAGE_STACK === "0"
+ ? process.env.DATABASE_URL
+ : (process.env.E2E_DATABASE_URL ??
+ "postgresql://forge:forge@localhost:55432/forge_e2e?schema=public");
+if (
+ !databaseUrl ||
+ (process.env.E2E_MANAGE_STACK !== "0" && new URL(databaseUrl).pathname !== "/forge_e2e")
+)
+ throw new Error("Reminder E2E requires the disposable E2E database.");
+const db = new PrismaClient({ datasourceUrl: databaseUrl });
+if (process.env.E2E_MANAGE_STACK !== "0") {
+ const target = new URL(databaseUrl);
+ if (
+ !["localhost", "127.0.0.1"].includes(target.hostname) ||
+ target.port !== "55432" ||
+ target.username !== "forge" ||
+ target.password !== "forge" ||
+ target.searchParams.get("schema") !== "public"
+ ) {
+ throw new Error("Reminder E2E requires the guarded local Postgres endpoint.");
+ }
+}
+test.afterAll(() => db.$disconnect());
+
+test("schedule, surface, snooze, and dismiss a personal issue reminder", async ({ page }) => {
+ await page.goto("/w/forge/issues");
+ await page.keyboard.press("Shift+C");
+ const title = `E2E personal reminder ${Date.now()}`;
+ await page.getByPlaceholder(/Issue title/).fill(title);
+ await page.getByPlaceholder(/Issue title/).press("Control+Enter");
+ await expect(page).toHaveURL(/\/w\/forge\/issues\//);
+ await page.locator("summary", { hasText: "Remind me" }).click();
+ await page.getByRole("button", { name: "In 1 hour", exact: true }).click();
+ const saved = page.waitForResponse(
+ (response) =>
+ response.url().includes("/api/trpc/reminder.set") && response.request().method() === "POST",
+ );
+ await page.getByRole("button", { name: "Save reminder", exact: true }).click();
+ const payload = await (await saved).json();
+ const reminder = payload[0].result.data.json;
+ await expect(page.locator("summary", { hasText: "My reminder" })).toBeVisible();
+ await expect(page.getByRole("button", { name: "Cancel reminder", exact: true })).toBeVisible();
+
+ // Advance only this test's persisted schedule; no wall-clock sleeps or production data.
+ await db.issueReminder.update({
+ where: { id: reminder.id },
+ data: { remindAt: new Date(Date.now() - 1000) },
+ });
+ await page.reload();
+ await page.getByRole("button", { name: /^(?:\d+|No) unread items?$/ }).click();
+ const inbox = page.getByRole("region", { name: "Your reminders" });
+ const card = inbox.locator("article").filter({ hasText: title });
+ await expect(inbox.getByRole("link", { name: new RegExp(title) })).toBeVisible();
+ await card.getByRole("button", { name: "Snooze 1 hour", exact: true }).click();
+ await expect(card).toBeHidden();
+ expect(
+ (await db.issueReminder.findUniqueOrThrow({ where: { id: reminder.id } })).remindAt.getTime(),
+ ).toBeGreaterThan(Date.now());
+
+ await db.issueReminder.update({
+ where: { id: reminder.id },
+ data: { remindAt: new Date(Date.now() - 1000) },
+ });
+ await page.reload();
+ await page.getByRole("button", { name: /^(?:\d+|No) unread items?$/ }).click();
+ await expect(inbox.getByText(title, { exact: false })).toBeVisible();
+ await page.setViewportSize({ width: 390, height: 844 });
+ await page.screenshot({ path: "output/playwright/axi-204-reminder-mobile.png" });
+ await card.getByRole("button", { name: "Dismiss reminder", exact: true }).click();
+ await expect(card).toBeHidden();
+ expect(
+ (await db.issueReminder.findUniqueOrThrow({ where: { id: reminder.id } })).dismissedAt,
+ ).not.toBeNull();
+});
diff --git a/tests/e2e/mission-control-agent-management.spec.ts b/tests/e2e/mission-control-agent-management.spec.ts
index d5f98b85..85f8489d 100644
--- a/tests/e2e/mission-control-agent-management.spec.ts
+++ b/tests/e2e/mission-control-agent-management.spec.ts
@@ -10,15 +10,25 @@ test.describe.serial("Mission Control agent management", () => {
await page.goto("/settings/agents", { waitUntil: "domcontentloaded" });
await expect(page).toHaveURL(/\/agents$/);
- await expect(page.getByRole("heading", { name: "Agent fleet" })).toBeVisible();
- await expect(page.getByText("Global control plane", { exact: true })).toBeVisible();
+ await expect(page.getByRole("heading", { name: "Agents", exact: true })).toBeVisible();
+ await expect(
+ page.getByText("Ownership, catalog availability, and workspace access are separate.", {
+ exact: true,
+ }),
+ ).toBeVisible();
- await page.getByRole("button", { name: "New profile" }).click();
+ await page.getByRole("button", { name: "Connect via MCP", exact: true }).click();
+ await page.getByRole("combobox", { name: "Setup workspace" }).click();
+ await page.getByRole("option", { name: /Forge\s+FRG/ }).click();
+ await page.getByRole("button", { name: "Create a new agent", exact: true }).click();
const createDialog = page.getByRole("dialog", { name: "Create agent profile" });
await createDialog.getByPlaceholder("Review Bot").fill(name);
await createDialog.getByPlaceholder("review-bot").fill(profileKey);
await createDialog.getByRole("button", { name: "Create profile" }).click();
+ await expect(page).toHaveURL(/\/agents\/[^/?]+\?/);
+ const createdProfileId = new URL(page.url()).pathname.split("/").at(-1)!;
+ await page.goto("/agents");
await expect(page.getByText(name, { exact: true })).toBeVisible();
await page.setViewportSize({ width: 900, height: 650 });
const fleetScrollRegion = page.getByTestId("agent-fleet-scroll-region");
@@ -34,7 +44,7 @@ test.describe.serial("Mission Control agent management", () => {
await page.getByText(name, { exact: true }).click();
await expect(page).toHaveURL(/\/agents\/[^/]+$/);
- await expect(page.getByText("Global profile", { exact: true }).first()).toBeVisible();
+ await expect(page.getByText("Identity & execution", { exact: true })).toBeVisible();
const profileScrollRegion = page.getByTestId("agent-profile-scroll-region");
await expect
.poll(() =>
@@ -48,19 +58,20 @@ test.describe.serial("Mission Control agent management", () => {
const detailUrl = new URL(page.url());
const profileId = detailUrl.pathname.split("/").at(-1)!;
+ expect(profileId).toBe(createdProfileId);
await page.goto(`/settings/agents/${profileId}`, { waitUntil: "domcontentloaded" });
await expect(page).toHaveURL(`/agents/${profileId}`);
- await page.getByRole("combobox", { name: "Workspace to bind" }).click();
+ await page.getByRole("combobox", { name: "Workspace to add" }).click();
await page.getByRole("option", { name: /Forge\s+FRG/ }).click();
- await page.getByRole("button", { name: "Bind", exact: true }).click();
+ await page.getByRole("button", { name: "Add access", exact: true }).click();
await expect(page.getByText("Forge", { exact: true }).first()).toBeVisible();
await expect(page.getByRole("link", { name: "Workspace policy" })).toBeVisible();
- await page.getByRole("button", { name: "Unbind" }).click();
+ await page.getByRole("button", { name: "Remove access", exact: true }).click();
const unbindDialog = page.getByRole("alertdialog", { name: /Unbind from Forge/ });
await unbindDialog.getByRole("button", { name: /^Unbind/ }).click();
- await expect(page.getByText("Not bound to any workspace", { exact: true })).toBeVisible();
+ await expect(page.getByText("No visible workspace access", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "Remove profile" }).click();
const removeDialog = page.getByRole("alertdialog", { name: `Remove ${name}?` });
@@ -74,7 +85,9 @@ test.describe.serial("Mission Control agent management", () => {
}) => {
await page.goto("/w/forge/settings/agents", { waitUntil: "domcontentloaded" });
await expect(page.getByRole("heading", { name: "Agent policy" })).toBeVisible();
- await expect(page.getByRole("link", { name: "Manage profiles & bindings" })).toBeVisible();
+ await expect(
+ page.getByRole("link", { name: "Manage agents & workspace access" }),
+ ).toBeVisible();
await expect(page.getByText("Available to bind", { exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Delete", exact: true })).toHaveCount(0);
diff --git a/tests/e2e/mobile-smoke.spec.ts b/tests/e2e/mobile-smoke.spec.ts
index d788c5a1..e0da81e5 100644
--- a/tests/e2e/mobile-smoke.spec.ts
+++ b/tests/e2e/mobile-smoke.spec.ts
@@ -43,7 +43,7 @@ async function createIssueFromMobileTopbar(page: Page, title: string) {
await expect(titleField).toHaveValue(title);
await titleField.press("Control+Enter");
await expect(page).toHaveURL(/\/w\/forge\/issues\//, { timeout: 20_000 });
- await expect(page.getByText(title).first()).toBeVisible();
+ await expect(page.getByRole("heading", { name: title, exact: true })).toBeVisible();
}
test.describe("Mobile smoke", () => {
@@ -120,7 +120,7 @@ test.describe("Mobile smoke", () => {
}) => {
await page.setViewportSize({ width, height: HEIGHT });
- await page.goto("/");
+ await page.goto("/mission-control");
await expect(page.getByRole("heading", { name: "Mission Control" })).toBeVisible();
const activityPill = page.getByTitle("Activity · live runs and changes · shortcut G then 5");
await expect(activityPill).toBeVisible();
@@ -132,7 +132,7 @@ test.describe("Mobile smoke", () => {
await expectNoDocumentHorizontalOverflow(page, `activity feed at ${width}px`);
await page.goto("/agents");
- await expect(page.getByRole("heading", { name: "Agent fleet" })).toBeVisible();
+ await expect(page.getByRole("heading", { name: "Agents", exact: true })).toBeVisible();
await page.getByRole("button", { name: "Open menu" }).click();
const globalMenu = page.getByRole("dialog", { name: "Menu" });
await expect(globalMenu).toBeVisible();
@@ -166,7 +166,7 @@ test.describe("Mobile smoke", () => {
test("primary phone-width issue workflow supports create, edit, comment, status, assignment, agents, and runtimes", async ({
page,
- }) => {
+ }, testInfo) => {
await page.setViewportSize({ width: 390, height: HEIGHT });
const title = `Mobile UX ${Date.now()}`;
const editedTitle = `${title} edited`;
@@ -193,7 +193,13 @@ test.describe("Mobile smoke", () => {
await page.getByRole("option", { name: "In Progress" }).click();
await expect(status).toContainText("In Progress");
- await page.getByTitle(/Assign agent/i).click();
+ await page.getByRole("link", { name: "Automation", exact: true }).click();
+ await page.getByText("Add agent or delivery…", { exact: true }).click();
+ await testInfo.attach("Mobile automation menu clears the floating dock", {
+ body: await page.screenshot(),
+ contentType: "image/png",
+ });
+ await page.getByRole("button", { name: "Assign an agent", exact: true }).click();
await expect(page.getByRole("dialog")).toBeVisible();
await expect(page.getByRole("radiogroup", { name: "Engagement mode" })).toBeVisible();
await page.getByPlaceholder(/Assign agent/i).fill("victor");
diff --git a/tests/e2e/multiws-restructure.spec.ts b/tests/e2e/multiws-restructure.spec.ts
index 6d359c43..78505bd9 100644
--- a/tests/e2e/multiws-restructure.spec.ts
+++ b/tests/e2e/multiws-restructure.spec.ts
@@ -16,12 +16,16 @@ import { test, expect } from "@playwright/test";
test.describe("multi-workspace restructure", () => {
test("global Mission Control renders without a workspace and is read-only", async ({ page }) => {
- await page.goto("/", { waitUntil: "domcontentloaded" });
+ await page.goto("/mission-control", { waitUntil: "domcontentloaded" });
// Mission Control page header (no /w/ in the URL).
- await expect(page).toHaveURL(/\/$/);
+ await expect(page).toHaveURL(/\/mission-control$/);
await expect(page.getByRole("heading", { name: "Mission Control" })).toBeVisible();
// Read-only badge in the global top bar.
- await expect(page.getByText("Read-only across workspaces", { exact: true })).toBeVisible();
+ await expect(
+ page.getByText("Agent operations and delivery across your accessible workspaces.", {
+ exact: true,
+ }),
+ ).toBeVisible();
await expect(page.getByText("Run capacity", { exact: true })).toBeVisible();
const activityPill = page.getByRole("link", { name: /Open activity/ });
await expect(activityPill).toBeVisible();
@@ -57,7 +61,7 @@ test.describe("multi-workspace restructure", () => {
await breadcrumb.getByRole("link", { name: "Forge" }).click();
await expect(page).toHaveURL(/\/$/);
- await expect(page.getByRole("heading", { name: "Mission Control" })).toBeVisible();
+ await expect(page.getByRole("heading", { name: "My work" })).toBeVisible();
});
test("global inbox and activity provide inspectable context", async ({ page }) => {
@@ -114,7 +118,7 @@ test.describe("multi-workspace restructure", () => {
test("global agents page lists profiles", async ({ page }) => {
await page.goto("/agents", { waitUntil: "domcontentloaded" });
- await expect(page.getByRole("heading", { name: "Agent fleet" })).toBeVisible();
+ await expect(page.getByRole("heading", { name: "Agents", exact: true })).toBeVisible();
// Seeded profiles are owned by the signed-in owner.
await expect(page.getByText("Victor").first()).toBeVisible();
await expect(page.getByText("Atlas").first()).toBeVisible();
@@ -122,12 +126,18 @@ test.describe("multi-workspace restructure", () => {
test("settings scope, redirects, and dispatch defaults stay authoritative", async ({ page }) => {
await page.goto("/settings", { waitUntil: "domcontentloaded" });
- await expect(page.getByText("Account & identity", { exact: true })).toBeVisible();
- await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible();
+ await expect(
+ page.getByText("Your profile, sign-in, preferences, and personal resources.", {
+ exact: true,
+ }),
+ ).toBeVisible();
+ await expect(page.getByRole("heading", { name: "My account" })).toBeVisible();
await page.goto("/settings/auth", { waitUntil: "domcontentloaded" });
await expect(page).toHaveURL(/\/admin\/auth$/);
- await expect(page.getByText(/Instance scope/).first()).toBeVisible();
+ await expect(
+ page.getByText("Instance administration · review the scope of each change", { exact: true }),
+ ).toBeVisible();
await expect(page.getByText("Identity & sign-in").first()).toBeVisible();
await page.goto("/w/forge/settings/dispatch-rules", { waitUntil: "domcontentloaded" });
@@ -149,24 +159,24 @@ test.describe("multi-workspace restructure", () => {
await page.goto("/agents", { waitUntil: "domcontentloaded" });
await page.locator('a[href^="/agents/"]', { hasText: "Atlas" }).first().click();
- await expect(page.getByText("Global profile", { exact: true }).first()).toBeVisible();
+ await expect(page.getByText("Identity & execution", { exact: true })).toBeVisible();
// Seed leaves Atlas unbound. Mission Control owns adoption into Forge.
- await page.getByRole("combobox", { name: "Workspace to bind" }).click();
+ await page.getByRole("combobox", { name: "Workspace to add" }).click();
await page.getByRole("option", { name: /Forge\s+FRG/ }).click();
- await page.getByRole("button", { name: /^Bind$/ }).click();
+ await page.getByRole("button", { name: "Add access", exact: true }).click();
await expect(page.getByRole("link", { name: "Workspace policy" })).toBeVisible({
timeout: 15_000,
});
await expect(page.getByRole("link", { name: "Add MCP client" })).toBeVisible();
- await page.getByRole("button", { name: "Unbind" }).click();
+ await page.getByRole("button", { name: "Remove access", exact: true }).click();
await page
.getByRole("alertdialog", { name: /Unbind from Forge/ })
.getByRole("button", { name: /^Unbind/ })
.click();
- await expect(page.getByText("Not bound to any workspace", { exact: true })).toBeVisible({
+ await expect(page.getByText("No visible workspace access", { exact: true })).toBeVisible({
timeout: 15_000,
});
});
diff --git a/tests/e2e/personal-workspace.spec.ts b/tests/e2e/personal-workspace.spec.ts
index 0ccee76d..7d887a36 100644
--- a/tests/e2e/personal-workspace.spec.ts
+++ b/tests/e2e/personal-workspace.spec.ts
@@ -29,7 +29,9 @@ test("creates a personal workspace and supports the Today flow", async ({ page }
await expect(page).toHaveURL(new RegExp(`/w/${slug}/inbox$`));
await page.goto(`/w/${slug}/dashboard`);
- await expect(page.getByRole("heading", { name: /Good (morning|afternoon|evening)/ })).toBeVisible();
+ await expect(
+ page.getByRole("heading", { name: /Good (morning|afternoon|evening)/ }),
+ ).toBeVisible();
await expect(page.getByRole("heading", { name: "Agent companion" })).toBeVisible();
await expect(page.getByText("New task", { exact: true })).toBeVisible();
// Route changes intentionally abort in-flight tRPC requests from the prior
@@ -43,6 +45,29 @@ test("creates a personal workspace and supports the Today flow", async ({ page }
await page.getByLabel("Complete Book dentist appointment").click();
await expect(page.getByText("Book dentist appointment", { exact: true })).toBeHidden();
+ await page.setViewportSize({ width: 390, height: 844 });
+ await page.getByRole("combobox", { name: "Task due date", exact: true }).click();
+ await page.getByRole("option", { name: "Someday", exact: true }).click();
+ await page.getByLabel("Task title").fill("Plan the garden");
+ const createdTask = page.waitForResponse(
+ (response) =>
+ response.url().includes("/api/trpc/issue.create") && response.request().method() === "POST",
+ );
+ await page.getByRole("button", { name: "Add", exact: true }).click();
+ const taskPayload = await (await createdTask).json();
+ expect(taskPayload[0].result.data.json.kind).toBe("TASK");
+ const someday = page.locator("section", {
+ has: page.getByRole("heading", { name: "Someday", exact: true }),
+ });
+ await expect(someday.getByText("Plan the garden", { exact: true })).toBeVisible();
+ await expect(
+ page.getByText(/^No tasks due today.*Capture what matters next\.$/),
+ ).toBeVisible();
+ await expect(page.getByText("Task added.", { exact: true })).toHaveCount(0);
+ await someday.scrollIntoViewIfNeeded();
+ await page.screenshot({ path: "output/playwright/axi-203-personal-mobile.png", fullPage: true });
+ await page.setViewportSize({ width: 1280, height: 720 });
+
await page.getByPlaceholder("Capture a note…").fill("Ask about weekend availability");
await page.getByRole("button", { name: "Save", exact: true }).click();
await expect(page.getByText("Ask about weekend availability", { exact: true })).toBeVisible();
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-request-draft.test.ts b/tests/unit/agent-request-draft.test.ts
new file mode 100644
index 00000000..15275dd7
--- /dev/null
+++ b/tests/unit/agent-request-draft.test.ts
@@ -0,0 +1,74 @@
+import { describe, expect, it } from "vitest";
+import { prepareAgentRequestDraft, resolveComposerAgentRequests } from "@/lib/agent-request-draft";
+import { resolveAgentRequests } from "@/lib/agent-request-parser";
+
+describe("explicit Ask agent selection", () => {
+ it("replaces prior Execute text and override with the newly selected Discuss", () => {
+ const previousOverrides = { codex: { mode: "EXECUTE" as const, assignIssue: true } };
+ const next = prepareAgentRequestDraft(
+ "@codex /execute fix this",
+ "codex",
+ "DISCUSS",
+ "@codex /discuss ",
+ );
+ const overrides = { ...previousOverrides, [next.profileKey]: next.override };
+ expect(next.body).toBe("@codex /discuss fix this");
+ const submitted = resolveComposerAgentRequests(next.body, overrides);
+ expect(resolveAgentRequests(submitted, next.body)).toEqual([
+ { profileKey: "codex", mode: "DISCUSS", assignIssue: false },
+ ]);
+ });
+
+ it("updates all selected-agent modes without duplicate requests or losing other text", () => {
+ const body =
+ "Please @Codex:execute this; ask @victor /review separately.\nLater @codex execute the follow-up.";
+ const next = prepareAgentRequestDraft(body, "@CODEX", "RESEARCH", "@codex /research ");
+ expect(next.body).toBe(
+ "Please @Codex:/research this; ask @victor /review separately.\nLater @codex /research the follow-up.",
+ );
+ expect(resolveComposerAgentRequests(next.body, { [next.profileKey]: next.override })).toEqual([
+ { profileKey: "codex", mode: "RESEARCH", assignIssue: false },
+ { profileKey: "victor", mode: "REVIEW", assignIssue: false },
+ ]);
+ });
+
+ it("adds explicit mode to an existing bare mention without appending a second mention", () => {
+ expect(
+ prepareAgentRequestDraft("@codex can you investigate?", "codex", "REVIEW", "@codex /review ")
+ .body,
+ ).toBe("@codex /review can you investigate?");
+ });
+
+ it("preserves other agent overrides and ordinary draft content", () => {
+ const next = prepareAgentRequestDraft(
+ "Keep these notes.\n@victor /execute the separate task.",
+ "codex",
+ "DISCUSS",
+ "@codex /discuss ",
+ );
+ expect(next.body).toBe(
+ "Keep these notes.\n@victor /execute the separate task.\n\n@codex /discuss ",
+ );
+ const requests = resolveComposerAgentRequests(next.body, {
+ victor: { mode: "EXECUTE", assignIssue: true },
+ [next.profileKey]: next.override,
+ });
+ expect(requests).toEqual([
+ { profileKey: "victor", mode: "EXECUTE", assignIssue: true },
+ { profileKey: "codex", mode: "DISCUSS", assignIssue: false },
+ ]);
+ });
+
+ it("does not retain stale assignment flags when the mode is not Execute", () => {
+ expect(
+ resolveComposerAgentRequests("@codex /discuss", {
+ codex: { mode: "DISCUSS", assignIssue: true },
+ }),
+ ).toEqual([{ profileKey: "codex", mode: "DISCUSS", assignIssue: false }]);
+ });
+
+ it("requires separate explicit assignment even when Ask mode is Execute", () => {
+ const next = prepareAgentRequestDraft("", "codex", "EXECUTE", "@codex /execute ");
+ expect(next.override).toEqual({ mode: "EXECUTE", assignIssue: false });
+ });
+});
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/dev-workflow.test.ts b/tests/unit/dev-workflow.test.ts
index 3fd59bf3..04337803 100644
--- a/tests/unit/dev-workflow.test.ts
+++ b/tests/unit/dev-workflow.test.ts
@@ -88,6 +88,9 @@ describe("safe local dev command decisions", () => {
it("resolves pnpm and Corepack JavaScript entries from Windows shims", () => {
expect(parseWindowsPnpmEntry('node "%~dp0\\pnpm.mjs" %*')).toBe("pnpm.mjs");
+ expect(parseWindowsPnpmEntry('"%_prog%" "%dp0%\\..\\pnpm\\bin\\pnpm.cjs" %*')).toBe(
+ "..\\pnpm\\bin\\pnpm.cjs",
+ );
expect(parseWindowsPnpmEntry('node "%~dp0\\node_modules\\corepack\\dist\\pnpm.js" %*')).toBe(
"node_modules\\corepack\\dist\\pnpm.js",
);
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/issue-automation-panel.test.ts b/tests/unit/issue-automation-panel.test.ts
new file mode 100644
index 00000000..a57af083
--- /dev/null
+++ b/tests/unit/issue-automation-panel.test.ts
@@ -0,0 +1,208 @@
+import React from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const state = vi.hoisted(() => ({
+ sessions: [] as unknown[],
+ links: [] as unknown[],
+ deliveryError: false,
+ githubError: false,
+ mutate: vi.fn(),
+}));
+vi.mock("@/hooks/use-workspace", () => ({ useWorkspace: () => ({ role: "OWNER" }) }));
+vi.mock("@/lib/trpc", () => {
+ const mutation = { useMutation: () => ({ mutate: state.mutate, isPending: false }) };
+ return {
+ trpc: {
+ useUtils: () => ({}),
+ workSession: {
+ listForIssue: {
+ useQuery: () => ({
+ data: state.sessions,
+ isLoading: false,
+ isError: state.deliveryError,
+ }),
+ },
+ claim: mutation,
+ heartbeat: mutation,
+ advance: mutation,
+ reconcileOwnership: mutation,
+ },
+ github: {
+ listLinked: {
+ useQuery: () => ({ data: state.links, isLoading: false, isError: state.githubError }),
+ },
+ listMappings: { useQuery: () => ({ data: [] }) },
+ sync: mutation,
+ },
+ system: { buildInfo: { useQuery: () => ({ data: {} }) } },
+ },
+ };
+});
+vi.mock("@/components/issue-detail/github-link-modal", () => ({
+ GitHubLinkModal: ({ defaultKind }: { defaultKind?: string }) =>
+ React.createElement("span", { "data-default-link-kind": defaultKind, hidden: true }),
+}));
+(globalThis as typeof globalThis & { React: typeof React }).React = React;
+const { IssueAutomationPanel } = await import("@/components/issue-detail/issue-automation-panel");
+const { WorkCoordinationPanel } = await import("@/components/issue-detail/work-coordination-panel");
+
+const render = (children?: React.ReactNode) =>
+ renderToStaticMarkup(
+ React.createElement(
+ IssueAutomationPanel,
+ {
+ issueId: "issue-1",
+ issueKey: "FRG-20",
+ onAskAgent: state.mutate,
+ onAssignAgent: state.mutate,
+ },
+ children,
+ ),
+ );
+
+describe("issue automation entry point", () => {
+ beforeEach(() => {
+ state.sessions = [];
+ state.links = [];
+ state.deliveryError = false;
+ state.githubError = false;
+ state.mutate.mockClear();
+ });
+
+ it("keeps an empty issue's entry point discoverable without empty delivery and GitHub cards", () => {
+ const html = render();
+ expect(html).toContain("Automation & delivery");
+ expect(html).toContain("Add agent or delivery…");
+ expect(html).toContain("Ask an agent");
+ expect(html).toContain("Assign an agent");
+ expect(html).toContain("Discuss, Research, Review, or Execute");
+ expect(html).toContain("Start delivery session");
+ expect(html).not.toContain('aria-label="Code work coordination"');
+ expect(html).not.toContain('id="github-links"');
+ expect(html).not.toMatch(/]*\sopen/);
+ expect(state.mutate).not.toHaveBeenCalled();
+ });
+
+ it("opens implementation linking with the native IMPLEMENTS relation", () => {
+ const html = render();
+ expect(html).toContain("Link implementation PR");
+ expect(html).toContain('data-default-link-kind="IMPLEMENTS"');
+ });
+
+ it("keeps workstream outcomes and required actions outside the collapsed add menu", () => {
+ const html = render(React.createElement("div", null, "Waiting on you · Approve request"));
+ expect(html.indexOf("Waiting on you · Approve request")).toBeGreaterThan(
+ html.indexOf(" "),
+ );
+ });
+
+ it("shows an existing delivery stage and PR while preventing a second active session", () => {
+ state.sessions = [
+ {
+ id: "session-1",
+ status: "MERGED",
+ source: "MCP",
+ repoFullName: "owner/repo",
+ branch: "codex/frg-20",
+ baseBranch: "main",
+ participants: [],
+ ownerAgent: { id: "agent-1", name: "Codex", profileKey: "codex" },
+ ownerUser: { name: "Bailey" },
+ ownerConnection: null,
+ lastHeartbeatAt: new Date(),
+ pullRequest: {
+ url: "https://github.com/owner/repo/pull/12",
+ repoFullName: "owner/repo",
+ number: 12,
+ state: "merged",
+ },
+ },
+ ];
+ const html = render();
+ expect(html).toContain("Merged");
+ expect(html).toContain("owner/repo#12");
+ expect(html).toContain("This issue already has an open delivery session");
+ expect(html).toContain("MCP status is unconfirmed");
+ expect(html).toContain("Delivery evidence");
+ expect(state.mutate).not.toHaveBeenCalled();
+ });
+
+ it("keeps linked PR summary, relation and state visible while evidence stays collapsed", () => {
+ state.links = [
+ {
+ id: "link-1",
+ kind: "IMPLEMENTS",
+ externalResource: {
+ id: "resource-1",
+ resourceType: "PULL_REQUEST",
+ title: "Implement daily workflow",
+ url: "https://github.com/owner/repo/pull/12",
+ repoFullName: "owner/repo",
+ number: 12,
+ state: "merged",
+ metadata: { checks: { status: "pending" } },
+ },
+ },
+ ];
+ const html = render();
+ expect(html).toContain("Implement daily workflow");
+ expect(html).toContain("implements");
+ expect(html).toContain("merged");
+ expect(html).toContain("GitHub evidence");
+ expect(html).not.toContain("checks pending");
+ expect(html).not.toMatch(/]*\sopen/);
+ });
+
+ it("does not mistake failed queries for an empty issue", () => {
+ state.deliveryError = true;
+ state.githubError = true;
+ const html = render();
+ expect(html).toContain("Delivery could not be loaded");
+ expect(html).toContain("GitHub links could not be loaded");
+ expect(html).toContain("Existing ownership may still apply");
+ });
+
+ it("opens an explicit claim form after an earlier session is terminal without claiming on render", () => {
+ state.sessions = [{ id: "old", status: "VERIFIED", source: "MANUAL", participants: [] }];
+ const html = renderToStaticMarkup(
+ React.createElement(WorkCoordinationPanel, {
+ issueId: "issue-1",
+ issueKey: "FRG-20",
+ embedded: true,
+ createOpen: true,
+ }),
+ );
+ expect(html).toContain("Start delivery session");
+ expect(html).toContain("Repository");
+ expect(html).toContain("codex/frg-20-work");
+ expect(html).toContain("Claim work");
+ expect(html).not.toContain(">Verified<");
+ expect(state.mutate).not.toHaveBeenCalled();
+ });
+
+ it("retains the active session when ownership changes while the claim form is open", () => {
+ state.sessions = [
+ {
+ id: "other",
+ status: "IN_PROGRESS",
+ source: "MANUAL",
+ participants: [],
+ lastHeartbeatAt: new Date(),
+ branch: "existing",
+ },
+ ];
+ const html = renderToStaticMarkup(
+ React.createElement(WorkCoordinationPanel, {
+ issueId: "issue-1",
+ issueKey: "FRG-20",
+ embedded: true,
+ createOpen: true,
+ }),
+ );
+ expect(html).toContain("In progress");
+ expect(html).toContain("existing");
+ expect(html).not.toContain("Claim work");
+ expect(state.mutate).not.toHaveBeenCalled();
+ });
+});
diff --git a/tests/unit/issue-context-panels.test.ts b/tests/unit/issue-context-panels.test.ts
new file mode 100644
index 00000000..a12a9c85
--- /dev/null
+++ b/tests/unit/issue-context-panels.test.ts
@@ -0,0 +1,164 @@
+import React from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const state = vi.hoisted(() => ({
+ attachments: { data: [] as unknown[], isLoading: false, isError: false, refetch: vi.fn() },
+ relations: {
+ data: {} as Record,
+ isLoading: false,
+ isError: false,
+ refetch: vi.fn(),
+ },
+ role: "OWNER",
+ mutate: vi.fn(),
+}));
+
+vi.mock("@/hooks/use-workspace", () => ({
+ useWorkspace: () => ({ key: "FRG", slug: "forge", role: state.role }),
+}));
+vi.mock("@/components/attachments/attachment-lightbox", () => ({
+ useAttachmentLightbox: () => ({ open: vi.fn() }),
+}));
+vi.mock("@/components/attachments/attachment-chip", () => ({ LinkFavicon: () => null }));
+vi.mock("@/components/ui/modal", () => ({ Confirm: () => null }));
+vi.mock("@/components/relations/issue-relations-graph", () => ({
+ IssueRelationsGraph: () => null,
+}));
+vi.mock("@/lib/trpc", () => ({
+ trpc: {
+ useUtils: () => ({}),
+ attachment: {
+ list: { useQuery: () => state.attachments },
+ getDownloadUrl: { useQuery: () => ({ data: { url: "/test-thumbnail.png" } }) },
+ initUpload: { useMutation: () => ({ mutate: state.mutate }) },
+ finalize: { useMutation: () => ({ mutate: state.mutate }) },
+ attachLink: { useMutation: () => ({ mutate: state.mutate }) },
+ delete: { useMutation: () => ({ mutate: state.mutate }) },
+ },
+ relation: {
+ listForIssue: { useQuery: () => state.relations },
+ remove: { useMutation: () => ({ mutate: state.mutate }) },
+ },
+ },
+}));
+
+(globalThis as typeof globalThis & { React: typeof React }).React = React;
+const { IssueAttachmentsPanel } = await import("@/components/attachments/issue-attachments-panel");
+const { IssueRelationsPanel } = await import("@/components/relations/issue-relations-panel");
+
+const renderAttachments = () =>
+ renderToStaticMarkup(React.createElement(IssueAttachmentsPanel, { issueId: "issue-1" }));
+const renderRelations = (props = {}) =>
+ renderToStaticMarkup(React.createElement(IssueRelationsPanel, { issueId: "issue-1", ...props }));
+const relation = (index: number, statusCategory = "TODO") => ({
+ relationId: `relation-${index}`,
+ target: { id: `issue-${index}`, number: index, title: `Related task ${index}`, statusCategory },
+});
+
+beforeEach(() => {
+ state.attachments.data = [];
+ state.attachments.isLoading = false;
+ state.attachments.isError = false;
+ state.relations.data = {};
+ state.relations.isLoading = false;
+ state.relations.isError = false;
+ state.role = "OWNER";
+ state.mutate.mockClear();
+});
+
+describe("inline issue attachments", () => {
+ it("keeps Add file discoverable in the compact empty state", () => {
+ const html = renderAttachments();
+ expect(html).toContain('id="issue-attachments"');
+ expect(html).toContain("Add file");
+ expect(html).toContain("No files attached");
+ expect(html).not.toContain("paste from clipboard");
+ expect(state.mutate).not.toHaveBeenCalled();
+ });
+
+ it("caps the visible file strip without hiding its total or expansion", () => {
+ state.attachments.data = Array.from({ length: 6 }, (_, index) => ({
+ id: `attachment-${index}`,
+ filename: `document-${index}.pdf`,
+ mimeType: "application/pdf",
+ size: 2048,
+ }));
+ const html = renderAttachments();
+ expect(html).toContain("document-3.pdf");
+ expect(html).not.toContain("document-4.pdf");
+ expect(html).toContain("View all attachments (6)");
+ expect(html).toContain('aria-controls="attachment-list-issue-1"');
+ expect(html).toContain('aria-expanded="false"');
+ expect(html).toContain("2.0 KB");
+ });
+
+ it("keeps image names visible and exposes preview/delete to keyboard users", () => {
+ state.attachments.data = [
+ { id: "image-1", filename: "Kitchen plan.png", mimeType: "image/png", size: 100 },
+ ];
+ const html = renderAttachments();
+ expect(html).toContain('aria-label="Preview attachment: Kitchen plan.png"');
+ expect(html).toContain('aria-label="Delete attachment: Kitchen plan.png"');
+ expect(html).toContain("Kitchen plan.png ");
+ expect(html).not.toContain("opacity-0");
+ state.role = "MEMBER";
+ expect(renderAttachments()).not.toContain('aria-label="Delete attachment:');
+ });
+
+ it("distinguishes load failure from no attachments and offers retry", () => {
+ state.attachments.isError = true;
+ const html = renderAttachments();
+ expect(html).toContain("Attachments could not be loaded");
+ expect(html).toContain("Try again");
+ expect(html).not.toContain("No files attached");
+ });
+});
+
+describe("visible related work", () => {
+ it("surfaces parent and subtasks without inventing a status or count", () => {
+ const html = renderRelations({
+ parentIssue: { id: "parent-1", number: 10, title: "Prepare home office" },
+ });
+ expect(html).toContain("Prepare home office");
+ expect(html).toContain("FRG-10");
+ expect(html).toContain('href="/w/forge/issues/parent-1"');
+ expect(html).toContain('href="#issue-subtasks"');
+ expect(html).toContain("View or add");
+ expect(html).not.toContain("0 · View or add");
+ });
+
+ it("prioritizes blockers with readable status and issue titles", () => {
+ state.relations.data = {
+ BLOCKS: [relation(2, "IN_PROGRESS")],
+ BLOCKED_BY: [relation(1, "BLOCKED")],
+ };
+ const html = renderRelations();
+ expect(html.indexOf("Related task 1")).toBeLessThan(html.indexOf("Related task 2"));
+ expect(html).toContain("In progress");
+ expect(html).toContain("Blocked");
+ expect(html).toContain('aria-label="Remove blocked by relation: FRG-1 Related task 1"');
+ expect(html).not.toContain('role="tablist"');
+ expect(state.mutate).not.toHaveBeenCalled();
+ });
+
+ it("caps each relation group and makes overflow discoverable", () => {
+ state.relations.data = { RELATES_TO: Array.from({ length: 5 }, (_, index) => relation(index)) };
+ const html = renderRelations();
+ expect(html).toContain("Related task 2");
+ expect(html).not.toContain("Related task 3");
+ expect(html).toContain("View all related to (5)");
+ expect(html).toContain('aria-controls="relations-issue-1-RELATES_TO"');
+ expect(html).toContain("Explore dependency map");
+ });
+
+ it("keeps parent navigation when relation loading fails", () => {
+ state.relations.isError = true;
+ const html = renderRelations({
+ parentIssue: { id: "parent-1", number: 10, title: "Parent title" },
+ });
+ expect(html).toContain("Parent title");
+ expect(html).toContain("Related work could not be loaded");
+ expect(html).not.toContain("No linked issues");
+ });
+});
diff --git a/tests/unit/issue-timeline.test.ts b/tests/unit/issue-timeline.test.ts
new file mode 100644
index 00000000..836070d7
--- /dev/null
+++ b/tests/unit/issue-timeline.test.ts
@@ -0,0 +1,167 @@
+import { describe, expect, it } from "vitest";
+import {
+ activityCommentId,
+ buildIssueTimeline,
+ type TimelineCommentSource,
+ type TimelineEventSource,
+} from "@/lib/issue-timeline";
+
+const at = (minute: number) => new Date(Date.UTC(2026, 9, 15, 12, minute));
+const comment = (
+ id: string,
+ minute: number,
+ kind: TimelineCommentSource["kind"] = "BODY",
+): TimelineCommentSource => ({ id, kind, createdAt: at(minute) });
+const event = (
+ id: string,
+ minute: number,
+ kind = "ISSUE_UPDATED",
+ payload?: unknown,
+): TimelineEventSource => ({ id, kind, createdAt: at(minute), payload, actor: { id: "bailey" } });
+
+describe("issue timeline", () => {
+ it("defaults discussion to full comments and significant outcomes", () => {
+ const result = buildIssueTimeline(
+ [comment("reply", 2), comment("narration", 4, "SYSTEM")],
+ [event("title", 1), event("done", 3, "AGENT_RUN_COMPLETED")],
+ "discussion",
+ );
+ expect(result.map((entry) => entry.id)).toEqual(["comment-reply", "activity-done"]);
+ });
+
+ it("keeps final agent status output in discussion", () => {
+ const result = buildIssueTimeline(
+ [{ ...comment("status", 1, "STATUS"), run: { status: "COMPLETED" } }],
+ [],
+ "discussion",
+ );
+ expect(result.map((entry) => entry.id)).toEqual(["comment-status"]);
+ });
+
+ it("keeps live status in workstream unless specifically deep-linked", () => {
+ const live = { ...comment("status", 1, "STATUS"), run: { status: "WAITING" } };
+ expect(buildIssueTimeline([live], [], "all")).toEqual([]);
+ expect(buildIssueTimeline([live], [], "all", "status")).toHaveLength(1);
+ });
+
+ it("updates contains system/status output but not body conversation", () => {
+ const result = buildIssueTimeline(
+ [comment("body", 0), comment("status", 1, "STATUS"), comment("system", 2, "SYSTEM")],
+ [event("created", 3, "ISSUE_CREATED")],
+ "updates",
+ );
+ expect(result.map((entry) => entry.id)).toEqual([
+ "comment-status",
+ "comment-system",
+ "activity-created",
+ ]);
+ });
+
+ it("merges audit and discussion chronologically without mutating inputs", () => {
+ const comments = [comment("later", 5), comment("earlier", 1)];
+ const events = [
+ event("outcome", 4, "AGENT_RUN_COMPLETED"),
+ event("status", 2, "ISSUE_STATUS_CHANGED"),
+ ];
+ expect(buildIssueTimeline(comments, events, "all").map((entry) => entry.id)).toEqual([
+ "comment-earlier",
+ "activity-status",
+ "activity-outcome",
+ "comment-later",
+ ]);
+ expect(comments[0].id).toBe("later");
+ expect(events[0].id).toBe("outcome");
+ });
+
+ it("deduplicates only activity referring to loaded comment IDs", () => {
+ const events = [
+ event("create", 1, "COMMENT_CREATED", { commentId: "body" }),
+ event("edit", 2, "COMMENT_UPDATED", { commentId: "body" }),
+ event("older", 0, "COMMENT_CREATED", { commentId: "unloaded" }),
+ ];
+ expect(
+ buildIssueTimeline([comment("body", 1)], events, "all").map((entry) => entry.id),
+ ).toEqual(["activity-older", "comment-body"]);
+ });
+
+ it("does not substitute a duplicate comment summary when a filter hides its card", () => {
+ expect(
+ buildIssueTimeline(
+ [comment("body", 1)],
+ [event("create", 1, "COMMENT_CREATED", { commentId: "body" })],
+ "updates",
+ ),
+ ).toEqual([]);
+ });
+
+ it("does not guess deduplication from preview text or timestamp", () => {
+ expect(
+ buildIssueTimeline(
+ [comment("body", 1)],
+ [event("create", 1, "COMMENT_CREATED", { preview: "same text" })],
+ "all",
+ ),
+ ).toHaveLength(2);
+ expect(
+ activityCommentId(event("source", 1, "COMMENT_CREATED", { sourceCommentId: "body" })),
+ ).toBe("body");
+ });
+
+ it("groups neighboring routine property changes while retaining each record", () => {
+ const result = buildIssueTimeline(
+ [],
+ [event("title", 1), event("priority", 2, "ISSUE_PRIORITY_CHANGED")],
+ "all",
+ );
+ expect(result).toHaveLength(1);
+ expect(result[0].type === "changes" && result[0].events.map((row) => row.id)).toEqual([
+ "title",
+ "priority",
+ ]);
+ });
+
+ it("does not group changes across an intervening conversation or outcome", () => {
+ expect(
+ buildIssueTimeline([comment("reply", 2)], [event("first", 1), event("last", 3)], "all").map(
+ (entry) => entry.type,
+ ),
+ ).toEqual(["changes", "comment", "changes"]);
+ expect(
+ buildIssueTimeline(
+ [],
+ [event("first", 1), event("outcome", 2, "ISSUE_STATUS_CHANGED"), event("last", 3)],
+ "all",
+ ),
+ ).toHaveLength(3);
+ });
+
+ it("does not group different actors, unknown system actors, or distant changes", () => {
+ expect(
+ buildIssueTimeline([], [event("a", 1), { ...event("b", 2), actor: { id: "other" } }], "all"),
+ ).toHaveLength(2);
+ expect(
+ buildIssueTimeline(
+ [],
+ [
+ { ...event("a", 1), actor: null },
+ { ...event("b", 2), actor: null },
+ ],
+ "all",
+ ),
+ ).toHaveLength(2);
+ expect(buildIssueTimeline([], [event("a", 1), event("b", 32)], "all")).toHaveLength(2);
+ });
+
+ it("identifies agent actors independently from their API-key owner", () => {
+ expect(
+ buildIssueTimeline(
+ [],
+ [
+ { ...event("a", 1), actorAgent: { id: "codex" } },
+ { ...event("b", 2), actorAgent: { id: "victor" } },
+ ],
+ "all",
+ ),
+ ).toHaveLength(2);
+ });
+});
diff --git a/tests/unit/personal-home.test.ts b/tests/unit/personal-home.test.ts
new file mode 100644
index 00000000..7b78e500
--- /dev/null
+++ b/tests/unit/personal-home.test.ts
@@ -0,0 +1,80 @@
+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 }));
+vi.mock("@/components/reminders/reminder-inbox", () => ({
+ ReminderInbox: () => React.createElement("section", { "aria-label": "Your reminders" }),
+}));
+(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('aria-label="Your reminders"');
+ 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/production-compose-network.test.ts b/tests/unit/production-compose-network.test.ts
index e36c86dc..173dce67 100644
--- a/tests/unit/production-compose-network.test.ts
+++ b/tests/unit/production-compose-network.test.ts
@@ -5,7 +5,7 @@ import { describe, expect, it } from "vitest";
const compose = readFileSync(
resolve(process.cwd(), "docker/docker-compose.production.example.yml"),
"utf8",
-);
+).replace(/\r\n/g, "\n");
function serviceBlock(name: string, nextName: string): string {
const start = compose.indexOf(` ${name}:`);
diff --git a/tests/unit/quick-create-modes.test.ts b/tests/unit/quick-create-modes.test.ts
index d4718c09..9ba7558b 100644
--- a/tests/unit/quick-create-modes.test.ts
+++ b/tests/unit/quick-create-modes.test.ts
@@ -1,6 +1,12 @@
import { describe, expect, it } from "vitest";
-import { resolveQuickCreateMode, SWITCHABLE_MODES } from "@/components/quick-create";
+import {
+ resolveQuickCreateMode,
+ resolveWorkItemKind,
+ SWITCHABLE_MODES,
+} from "@/components/quick-create";
+import { WorkItemKind } from "@prisma/client";
import { parseGitHubIssueOrPrRef } from "@/lib/github-ref";
+import { quickCreateRequestFromDataset } from "@/lib/workspace-surface-requests";
describe("CaptureSheet (QuickCreate) switchable modes", () => {
it("exposes the seven agentic-OS destinations", () => {
@@ -23,6 +29,21 @@ describe("CaptureSheet (QuickCreate) switchable modes", () => {
});
describe("CaptureSheet route and caller intent", () => {
+ it("preserves named planning actions on detail pages before and after hydration", () => {
+ const request = quickCreateRequestFromDataset({ quickCreateMode: "project" });
+ expect(resolveQuickCreateMode("/w/forge/projects/cproject123", request)).toEqual({
+ kind: "project",
+ });
+ expect(resolveQuickCreateMode("/w/forge/issues/cissue123", { mode: "cycle" })).toEqual({
+ kind: "cycle",
+ });
+ expect(
+ quickCreateRequestFromDataset({
+ quickCreateMode: "invalid",
+ quickCreateProject: "project-id",
+ }),
+ ).toEqual({ projectId: "project-id" });
+ });
it("defaults issue-detail capture to a comment", () => {
expect(resolveQuickCreateMode("/w/forge/issues/cissue123")).toEqual({
kind: "issue-context",
@@ -38,6 +59,19 @@ describe("CaptureSheet route and caller intent", () => {
}),
).toEqual({ kind: "issue" });
});
+
+ it("lets an explicit create-task action bypass issue-detail comment context", () => {
+ expect(resolveQuickCreateMode("/w/forge/issues/cissue123", { mode: "task" })).toEqual({
+ kind: "issue",
+ });
+ });
+
+ it("creates tasks in personal workspaces without changing explicit issue actions", () => {
+ expect(resolveWorkItemKind("PERSONAL")).toBe(WorkItemKind.TASK);
+ expect(resolveWorkItemKind("TEAM")).toBe(WorkItemKind.ISSUE);
+ expect(resolveWorkItemKind("PERSONAL", { mode: "issue" })).toBe(WorkItemKind.ISSUE);
+ expect(resolveWorkItemKind("TEAM", { mode: "task" })).toBe(WorkItemKind.TASK);
+ });
});
describe("QuickCreate GitHub import refs", () => {
diff --git a/tests/unit/realtime-targets.test.ts b/tests/unit/realtime-targets.test.ts
index 550a235e..54310d9d 100644
--- a/tests/unit/realtime-targets.test.ts
+++ b/tests/unit/realtime-targets.test.ts
@@ -1,7 +1,23 @@
import { describe, expect, it } from "vitest";
-import { realtimeTargets } from "@/lib/realtime-targets";
+import { realtimeTargets, relationRealtimeTargets } from "@/lib/realtime-targets";
describe("realtime cache targets", () => {
+ it("refreshes both ends of a relation without treating ordinary metadata as a relation", () => {
+ expect(
+ relationRealtimeTargets({
+ subjectType: "issue",
+ subjectId: "issue-1",
+ payload: { relationId: "relation-1", toIssueId: "issue-2" },
+ }),
+ ).toEqual(["issue-1", "issue-2"]);
+ expect(
+ relationRealtimeTargets({
+ subjectType: "issue",
+ subjectId: "issue-1",
+ payload: { title: "Changed" },
+ }),
+ ).toEqual([]);
+ });
it("targets the viewed issue and both sides of a sprint move", () => {
expect(
realtimeTargets({
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,
+ );
+ });
+});