diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.test.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.test.tsx index 05ccf29b2ae70..77b75014594b6 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.test.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.test.tsx @@ -67,7 +67,10 @@ vi.mock("../../state/threads", () => ({ olderSettledThreads: {}, threadFamilies: vi.mock("../../state/pull-requests", () => ({ composerPullRequests: {} })); vi.mock("../../state/olderSettledShelf", () => ({ useOlderSettledCount: () => 0 })); vi.mock("../../state/project-grouping", () => ({ - useMobileProjectGroupingSettings: () => ({ sidebarProjectGroupingMode: "none" }), + useMobileProjectGroupingSettings: () => ({ + sidebarProjectGroupingMode: "repository", + sidebarProjectGroupingOverrides: {}, + }), })); vi.mock("../../components/ui/Dialog", () => ({ alert: vi.fn() })); vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => mocks.openScratch })); @@ -134,7 +137,12 @@ function setup() { vi.clearAllMocks(); const registry = AtomRegistry.make(); mocks.registry.mockReturnValue(registry); - const projects = [project("one", "a"), project("one", "b"), project("two", "b")]; + const groupingIdentity = "39c61a1b-8a64-43a6-9da5-65c15da93d52"; + const projects = [ + project("one", "a"), + { ...project("one", "b"), groupingIdentity }, + { ...project("two", "copy"), groupingIdentity }, + ]; mocks.projects.mockReturnValue(projects); mocks.presentation.mockReturnValue({ isReady: true, @@ -290,9 +298,9 @@ it("owns a status stream only while the draft is active, retaining draft choices expect(screen.current.selectedProject).toEqual(screen.projects[2]); expect(screen.snapshot(draftKey).project).toEqual({ environmentId: "two", - projectId: "b", + projectId: "copy", }); - expect(screen.events).toEqual(["start:/a", "start:/b", "start:/b"]); + expect(screen.events).toEqual(["start:/a", "start:/b", "start:/copy"]); // A late old-project result cannot replace the current environment's checkout. await screen.emit("/a", "stale-branch"); expect(screen.current.currentCheckoutBranchName).toBe("main"); diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 5852206095a8f..8c9f2269dc575 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -15,7 +15,6 @@ import { CommandId, DEFAULT_PROVIDER_INTERACTION_MODE, MessageId, - repositoryGroupingKeyOf, ThreadId, } from "@t3tools/contracts"; import { threadContextsOutsideEnvironment } from "@t3tools/shared/composerContextReferences"; @@ -89,7 +88,11 @@ import { resolveNewTaskBranchWorktreePath, resolveNewTaskLocalWorkspaceSelection, } from "./new-task-context-presentation"; -import { isSameRepository, resolveEnvironmentProjectMatch } from "./new-task-project-selection"; +import { + isSameRepository, + resolveEnvironmentProjectMatch, + resolveNewTaskEnvironments, +} from "./new-task-project-selection"; import { useNewTaskProjectDefaults } from "./use-new-task-project-defaults"; import { resolveNewThreadRuntimeMode } from "./thread-settings-options"; import { resolveProjectThreadCreationBranch } from "./projectThreadCreationValidation"; @@ -585,17 +588,18 @@ export function NewTaskFlowProvider( const match = resolveEnvironmentProjectMatch( projects.filter((project) => project.environmentId === environmentId), selectedProject, + groupingSettings, + projects, ); + if (!match) return false; updateFlow({ - // A name or title match may be another repository, which must not receive the PR checkout. - draftKey: match - ? carryDraftContentTo(match, { - keepPullRequestWorkspace: isSameRepository(match, selectedProject), - }) - : readFlow().draftKey, + draftKey: carryDraftContentTo(match, { + keepPullRequestWorkspace: isSameRepository(match, selectedProject), + }), environmentId, - projectKey: match ? scopedProjectKey(match.environmentId, match.id) : null, + projectKey: scopedProjectKey(match.environmentId, match.id), }); + return true; }; const openScratch = useAtomCommand(projectEnvironment.openScratch, { reportFailure: false }); @@ -611,8 +615,7 @@ export function NewTaskFlowProvider( return true; } if (!isScratchDraft) { - selectEnvironment(environmentId); - return true; + return selectEnvironment(environmentId); } const request = {}; latestSwitchRef.current = request; @@ -806,12 +809,6 @@ export function NewTaskFlowProvider( ) !== null, ); - const selectedRepositoryKey = selectedProject?.repositoryIdentity - ? repositoryGroupingKeyOf(selectedProject.repositoryIdentity) - : null; - // Use || null: placeholder workspaceRoot can be empty, and an empty basename rejects real hosts. - const selectedWorkspaceBasename = selectedProject?.workspaceRoot.split("/").at(-1) || null; - const selectedProjectTitle = selectedProject?.title ?? null; const environments = (() => { if (isScratchDraft) { return scratchEnvironments.map((environment) => ({ @@ -819,44 +816,12 @@ export function NewTaskFlowProvider( environmentLabel: environment.environmentLabel, })); } - const seen = new Set(); - const result: Array<{ - readonly environmentId: EnvironmentId; - readonly environmentLabel: string; - }> = []; - const hostsSelectedRepository = (project: EnvironmentProject) => { - if (selectedRepositoryKey === null && selectedWorkspaceBasename === null) { - return true; - } - const projectKey = project.repositoryIdentity - ? repositoryGroupingKeyOf(project.repositoryIdentity) - : null; - if (selectedRepositoryKey !== null && projectKey !== null) { - return projectKey === selectedRepositoryKey; - } - return ( - project.workspaceRoot.split("/").at(-1) === selectedWorkspaceBasename || - (selectedProjectTitle !== null && project.title === selectedProjectTitle) - ); - }; - for (const project of projects) { - if (!hostsSelectedRepository(project)) { - continue; - } - if (seen.has(project.environmentId)) { - continue; - } - const environment = savedConnectionsById[project.environmentId]; - if (!environment) { - continue; - } - seen.add(project.environmentId); - result.push({ - environmentId: project.environmentId, - environmentLabel: environment.environmentLabel, - }); - } - return result; + return resolveNewTaskEnvironments({ + projects, + selectedProject, + settings: groupingSettings, + savedConnectionsById, + }); })(); const value = { diff --git a/apps/mobile/src/features/threads/new-task-project-selection.test.ts b/apps/mobile/src/features/threads/new-task-project-selection.test.ts index 82533a3c91e74..0e4b1b5d52a58 100644 --- a/apps/mobile/src/features/threads/new-task-project-selection.test.ts +++ b/apps/mobile/src/features/threads/new-task-project-selection.test.ts @@ -11,6 +11,7 @@ import { resolveDraftProjectSelection, resolveEnvironmentProjectMatch, resolveNewTaskScratchOption, + resolveNewTaskEnvironments, } from "./new-task-project-selection"; function makeProject( @@ -121,36 +122,112 @@ describe("resolveEnvironmentProjectMatch", () => { expect(isSameRepository(target[1]!, selected)).toBe(true); }); - it("falls back to workspace basename, then title, for unindexed projects", () => { - const selected = makeProject("t3code", "mac", { workspaceRoot: "/Users/me/t3code" }); - const byBasename = [ - makeProject("other", "server"), - makeProject("srv", "server", { workspaceRoot: "/home/me/t3code" }), - ]; - expect(resolveEnvironmentProjectMatch(byBasename, selected)).toBe(byBasename[1]); - expect(isSameRepository(byBasename[1]!, selected)).toBe(false); - - const byTitle = [ - makeProject("other", "server"), - makeProject("srv", "server", { title: "t3code" }), - ]; - expect(resolveEnvironmentProjectMatch(byTitle, selected)).toBe(byTitle[1]); + it("requires explicit identity for non-Git copies and respects separate mode", () => { + const groupingIdentity = "39c61a1b-8a64-43a6-9da5-65c15da93d52"; + const selected = { ...makeProject("sdk", "mac"), groupingIdentity }; + const unrelated = makeProject("sdk", "server"); + const target = { ...makeProject("copy", "server"), groupingIdentity }; + expect(resolveEnvironmentProjectMatch([unrelated], selected)).toBeNull(); + expect(resolveEnvironmentProjectMatch([unrelated, target], selected)).toBe(target); + expect(isSameRepository(target, selected)).toBe(false); + expect( + resolveEnvironmentProjectMatch([target], selected, { + sidebarProjectGroupingMode: "separate", + sidebarProjectGroupingOverrides: {}, + }), + ).toBeNull(); + expect( + resolveEnvironmentProjectMatch([unrelated], { ...selected, groupingIdentity: null }), + ).toBeNull(); }); + it("does not switch to unrelated repositories or first-project fallbacks", () => { + const selected = makeProject("sdk", "mac", { repositoryKey: "github.com/g/sdk" }); + const fork = makeProject("sdk", "server", { repositoryKey: "github.com/other/sdk" }); + expect(resolveEnvironmentProjectMatch([fork], selected)).toBeNull(); + expect(isSameRepository(fork, selected)).toBe(false); + expect(resolveEnvironmentProjectMatch([], selected)).toBeNull(); + }); +}); - it("does not treat a known different repository as a basename or title match", () => { - const selected = makeProject("t3code", "mac", { - repositoryKey: "github.com/t3tools/t3code", - workspaceRoot: "/Users/me/t3code", - }); - const fork = makeProject("fork", "server", { - repositoryKey: "github.com/someone/t3code", - title: "t3code", - workspaceRoot: "/home/me/t3code", +describe("collection-aware environment routing", () => { + const settings = { + sidebarProjectGroupingMode: "repository" as const, + sidebarProjectGroupingOverrides: {}, + }; + + it("routes duplicate physical records using the same inherited Git identity as project grouping", () => { + const older = makeProject("older", "mac", { + workspaceRoot: "/sdk", + repositoryKey: "github.com/g/sdk", }); - const unindexed = makeProject("unindexed", "server", { workspaceRoot: "/srv/t3code" }); - expect(resolveEnvironmentProjectMatch([fork, unindexed], selected)).toBe(unindexed); - expect(resolveEnvironmentProjectMatch([fork], selected)).toBe(fork); - expect(isSameRepository(fork, selected)).toBe(false); + const selectedProject = { + ...makeProject("newer", "mac", { workspaceRoot: "/sdk" }), + groupingIdentity: "39c61a1b-8a64-43a6-9da5-65c15da93d52", + updatedAt: "2026-07-02T00:00:00.000Z", + }; + const remote = makeProject("copy", "server", { repositoryKey: "github.com/g/sdk" }); + const projects = [selectedProject, older, remote]; + expect(resolveEnvironmentProjectMatch([remote], selectedProject, settings, projects)).toBe( + remote, + ); + expect( + resolveNewTaskEnvironments({ + projects, + selectedProject, + settings, + savedConnectionsById: { + mac: { environmentLabel: "Mac" }, + server: { environmentLabel: "Server" }, + }, + }).map((environment) => environment.environmentId), + ).toEqual(["mac", "server"]); + }); +}); + +describe("resolveNewTaskEnvironments", () => { + const settings = { + sidebarProjectGroupingMode: "repository" as const, + sidebarProjectGroupingOverrides: {}, + }; + const savedConnectionsById = { + mac: { environmentLabel: "Mac" }, + server: { environmentLabel: "Server" }, + }; + + it("lists only registered hosts of the explicit group", () => { + const groupingIdentity = "39c61a1b-8a64-43a6-9da5-65c15da93d52"; + const selectedProject = { ...makeProject("sdk", "mac"), groupingIdentity }; + const input = { + projects: [ + selectedProject, + { ...makeProject("copy", "server"), groupingIdentity }, + { ...makeProject("copy", "unsaved"), groupingIdentity }, + ], + selectedProject, + settings, + savedConnectionsById, + }; + expect(resolveNewTaskEnvironments(input)).toEqual([ + { environmentId: "mac", environmentLabel: "Mac" }, + { environmentId: "server", environmentLabel: "Server" }, + ]); + expect( + resolveNewTaskEnvironments({ + ...input, + settings: { ...settings, sidebarProjectGroupingMode: "separate" }, + }), + ).toEqual([{ environmentId: "mac", environmentLabel: "Mac" }]); + }); + + it("does not offer unrelated machines for an ordinary draft with a missing project", () => { + expect( + resolveNewTaskEnvironments({ + projects: [makeProject("other", "server")], + selectedProject: makeProject("missing", "mac"), + settings, + savedConnectionsById, + }), + ).toEqual([]); }); }); diff --git a/apps/mobile/src/features/threads/new-task-project-selection.ts b/apps/mobile/src/features/threads/new-task-project-selection.ts index 2fa21eba1e234..6fa13becf124a 100644 --- a/apps/mobile/src/features/threads/new-task-project-selection.ts +++ b/apps/mobile/src/features/threads/new-task-project-selection.ts @@ -1,5 +1,10 @@ import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; import { availableScratchWorkspaceRoot } from "@t3tools/client-runtime/operations/projects"; +import { + derivePhysicalProjectKey, + resolveProjectGroup, + type ProjectGroupingSettings, +} from "@t3tools/client-runtime/state/project-grouping"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { repositoryGroupingKeyOf, type EnvironmentId, type ServerConfig } from "@t3tools/contracts"; @@ -78,42 +83,18 @@ export function resolveNewTaskScratchOption(input: { export function resolveEnvironmentProjectMatch( projectsOnTarget: ReadonlyArray, selectedProject: EnvironmentProject | null, + settings: ProjectGroupingSettings = { + sidebarProjectGroupingMode: "repository", + sidebarProjectGroupingOverrides: {}, + }, + allProjects: ReadonlyArray = projectsOnTarget, ): EnvironmentProject | null { - const repositoryKey = selectedProject?.repositoryIdentity - ? repositoryGroupingKeyOf(selectedProject.repositoryIdentity) - : null; - // Use || null: placeholder workspaceRoot can be empty, which supplies no useful basename. - const workspaceBasename = selectedProject?.workspaceRoot.split("/").at(-1) || null; - // Use weaker signals only when identity is unknown; different known repositories never match by basename or title. - const isKnownMismatch = (project: EnvironmentProject) => { - const projectKey = project.repositoryIdentity - ? repositoryGroupingKeyOf(project.repositoryIdentity) - : null; - return repositoryKey !== null && projectKey !== null && projectKey !== repositoryKey; - }; + if (selectedProject === null) return projectsOnTarget[0] ?? null; + const targetKeys = new Set(projectsOnTarget.map(derivePhysicalProjectKey)); return ( - (repositoryKey !== null - ? projectsOnTarget.find( - (project) => - (project.repositoryIdentity - ? repositoryGroupingKeyOf(project.repositoryIdentity) - : null) === repositoryKey, - ) - : undefined) ?? - (workspaceBasename !== null - ? projectsOnTarget.find( - (project) => - !isKnownMismatch(project) && - project.workspaceRoot.split("/").at(-1) === workspaceBasename, - ) - : undefined) ?? - (selectedProject !== null - ? projectsOnTarget.find( - (project) => !isKnownMismatch(project) && project.title === selectedProject.title, - ) - : undefined) ?? - projectsOnTarget[0] ?? - null + resolveProjectGroup(selectedProject, allProjects, settings)?.members.find((member) => + targetKeys.has(member.physicalProjectKey), + )?.project ?? null ); } @@ -130,6 +111,26 @@ export function isSameRepository( ); } +export function resolveNewTaskEnvironments(input: { + readonly projects: ReadonlyArray; + readonly selectedProject: EnvironmentProject | null; + readonly settings: ProjectGroupingSettings; + readonly savedConnectionsById: Readonly< + Record + >; +}): Array<{ readonly environmentId: EnvironmentId; readonly environmentLabel: string }> { + const matchingProjects = input.selectedProject + ? (resolveProjectGroup(input.selectedProject, input.projects, input.settings)?.members.map( + (member) => member.project, + ) ?? []) + : input.projects; + const candidates = matchingProjects.map((project) => project.environmentId); + return [...new Set(candidates)].flatMap((environmentId) => { + const environment = input.savedConnectionsById[environmentId]; + return environment ? [{ environmentId, environmentLabel: environment.environmentLabel }] : []; + }); +} + export function resolveDraftProjectSelection( selectedProjectKey: string | null, projects: ReadonlyArray, diff --git a/apps/mobile/src/reactCompilerCoverage.test.ts b/apps/mobile/src/reactCompilerCoverage.test.ts index 192b70edba47e..a5afefab6e59b 100644 --- a/apps/mobile/src/reactCompilerCoverage.test.ts +++ b/apps/mobile/src/reactCompilerCoverage.test.ts @@ -187,7 +187,7 @@ describe("React Compiler coverage", () => { it("caches the new-task model options and host environments", () => { const file = "src/features/threads/new-task-flow-provider.tsx"; expect(compilerCachedCalls(file, "buildModelOptions")).toEqual([true]); - expect(compilerCachedCalls(file, "repositoryGroupingKeyOf")).toEqual([true, true]); + expect(compilerCachedCalls(file, "resolveNewTaskEnvironments")).toEqual([true]); }); // An uncached registration would re-register the workspace inspector on every render. diff --git a/apps/server/src/cli/project.test.ts b/apps/server/src/cli/project.test.ts index bca02daaf2916..428c9adecffa2 100644 --- a/apps/server/src/cli/project.test.ts +++ b/apps/server/src/cli/project.test.ts @@ -27,6 +27,7 @@ import * as Stream from "effect/Stream"; import { Command } from "effect/cli"; import { FetchHttpClient } from "effect/http"; import * as TestClock from "effect/testing/TestClock"; +import * as TestConsole from "effect/testing/TestConsole"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import { cli } from "../binCli.ts"; @@ -505,3 +506,67 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))( ); }, ); + +it.effect("formats grouping and project list output", () => + Effect.gen(function* () { + const { baseDir, project } = yield* makeProjectLookupFixture(); + const identity = "39c61a1b-8a64-43a6-9da5-65c15da93d52"; + yield* runCli([ + "project", + "grouping", + project.id, + "--identity", + identity, + "--base-dir", + baseDir, + ]); + const groupingOutput = yield* runCli([ + "project", + "grouping", + project.id, + "--base-dir", + baseDir, + ]).pipe(Effect.andThen(TestConsole.logLines), Effect.provide(Layer.fresh(TestConsole.layer))); + assert.deepEqual( + groupingOutput.map((line) => JSON.parse(String(line))), + [{ projectId: project.id, groupingIdentity: identity }], + ); + const listOutput = yield* runCli(["project", "list", "--base-dir", baseDir]).pipe( + Effect.andThen(TestConsole.logLines), + Effect.provide(Layer.fresh(TestConsole.layer)), + ); + assert.lengthOf(listOutput, 1); + const listed = JSON.parse(String(listOutput[0])); + assert.lengthOf(listed, 1); + assert.nestedPropertyVal(listed, "[0].id", project.id); + assert.nestedPropertyVal(listed, "[0].groupingIdentity", identity); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("rejects malformed grouping flags before opening or changing project state", () => + Effect.gen(function* () { + const { baseDir, workspaceRoot } = yield* makeProjectLookupFixture(); + const before = yield* readProjects(baseDir); + const fs = yield* FileSystem.FileSystem; + const unopenedBaseDir = `${baseDir}-unopened`; + const newRoot = `${workspaceRoot}-new`; + yield* fs.makeDirectory(newRoot); + for (const command of [ + ["add", newRoot, "--grouping-identity", "group/sdk"], + ["add", workspaceRoot, "--grouping-identity", "group/sdk"], + ["grouping", workspaceRoot, "--identity", "group/sdk"], + ]) { + const error = yield* runCli(["project", ...command, "--base-dir", baseDir]).pipe(Effect.flip); + assert.equal(error.message, "Grouping identity must be a lowercase UUID v4."); + assert.deepEqual(yield* readProjects(baseDir), before); + const unopenedError = yield* runCli([ + "project", + ...command, + "--base-dir", + unopenedBaseDir, + ]).pipe(Effect.flip); + assert.equal(unopenedError.message, "Grouping identity must be a lowercase UUID v4."); + assert.isFalse(yield* fs.exists(unopenedBaseDir)); + } + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/cli/project.ts b/apps/server/src/cli/project.ts index da63d65975ae5..3c88d95479780 100644 --- a/apps/server/src/cli/project.ts +++ b/apps/server/src/cli/project.ts @@ -3,11 +3,15 @@ import { CommandId, EnvironmentHttpApi, EnvironmentHttpCommonError, + Project, + ProjectGroupingIdentity, type ProjectMutation, type ProjectSnapshot, ProjectId, } from "@t3tools/contracts"; import * as Console from "effect/Console"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import * as SecureRandom from "effect/crypto/SecureRandom"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -55,6 +59,26 @@ type ProjectCliDispatchCommand = ProjectMutation; const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError); +class ProjectGroupingIdentityInvalidError extends Schema.TaggedError()( + "ProjectGroupingIdentityInvalidError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Grouping identity must be a lowercase UUID v4."; + } +} + +class ProjectGroupingFlagsError extends Schema.TaggedError()( + "ProjectGroupingFlagsError", + {}, +) { + override get message(): string { + return "Use either --identity or --clear."; + } +} + +const decodeGroupingIdentity = Schema.decodeUnknownEffect(ProjectGroupingIdentity); + export class ProjectCommandIdGenerationError extends Schema.TaggedError()( "ProjectCommandIdGenerationError", { @@ -145,19 +169,6 @@ export class ProjectNotFoundError extends Schema.TaggedError()( - "ProjectAlreadyExistsError", - { - operation: Schema.Literal("addProject"), - projectId: ProjectId, - workspaceRoot: Schema.String, - }, -) { - override get message(): string { - return `An active project already exists for '${this.workspaceRoot}'.`; - } -} - export const ProjectCommandError = Schema.Union([ ProjectCommandIdGenerationError, ProjectLiveServerDeclaredResponseError, @@ -166,7 +177,6 @@ export const ProjectCommandError = Schema.Union([ ProjectTitleEmptyError, ProjectIdentifierEmptyError, ProjectNotFoundError, - ProjectAlreadyExistsError, ]); export type ProjectCommandError = typeof ProjectCommandError.Type; @@ -338,10 +348,10 @@ const fetchLiveOrchestrationSnapshot = Effect.fn("project.fetchLiveOrchestration const dispatchLiveOrchestrationCommand = Effect.fn("project.dispatchLiveOrchestrationCommand")( function* (origin: string, bearerToken: string, command: ProjectCliDispatchCommand) { const client = yield* makeLiveServerClient(origin); - yield* client.projects.mutate({ + return yield* client.projects.mutate({ headers: { authorization: `Bearer ${bearerToken}` }, payload: command, - } as Parameters[0]); + }); }, (effect, _origin, _bearerToken, _command) => effect.pipe( @@ -396,7 +406,7 @@ const runProjectMutation = Effect.fn("runProjectMutation")(function* ( readonly snapshot: ProjectSnapshot; readonly dispatch: ( command: ProjectCliDispatchCommand, - ) => Effect.Effect; + ) => Effect.Effect; readonly mode: ProjectCommandExecutionMode; }) => Effect.Effect< string, @@ -450,7 +460,7 @@ const runProjectMutation = Effect.fn("runProjectMutation")(function* ( const projects = yield* ProjectService.ProjectService; const output = yield* run({ snapshot, - dispatch: (command) => projectMutationOperation(projects, command).pipe(Effect.asVoid), + dispatch: (command) => projectMutationOperation(projects, command), mode: "offline", }); yield* Console.log(output); @@ -472,45 +482,44 @@ const projectAddCommand = Command.make("add", { workspaceRoot: Argument.String("path").pipe( Argument.withDescription("Workspace root to add as a project."), ), + groupingIdentity: Flag.String("grouping-identity").pipe( + Flag.withDescription( + "Shared lowercase UUID v4 for copies of a non-Git project; updates an existing registration.", + ), + Flag.optional, + ), title: Flag.String("title").pipe(Flag.withDescription("Optional project title."), Flag.optional), }).pipe( Command.withDescription("Add a project."), Command.withHandler((flags) => - runProjectMutation( - flags, - Effect.fn("projectAddMutation")(function* ({ - snapshot, - dispatch, - }: { - readonly snapshot: ProjectSnapshot; - readonly dispatch: ( - command: ProjectCliDispatchCommand, - ) => Effect.Effect; - }) { - const workspaceRoot = yield* normalizeWorkspaceRootForProjectCommand(flags.workspaceRoot); - const existingProject = snapshot.projects.find( - (project) => project.deletedAt === null && project.workspaceRoot === workspaceRoot, - ); - if (existingProject) { - return yield* new ProjectAlreadyExistsError({ - operation: "addProject", - projectId: existingProject.id, + Effect.gen(function* () { + const groupingIdentity = Option.isSome(flags.groupingIdentity) + ? yield* decodeGroupingIdentity(flags.groupingIdentity.value).pipe( + Effect.mapError((cause) => new ProjectGroupingIdentityInvalidError({ cause })), + ) + : undefined; + return yield* runProjectMutation( + flags, + Effect.fn("projectAddMutation")(function* ({ dispatch }) { + const path = yield* Path.Path; + const workspaceRoot = path.resolve( + expandHomePath(flags.workspaceRoot.trim(), yield* HostProcess.HomeDirectory), + ); + const projectId = ProjectId.make(yield* projectCommandUuid); + const project = yield* dispatch({ + type: "project.register", + commandId: CommandId.make(yield* projectCommandUuid), + projectId, workspaceRoot, + ...Option.match(flags.title, { onNone: () => ({}), onSome: (title) => ({ title }) }), + ...(groupingIdentity === undefined ? {} : { groupingIdentity }), }); - } - - const title = yield* resolveProjectTitle(workspaceRoot, Option.getOrUndefined(flags.title)); - const projectId = ProjectId.make(yield* projectCommandUuid); - yield* dispatch({ - type: "project.create", - commandId: CommandId.make(yield* projectCommandUuid), - projectId, - title, - workspaceRoot, - }); - return `Added project ${projectId} (${title}) at ${workspaceRoot}.`; - }), - ), + return project.id === projectId + ? `Added project ${project.id} (${project.title}) at ${project.workspaceRoot}.` + : `Set grouping identity for project ${project.id}.`; + }), + ); + }), ), ); @@ -535,7 +544,11 @@ const projectRemoveCommand = Command.make("remove", { readonly snapshot: ProjectSnapshot; readonly dispatch: ( command: ProjectCliDispatchCommand, - ) => Effect.Effect; + ) => Effect.Effect< + Project, + Error, + FileSystem.FileSystem | HttpClient.HttpClient | Path.Path + >; }) { const project = yield* findActiveProjectTarget({ snapshot, @@ -571,7 +584,11 @@ const projectRenameCommand = Command.make("rename", { readonly snapshot: ProjectSnapshot; readonly dispatch: ( command: ProjectCliDispatchCommand, - ) => Effect.Effect; + ) => Effect.Effect< + Project, + Error, + FileSystem.FileSystem | HttpClient.HttpClient | Path.Path + >; }) { const project = yield* findActiveProjectTarget({ snapshot, @@ -594,7 +611,75 @@ const projectRenameCommand = Command.make("rename", { ), ); +const encodeProjectList = Schema.encodeEffect(Schema.fromJsonString(Schema.Array(Project))); +const encodeGroupingResult = Schema.encodeEffect( + Schema.fromJsonString( + Schema.Struct({ + projectId: ProjectId, + groupingIdentity: Schema.NullOr(ProjectGroupingIdentity), + }), + ), +); + +const projectListCommand = Command.make("list", { ...projectLocationFlags }).pipe( + Command.withDescription("List registered projects as JSON, including groupingIdentity."), + Command.withHandler((flags) => + runProjectMutation(flags, ({ snapshot }) => encodeProjectList(snapshot.projects)), + ), +); + +const projectGroupingCommand = Command.make("grouping", { + ...projectLocationFlags, + project: Argument.String("project").pipe( + Argument.withDescription("Project id or workspace root."), + ), + identity: Flag.String("identity").pipe( + Flag.withDescription("Shared lowercase UUID v4. Omit to show the current identity."), + Flag.optional, + ), + clear: Flag.Boolean("clear").pipe(Flag.withDefault(false)), +}).pipe( + Command.withDescription("Show, set, or clear a project's explicit grouping identity."), + Command.withHandler((flags) => + Effect.gen(function* () { + if (flags.clear && Option.isSome(flags.identity)) { + return yield* new ProjectGroupingFlagsError({}); + } + const groupingIdentity = flags.clear + ? null + : Option.isSome(flags.identity) + ? yield* decodeGroupingIdentity(flags.identity.value).pipe( + Effect.mapError((cause) => new ProjectGroupingIdentityInvalidError({ cause })), + ) + : undefined; + return yield* runProjectMutation( + flags, + Effect.fn("projectGroupingMutation")(function* ({ dispatch }) { + const path = yield* Path.Path; + const project = yield* dispatch({ + type: "project.grouping", + commandId: CommandId.make(yield* projectCommandUuid), + identifier: flags.project, + cwd: path.resolve("."), + ...(groupingIdentity === undefined ? {} : { groupingIdentity }), + }); + return yield* encodeGroupingResult({ + projectId: project.id, + groupingIdentity: project.groupingIdentity ?? null, + }); + }), + ); + }), + ), +); + export const projectCommand = Command.make("project").pipe( Command.withDescription("Manage projects."), - Command.withSubcommands([projectAddCommand, projectRemoveCommand, projectRenameCommand]), + Command.withSubcommands([ + projectAddCommand, + projectRemoveCommand, + projectRenameCommand, + projectListCommand, + projectGroupingCommand, + ]), ); diff --git a/apps/server/src/mcp/toolkits/project/handlers.ts b/apps/server/src/mcp/toolkits/project/handlers.ts index 0615d09ae9211..3076d76e3e6c2 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -355,6 +355,7 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { if (workspaceRoot === undefined) { // Only updates record model defaults; reject defaults that creation would silently drop. if ( + input.groupingIdentity !== undefined || input.scripts !== undefined || input.createWorkspaceRootIfMissing !== undefined || input.defaultModelSelection !== undefined @@ -362,7 +363,7 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { return yield* new OrchestratorMcpFailure({ code: "invalid_request", message: - "A project started from its title takes only a title; set scripts or defaultModelSelection afterwards with t3_project_update.", + "A project started from its title takes only a title; set groupingIdentity, scripts or defaultModelSelection afterwards with t3_project_update. Fenced linked callers must ask an unfenced full-access/default caller to make that update.", }); const folders = yield* ManagedProjectFolders.ManagedProjectFolders; const created = yield* folders.createNamedProject({ name: input.title }).pipe( diff --git a/apps/server/src/mcp/toolkits/project/tools.ts b/apps/server/src/mcp/toolkits/project/tools.ts index 90949ed1d2881..0acff76265704 100644 --- a/apps/server/src/mcp/toolkits/project/tools.ts +++ b/apps/server/src/mcp/toolkits/project/tools.ts @@ -67,7 +67,7 @@ const ProjectListTool = Tool.make("t3_project_list", { .annotate(Tool.Destructive, false); const ProjectReadTool = Tool.make("t3_project_read", { ...shared, - description: "Read one project: workspace and saved scripts.", + description: "Read one project.", parameters: Schema.Struct({ environmentId: OrchestratorMcpEnvironmentTarget, projectId: ProjectId, @@ -78,7 +78,7 @@ const ProjectReadTool = Tool.make("t3_project_read", { const ProjectCreateTool = Tool.make("t3_project_create", { ...shared, description: - "Register a project folder. `createWorkspaceRootIfMissing` creates it; a registered folder is rejected. No `workspaceRoot`: makes a git repo from `title` with README, icon, first commit (`commitError` on failure; project still exists). Clone first with `t3_project_clone` if needed.", + "Register workspaceRoot; createWorkspaceRootIfMissing if absent. Duplicates fail. No root: title makes Git, README.md, assets/icon.svg, tries Initial commit; commitError keeps project. Clone if needed.", parameters: Schema.Struct({ environmentId: OrchestratorMcpEnvironmentTarget, ...ProjectCreatePayload.fields, @@ -89,7 +89,8 @@ const ProjectCreateTool = Tool.make("t3_project_create", { }).annotate(Tool.Destructive, true); const ProjectUpdateTool = Tool.make("t3_project_update", { ...shared, - description: "Update one project's settings. Omitted fields are unchanged.", + description: + "Update settings; omitted fields stay. groupingIdentity: shared lowercase UUID v4 for non-Git copies; null clears. Git wins.", parameters: Schema.Struct({ environmentId: OrchestratorMcpEnvironmentTarget, projectId: ProjectId, @@ -109,7 +110,7 @@ const ProjectDeleteTool = Tool.make("t3_project_delete", { const ProjectCloneTool = Tool.make("t3_project_clone", { ...shared, description: - "Clone a repository (`destinationPath` new or empty). Register after with `t3_project_create`, with the same `environmentId`.", + "Clone into new/empty destinationPath; register via t3_project_create on the same environmentId.", parameters: Schema.Struct({ environmentId: OrchestratorMcpEnvironmentTarget, ...SourceControlCloneRepositoryInput.fields, diff --git a/apps/server/src/orchestration-v2/ProjectCommands.test.ts b/apps/server/src/orchestration-v2/ProjectCommands.test.ts index 85cf6d08efc18..b87b89af4931c 100644 --- a/apps/server/src/orchestration-v2/ProjectCommands.test.ts +++ b/apps/server/src/orchestration-v2/ProjectCommands.test.ts @@ -17,6 +17,7 @@ const row = (overrides: Partial = {}): ProjectRow => ({ projectId, title: "Scripts", workspaceRoot: "/tmp/scripts", + groupingIdentity: null, defaultModelSelection: null, defaultThreadEnvMode: null, hasCustomTitle: false, diff --git a/apps/server/src/orchestration-v2/ProjectCommands.ts b/apps/server/src/orchestration-v2/ProjectCommands.ts index d4c1abb3ceda2..9dc11a71d1816 100644 --- a/apps/server/src/orchestration-v2/ProjectCommands.ts +++ b/apps/server/src/orchestration-v2/ProjectCommands.ts @@ -5,6 +5,7 @@ import { type ModelSelection, type ProjectIconOverride, ProjectId, + ProjectGroupingIdentity, type ProjectScript, SCRIPT_RUN_COMMAND_PATTERN, type ThreadEnvMode, @@ -22,6 +23,7 @@ export interface ProjectCreateCommand { readonly projectId: ProjectId; readonly title: string; readonly workspaceRoot: string; + readonly groupingIdentity?: string | null; readonly scripts?: ReadonlyArray; } @@ -36,6 +38,7 @@ export interface ProjectMetaUpdateCommand { readonly autoPull?: boolean; readonly faviconPath?: string | null; readonly projectIcon?: ProjectIconOverride | null; + readonly groupingIdentity?: string | null; readonly scripts?: ReadonlyArray; readonly pinned?: boolean; readonly archived?: boolean; @@ -104,6 +107,7 @@ export interface ProjectCommandState { } const monogramSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" }); +const isProjectGroupingIdentity = Schema.is(ProjectGroupingIdentity); const isScriptRunCommand = Schema.is(SCRIPT_RUN_COMMAND_PATTERN); /** Read state and commit the planned event under the project lock. */ @@ -131,6 +135,13 @@ export function planProjectCommand(input: { workspaceRoot, conflictingProjectId: state.workspaceOwner.projectId, }); + if ( + command.type !== "project.delete" && + command.groupingIdentity != null && + !isProjectGroupingIdentity(command.groupingIdentity) + ) { + return invariant("Project grouping identity must be a lowercase UUID v4."); + } const occurredAt = DateTime.formatIso(input.now); const base = { eventId: input.eventId, @@ -159,6 +170,7 @@ export function planProjectCommand(input: { projectId: command.projectId, title: command.title, workspaceRoot: command.workspaceRoot, + groupingIdentity: command.groupingIdentity ?? null, defaultModelSelection: null, faviconPath: null, projectIcon: null, @@ -198,6 +210,9 @@ export function planProjectCommand(input: { type: "project.meta-updated", payload: { projectId: command.projectId, + ...(command.groupingIdentity === undefined + ? {} + : { groupingIdentity: command.groupingIdentity }), ...(command.title === undefined ? {} : { title: command.title }), ...(command.workspaceRoot === undefined ? {} : { workspaceRoot: command.workspaceRoot }), ...(command.defaultModelSelection === undefined diff --git a/apps/server/src/orchestration-v2/ProjectStore.ts b/apps/server/src/orchestration-v2/ProjectStore.ts index af39d565109f4..3052e0b79d3c5 100644 --- a/apps/server/src/orchestration-v2/ProjectStore.ts +++ b/apps/server/src/orchestration-v2/ProjectStore.ts @@ -34,6 +34,7 @@ export const ProjectRow = Schema.Struct({ title: Schema.String, hasCustomTitle: Schema.Boolean, workspaceRoot: Schema.String, + groupingIdentity: Schema.NullOr(Schema.String), defaultModelSelection: Schema.NullOr(ModelSelection), defaultThreadEnvMode: Schema.NullOr(ThreadEnvMode), autoPull: Schema.Boolean, @@ -63,6 +64,7 @@ function toShell(row: ProjectRow): OrchestrationProjectShell { title: row.title, hasCustomTitle: row.hasCustomTitle, workspaceRoot: row.workspaceRoot, + groupingIdentity: row.groupingIdentity, repositoryIdentity: null, defaultModelSelection: row.defaultModelSelection, defaultThreadEnvMode: row.defaultThreadEnvMode, @@ -122,6 +124,7 @@ export const make = Effect.gen(function* () { title, has_custom_title AS "hasCustomTitle", workspace_root AS "workspaceRoot", + grouping_identity AS "groupingIdentity", default_model_selection_json AS "defaultModelSelection", default_thread_env_mode AS "defaultThreadEnvMode", auto_pull AS "autoPull", @@ -155,6 +158,7 @@ export const make = Effect.gen(function* () { title, has_custom_title, workspace_root, + grouping_identity, default_model_selection_json, default_thread_env_mode, auto_pull, @@ -172,6 +176,7 @@ export const make = Effect.gen(function* () { ${encoded.title}, ${encoded.hasCustomTitle}, ${encoded.workspaceRoot}, + ${encoded.groupingIdentity}, ${encoded.defaultModelSelection}, ${encoded.defaultThreadEnvMode}, ${encoded.autoPull}, @@ -193,6 +198,7 @@ export const make = Effect.gen(function* () { END, has_custom_title = MAX(projection_projects.has_custom_title, excluded.has_custom_title), workspace_root = excluded.workspace_root, + grouping_identity = excluded.grouping_identity, default_model_selection_json = excluded.default_model_selection_json, default_thread_env_mode = excluded.default_thread_env_mode, auto_pull = excluded.auto_pull, @@ -244,6 +250,7 @@ export const make = Effect.gen(function* () { title: payload.title, hasCustomTitle: false, workspaceRoot: payload.workspaceRoot, + groupingIdentity: payload.groupingIdentity ?? null, defaultModelSelection: payload.defaultModelSelection, defaultThreadEnvMode: payload.defaultThreadEnvMode ?? null, autoPull: false, @@ -270,6 +277,9 @@ export const make = Effect.gen(function* () { const payload = event.payload; yield* upsertRow({ ...row, + ...(payload.groupingIdentity === undefined + ? {} + : { groupingIdentity: payload.groupingIdentity }), ...(payload.title === undefined ? {} : { title: payload.title, hasCustomTitle: true }), ...(payload.workspaceRoot === undefined ? {} : { workspaceRoot: payload.workspaceRoot }), ...(payload.defaultModelSelection === undefined diff --git a/apps/server/src/orchestration-v2/RuntimePolicy.test.ts b/apps/server/src/orchestration-v2/RuntimePolicy.test.ts index 6f9fbe53d911a..aaa097988ae70 100644 --- a/apps/server/src/orchestration-v2/RuntimePolicy.test.ts +++ b/apps/server/src/orchestration-v2/RuntimePolicy.test.ts @@ -97,6 +97,7 @@ const layerWithPolicy = (policy?: ManagedPolicySummary) => projectId, title: "Project", workspaceRoot: "/project-root", + groupingIdentity: null, defaultModelSelection: null, defaultThreadEnvMode: null, hasCustomTitle: false, diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index e163b5c1eb6bc..4ab8469a6d584 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -194,6 +194,8 @@ function makeHarness(options: HarnessOptions = {}) { }), Layer.succeed(ProjectService.ProjectService, { create: () => Effect.die("unused"), + register: () => Effect.die("unused"), + grouping: () => Effect.die("unused"), bootstrap: () => Effect.die("unused"), update: () => Effect.die("unused"), delete: () => Effect.die("unused"), @@ -269,6 +271,7 @@ function makeHarness(options: HarnessOptions = {}) { projectId, title: project.title, workspaceRoot: project.workspaceRoot, + groupingIdentity: null, defaultModelSelection: project.defaultModelSelection, defaultThreadEnvMode: null, hasCustomTitle: false, diff --git a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts index cd4e4df9ff1a3..8e9e44f5142b3 100644 --- a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts @@ -81,6 +81,7 @@ function makeHarness( projectId, title: "Project", workspaceRoot: "/repo", + groupingIdentity: null, defaultModelSelection: modelSelection, defaultThreadEnvMode: null, hasCustomTitle: false, diff --git a/apps/server/src/persistence/Migrations.test.ts b/apps/server/src/persistence/Migrations.test.ts index 64705e4bbb8f5..8efc44e7e0a26 100644 --- a/apps/server/src/persistence/Migrations.test.ts +++ b/apps/server/src/persistence/Migrations.test.ts @@ -29,6 +29,46 @@ const v2TableCount = Effect.gen(function* () { }); describe("runMigrations", () => { + it.effect("adds explicit grouping after the message-ordinal index", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 94 }); + const before = yield* history; + yield* sql` + INSERT INTO projection_projects + (project_id, title, has_custom_title, workspace_root, scripts_json, pinned_at, archived_at, created_at, updated_at) + VALUES ('sdk-project', 'SDK', 1, '/work/sdk', '[]', '2026-10-27', '2026-10-28', '2026-10-27', '2026-10-28') + `; + + assert.deepStrictEqual(yield* runMigrations(), [[95, "ProjectGroupingIdentity"]]); + assert.deepStrictEqual(yield* history, [...before, [95, "ProjectGroupingIdentity"]]); + assert.deepStrictEqual( + yield* sql`SELECT project_id, title, has_custom_title, workspace_root, pinned_at, archived_at, grouping_identity FROM projection_projects`, + [ + { + project_id: "sdk-project", + title: "SDK", + has_custom_title: 1, + workspace_root: "/work/sdk", + pinned_at: "2026-10-27", + archived_at: "2026-10-28", + grouping_identity: null, + }, + ], + ); + yield* sql` + UPDATE projection_projects + SET grouping_identity = '39c61a1b-8a64-43a6-9da5-65c15da93d52' + WHERE project_id = 'sdk-project' + `; + assert.deepStrictEqual( + yield* sql`SELECT grouping_identity FROM projection_projects WHERE project_id = 'sdk-project'`, + [{ grouping_identity: "39c61a1b-8a64-43a6-9da5-65c15da93d52" }], + ); + assert.deepStrictEqual(yield* runMigrations(), []); + }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), + ); + it.effect("refuses to start when another migration took a V2 id", () => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 0e6b502285113..6c780df03f979 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -102,6 +102,7 @@ import Migration0091 from "./Migrations/091_AuthConnectDeniedRequests.ts"; import Migration0092 from "./Migrations/092_QueuedTurnInputIndexJsonValid.ts"; import Migration0093 from "./Migrations/093_ProviderTurnAttemptIndex.ts"; import Migration0094 from "./Migrations/094_TurnItemMessageOrdinalIndex.ts"; +import Migration0095 from "./Migrations/095_ProjectGroupingIdentity.ts"; const migrationEntries = [ [1, "OrchestrationEvents", Migration0001], @@ -199,6 +200,7 @@ const migrationEntries = [ [92, "QueuedTurnInputIndexJsonValid", Migration0092], [93, "ProviderTurnAttemptIndex", Migration0093], [94, "TurnItemMessageOrdinalIndex", Migration0094], + [95, "ProjectGroupingIdentity", Migration0095], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/095_ProjectGroupingIdentity.ts b/apps/server/src/persistence/Migrations/095_ProjectGroupingIdentity.ts new file mode 100644 index 0000000000000..c32e41e32d0e8 --- /dev/null +++ b/apps/server/src/persistence/Migrations/095_ProjectGroupingIdentity.ts @@ -0,0 +1,7 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`ALTER TABLE projection_projects ADD COLUMN grouping_identity TEXT`; +}); diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts index 953a2229ce26e..fa8b561811954 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -74,6 +74,7 @@ describe("V2 preview upgrade", () => { [92, "QueuedTurnInputIndexJsonValid"], [93, "ProviderTurnAttemptIndex"], [94, "TurnItemMessageOrdinalIndex"], + [95, "ProjectGroupingIdentity"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -191,6 +192,7 @@ describe("V2 preview upgrade", () => { [92, "QueuedTurnInputIndexJsonValid"], [93, "ProviderTurnAttemptIndex"], [94, "TurnItemMessageOrdinalIndex"], + [95, "ProjectGroupingIdentity"], ]); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); diff --git a/apps/server/src/project/ProjectMutation.test.ts b/apps/server/src/project/ProjectMutation.test.ts index 9e4e075f06ea0..05cb258b18623 100644 --- a/apps/server/src/project/ProjectMutation.test.ts +++ b/apps/server/src/project/ProjectMutation.test.ts @@ -31,6 +31,8 @@ describe("mutateProjectWithClone", () => { const result = yield* mutateProjectWithClone( { create: () => Effect.die("unused"), + register: () => Effect.die("unused"), + grouping: () => Effect.die("unused"), update: () => Effect.die("unused"), delete: () => Effect.fail(rejected), }, @@ -49,6 +51,8 @@ describe("mutateProjectWithClone", () => { const discarded: Array = []; const projects = { create: () => Effect.die("unused"), + register: () => Effect.die("unused"), + grouping: () => Effect.die("unused"), update: () => Effect.die("unused"), // The delete event is already durable while its post-commit cleanup runs. delete: () => diff --git a/apps/server/src/project/ProjectMutation.ts b/apps/server/src/project/ProjectMutation.ts index 550738b24baae..cafb3aaf42e56 100644 --- a/apps/server/src/project/ProjectMutation.ts +++ b/apps/server/src/project/ProjectMutation.ts @@ -4,13 +4,20 @@ import * as Effect from "effect/Effect"; import { type ProjectCloneTracker } from "./ProjectCloneTracker.ts"; import { type ProjectService } from "./ProjectService.ts"; -type ProjectMutations = Pick; +type ProjectMutations = Pick< + ProjectService["Service"], + "create" | "delete" | "update" | "register" | "grouping" +>; export const projectMutationOperation = Effect.fn("projectMutationOperation")(function* ( projects: ProjectMutations, mutation: ProjectMutation, ) { switch (mutation.type) { + case "project.register": + return yield* projects.register(mutation); + case "project.grouping": + return yield* projects.grouping(mutation); case "project.create": return yield* projects.create({ commandId: mutation.commandId, @@ -23,6 +30,9 @@ export const projectMutationOperation = Effect.fn("projectMutationOperation")(fu ...(mutation.defaultModelSelection === undefined ? {} : { defaultModelSelection: mutation.defaultModelSelection }), + ...(mutation.groupingIdentity === undefined + ? {} + : { groupingIdentity: mutation.groupingIdentity }), ...(mutation.scripts === undefined ? {} : { scripts: mutation.scripts }), }); @@ -41,6 +51,9 @@ export const projectMutationOperation = Effect.fn("projectMutationOperation")(fu ...(mutation.defaultThreadEnvMode === undefined ? {} : { defaultThreadEnvMode: mutation.defaultThreadEnvMode }), + ...(mutation.groupingIdentity === undefined + ? {} + : { groupingIdentity: mutation.groupingIdentity }), ...(mutation.scripts === undefined ? {} : { scripts: mutation.scripts }), ...(mutation.pinned === undefined ? {} : { pinned: mutation.pinned }), ...(mutation.archived === undefined ? {} : { archived: mutation.archived }), diff --git a/apps/server/src/project/ProjectService.test.ts b/apps/server/src/project/ProjectService.test.ts index 492379beecc26..30998e2557113 100644 --- a/apps/server/src/project/ProjectService.test.ts +++ b/apps/server/src/project/ProjectService.test.ts @@ -942,3 +942,112 @@ it.effect("rejects an update that waited on the lock while its project was delet ); }).pipe(Effect.provide(layerProjectServiceDependencies)), ); + +it.layer( + layerTestFor( + Layer.merge( + Layer.succeed(RepositoryIdentityResolver.RepositoryIdentityResolver, { + resolve: () => Effect.succeed(null), + }), + Layer.succeed(ProjectFaviconResolver.ProjectFaviconResolver, { + resolvePath: () => Effect.succeed(null), + invalidate: () => Effect.void, + }), + ), + ), +)("non-Git grouping metadata", (it) => { + it.effect( + "persists, changes, and clears explicit grouping without changing physical identity", + () => + Effect.gen(function* () { + const service = yield* ProjectService.ProjectService; + const projectId = ProjectId.make("explicit-grouping"); + const identity = "39c61a1b-8a64-43a6-9da5-65c15da93d52"; + const created = yield* service.register({ + commandId: CommandId.make("group-create"), + projectId, + title: "sdk", + workspaceRoot: "/groups/sdk", + groupingIdentity: identity, + }); + assert.equal(created.groupingIdentity, identity); + const sql = yield* SqlClient.SqlClient; + yield* sql` + INSERT INTO orchestration_v2_projection_threads + (thread_id, project_id, title, default_provider, runtime_mode, interaction_mode, created_at, updated_at, payload_json) + VALUES ('grouping-thread', ${projectId}, 'Existing thread', 'codex', 'full-access', 'default', '2026-10-27', '2026-10-27', '{}') + `; + const threadsBefore = yield* sql`SELECT * FROM orchestration_v2_projection_threads`; + + yield* service.update({ + commandId: CommandId.make("group-rename"), + projectId, + title: "Renamed", + }); + assert.equal( + Option.getOrThrow(yield* service.getById(projectId)).groupingIdentity, + identity, + ); + assert.equal((yield* service.listShells())[0]?.groupingIdentity, identity); + yield* service.grouping({ + commandId: CommandId.make("group-before-assignment"), + identifier: created.id, + cwd: "/", + groupingIdentity: null, + }); + for (const suffix of ["assign", "repeat"]) { + const registered = yield* service.register({ + commandId: CommandId.make(`group-${suffix}`), + projectId: ProjectId.make(`unused-${suffix}`), + title: "Ignored for existing", + workspaceRoot: "/groups/sdk", + groupingIdentity: identity, + }); + assert.equal(registered.id, created.id); + assert.equal(registered.title, "Renamed"); + assert.equal(registered.createdAt, created.createdAt); + } + const duplicate = yield* service + .register({ + commandId: CommandId.make("group-duplicate"), + projectId: ProjectId.make("unused-duplicate"), + workspaceRoot: "/groups/sdk", + }) + .pipe(Effect.flip); + assert.instanceOf(duplicate, ProjectService.ProjectConflictError); + const inspected = yield* service.grouping({ + commandId: CommandId.make("group-inspect"), + identifier: created.id, + cwd: "/", + }); + assert.equal(inspected.groupingIdentity, identity); + const changed = yield* service.grouping({ + commandId: CommandId.make("group-change"), + identifier: "groups/sdk", + cwd: "/", + groupingIdentity: "e703c118-3547-41b1-88c5-9774cf40ae86", + }); + assert.equal(changed.groupingIdentity, "e703c118-3547-41b1-88c5-9774cf40ae86"); + const cleared = yield* service.grouping({ + commandId: CommandId.make("group-clear"), + identifier: created.id, + cwd: "/", + groupingIdentity: null, + }); + assert.isNull(cleared.groupingIdentity); + assert.deepEqual( + yield* sql`SELECT title, has_custom_title FROM projection_projects WHERE project_id = ${projectId}`, + [{ title: "Renamed", has_custom_title: 1 }], + ); + assert.equal(cleared.id, created.id); + assert.equal(cleared.workspaceRoot, created.workspaceRoot); + assert.equal(cleared.createdAt, created.createdAt); + assert.isNull((yield* service.snapshot).projects[0]?.groupingIdentity); + assert.isNull(Option.getOrThrow(yield* service.getById(projectId)).repositoryIdentity); + assert.deepEqual( + yield* sql`SELECT * FROM orchestration_v2_projection_threads`, + threadsBefore, + ); + }), + ); +}); diff --git a/apps/server/src/project/ProjectService.ts b/apps/server/src/project/ProjectService.ts index 4bdc8386438ca..12761255a9786 100644 --- a/apps/server/src/project/ProjectService.ts +++ b/apps/server/src/project/ProjectService.ts @@ -15,6 +15,9 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import * as Result from "effect/Result"; import * as Schema from "effect/Schema"; @@ -50,6 +53,21 @@ export interface ProjectUpdateInput extends ProjectUpdatePayload { readonly projectId: ProjectId; } +export interface ProjectRegisterInput { + readonly commandId: CommandId; + readonly projectId: ProjectId; + readonly workspaceRoot: string; + readonly title?: string | undefined; + readonly groupingIdentity?: NonNullable | undefined; +} + +export interface ProjectGroupingInput { + readonly commandId: CommandId; + readonly identifier: string; + readonly cwd: string; + readonly groupingIdentity?: ProjectUpdatePayload["groupingIdentity"]; +} + export interface ProjectBootstrapInput extends ProjectCreateInput {} export interface ProjectDeleteInput { @@ -130,6 +148,8 @@ export type ProjectServiceError = export class ProjectService extends Context.Service< ProjectService, { + readonly register: (input: ProjectRegisterInput) => Effect.Effect; + readonly grouping: (input: ProjectGroupingInput) => Effect.Effect; readonly create: (input: ProjectCreateInput) => Effect.Effect; readonly bootstrap: ( input: ProjectBootstrapInput, @@ -177,6 +197,8 @@ export const make = Effect.gen(function* () { const projects = yield* ProjectStore.ProjectStoreV2; const projectEnrichment = yield* ProjectEnrichmentService.ProjectEnrichmentService; const workspacePaths = yield* WorkspacePaths.WorkspacePaths; + const path = yield* Path.Path; + const homeDirectory = yield* HostProcess.HomeDirectory; const threadProjections = yield* ProjectionStore.ProjectionStoreV2; const eventSink = yield* EventSink.EventSinkV2; const idAllocator = yield* IdAllocator.IdAllocatorV2; @@ -198,6 +220,7 @@ export const make = Effect.gen(function* () { id: row.projectId, title: row.title, workspaceRoot: row.workspaceRoot, + groupingIdentity: row.groupingIdentity, repositoryIdentity: enrichment?.repositoryIdentity ?? null, faviconPath: row.faviconPath ?? enrichment?.faviconPath ?? null, defaultModelSelection: row.defaultModelSelection, @@ -407,6 +430,9 @@ export const make = Effect.gen(function* () { projectId: input.projectId, title: input.title, workspaceRoot, + ...(input.groupingIdentity === undefined + ? {} + : { groupingIdentity: input.groupingIdentity }), ...(input.scripts === undefined ? {} : { scripts: input.scripts }), }); yield* projectEnrichment.invalidate([workspaceRoot]); @@ -446,6 +472,9 @@ export const make = Effect.gen(function* () { ...(input.defaultThreadEnvMode === undefined ? {} : { defaultThreadEnvMode: input.defaultThreadEnvMode }), + ...(input.groupingIdentity === undefined + ? {} + : { groupingIdentity: input.groupingIdentity }), ...(input.scripts === undefined ? {} : { scripts: input.scripts }), ...(input.pinned === undefined ? {} : { pinned: input.pinned }), ...(input.archived === undefined ? {} : { archived: input.archived }), @@ -457,6 +486,85 @@ export const make = Effect.gen(function* () { }, ); + const register: ProjectService["Service"]["register"] = Effect.fn("ProjectService.register")( + function* (input) { + const workspaceRoot = yield* normalizeWorkspaceRoot({ + projectId: input.projectId, + workspaceRoot: input.workspaceRoot, + }); + const existing = yield* getByWorkspaceRoot(workspaceRoot); + if (Option.isSome(existing)) { + if (input.groupingIdentity === undefined) { + return yield* new ProjectConflictError({ + projectId: input.projectId, + workspaceRoot, + conflictingProjectId: existing.value.id, + }); + } + if (existing.value.groupingIdentity === input.groupingIdentity) return existing.value; + return yield* update({ + commandId: input.commandId, + projectId: existing.value.id, + groupingIdentity: input.groupingIdentity, + }); + } + const title = + input.title === undefined + ? path.basename(workspaceRoot).trim() || "project" + : input.title.trim(); + if (title.length === 0) { + return yield* new ProjectInvalidError({ + projectId: input.projectId, + detail: "Project title cannot be empty.", + }); + } + return yield* create({ ...input, title, workspaceRoot }); + }, + ); + + const grouping: ProjectService["Service"]["grouping"] = Effect.fn("ProjectService.grouping")( + function* (input) { + const identifier = input.identifier.trim(); + const projectId = ProjectId.make(identifier || "unknown"); + if (identifier.length === 0) { + return yield* new ProjectInvalidError({ + projectId, + detail: "Project identifier cannot be empty.", + }); + } + let project = yield* getById(projectId); + if (Option.isNone(project)) { + const candidate = path.resolve(input.cwd, expandHomePath(identifier, homeDirectory)); + const normalized = yield* Effect.result(workspacePaths.normalizeWorkspaceRoot(candidate)); + const workspaceRoot = + normalized._tag === "Success" + ? normalized.success + : normalized.failure.normalizedWorkspaceRoot; + const rows = yield* projects + .findActiveByWorkspaceRoot(workspaceRoot) + .pipe( + Effect.mapError( + (cause) => + new ProjectOperationError({ operation: "read-project", workspaceRoot, cause }), + ), + ); + project = Option.isSome(rows) ? Option.some(yield* hydrate(rows.value)) : Option.none(); + } + if (Option.isNone(project)) { + return yield* new ProjectInvalidError({ + projectId, + detail: `No active project found for '${identifier}'.`, + }); + } + if (input.groupingIdentity === undefined) return project.value; + return yield* update({ + commandId: input.commandId, + projectId: project.value.id, + groupingIdentity: input.groupingIdentity, + }); + }, + ); + const bootstrap: ProjectService["Service"]["bootstrap"] = Effect.fn("ProjectService.bootstrap")( function* (input) { const existing = yield* getByWorkspaceRoot(input.workspaceRoot); @@ -661,6 +769,8 @@ export const make = Effect.gen(function* () { ); return ProjectService.of({ + register, + grouping, create, bootstrap, update, diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index 7d9b31e78791e..734e351ec61fe 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5930, "codexAdditionalContextBytes": 7324, - "mcpToolListJsonBytes": 49749 + "mcpToolListJsonBytes": 49747 } diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index 10388e99fe7c5..26668ff05920b 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -316,6 +316,8 @@ const makeTestRelay = Effect.fnUntraced(function* ( }), Effect.provideService(ProjectService.ProjectService, { create: unused, + register: unused, + grouping: unused, bootstrap: unused, update: unused, delete: unused, diff --git a/apps/web/src/hooks/useLogicalProjectEnvironments.ts b/apps/web/src/hooks/useLogicalProjectEnvironments.ts index ebf5fe7a4d2fe..69672a7d538e5 100644 --- a/apps/web/src/hooks/useLogicalProjectEnvironments.ts +++ b/apps/web/src/hooks/useLogicalProjectEnvironments.ts @@ -9,7 +9,7 @@ import { import type { EnvironmentOption } from "../components/BranchToolbar.logic"; import { - deriveLogicalProjectKeyFromSettings, + resolveProjectGroup, selectProjectGroupingSettings, } from "@t3tools/client-runtime/state/project-grouping"; import { useProjects } from "../state/entities"; @@ -58,11 +58,10 @@ export function useLogicalProjectEnvironments( pushEnvironment(environment.environmentId, scratchProject?.id ?? null); } } else { - const logicalKey = deriveLogicalProjectKeyFromSettings(activeProject, projectGroupingSettings); + const group = resolveProjectGroup(activeProject, allProjects, projectGroupingSettings); const seen = new Set(); - for (const p of allProjects) { + for (const { project: p } of group?.members ?? []) { if (seen.has(p.environmentId)) continue; - if (deriveLogicalProjectKeyFromSettings(p, projectGroupingSettings) !== logicalKey) continue; seen.add(p.environmentId); pushEnvironment(p.environmentId, p.id); } diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index 899640ad23590..e480176b553eb 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -285,3 +285,35 @@ On mobile, use **Settings → Source control** to change selected environment de T3 Code only pulls when it can fast-forward and the checkout has no changed files, untracked files, or local commits. It skips checkouts on another branch or without an upstream. If a checkout has local work, resolve it yourself before automatic pulls can resume. + +## Group non-Git projects across computers + +Assign the same explicit grouping identity to copies of a non-Git project to show them as one project with a choice of computers. Each computer keeps its own directory, project record, and threads. Names and paths alone never establish this relationship. Git projects continue to group by repository identity; the Separate grouping setting keeps all copies apart. + +Before grouping copies, [connect each computer's environment to the same client](./remote-access.md). Assigning an identity does not connect the computers. + +If `t3` is not available in your terminal, desktop users can [install the bundled command](./install.md#the-t3-command) from **Settings → About**, then open a new terminal. + +Generate a lowercase UUID v4 once (for example, `python -c "import uuid; print(uuid.uuid4())"`) and reuse it only for copies of that project. + +Run these commands on each computer, using that computer's local copy path and the same UUID for `project add`: + +```sh +t3 project add /path/to/sdk --grouping-identity +t3 project grouping /path/to/sdk +t3 project list +``` + +Optional: change a copy's identity to move it to another group: + +```sh +t3 project grouping /path/to/sdk --identity +``` + +Optional: remove a copy's explicit identity to stop grouping it with those copies: + +```sh +t3 project grouping /path/to/sdk --clear +``` + +With `--grouping-identity`, `project add` also updates an existing registration at that path, preserving its title and threads. Without the flag, adding an existing project still reports a conflict. `project list` returns JSON including the saved identity. Agents can use `t3_project_create` or `t3_project_update` with `groupingIdentity`; omit it to preserve the value or pass `null` to clear it. Read and list tools expose the identity. Update the servers and clients on all computers before assigning identities. diff --git a/evals/mcp-tool-use/cases/projects.ndjson b/evals/mcp-tool-use/cases/projects.ndjson index b52ee4eff30bc..d64201067cc42 100644 --- a/evals/mcp-tool-use/cases/projects.ndjson +++ b/evals/mcp-tool-use/cases/projects.ndjson @@ -1 +1,5 @@ {"id": "archive-project-not-delete", "area": "projects", "kind": "guard", "pattern": "archive a project by deleting it", "experience": "Projects gained an archive flag. Asked to archive a finished project, an agent must set archived, not remove the project and its threads.", "rule": "t3_project_update archived: Threads and files are kept.", "privacy": "commit-safe", "setup": {"role": "top", "mocks": {"t3_project_list": {"projects": [{"id": "project-app", "title": "app", "workspaceRoot": "/home/dev/code/app", "defaultModelSelection": null, "scripts": [], "pinnedAt": null, "archivedAt": null, "createdAt": "2026-09-01T00:00:00.000Z", "updatedAt": "2026-10-20T00:00:00.000Z", "deletedAt": null}, {"id": "project-old-site", "title": "old-site", "workspaceRoot": "/home/dev/code/old-site", "defaultModelSelection": null, "scripts": [], "pinnedAt": null, "archivedAt": null, "createdAt": "2026-03-01T00:00:00.000Z", "updatedAt": "2026-04-02T00:00:00.000Z", "deletedAt": null}], "nextCursor": null}}}, "prompt": "I'm done with the old-site project. Archive it in T3 Code so it stops showing up in my project pickers.", "expect": {"calls": [{"tool": "t3_project_update", "args": {"projectId": "project-old-site", "archived": true}}], "callsMustNot": [{"tool": "t3_project_delete"}, {"tool": "t3_project_update", "args": {"projectId": "project-app"}}]}, "maxSteps": 6} +{"id": "non-git-copies-reuse-grouping-identity", "area": "projects", "kind": "guard", "pattern": "reuse the existing explicit identity on every non-Git copy", "experience": "Grouping guidance must keep one lowercase UUID v4 across copies rather than generating one identity per machine.", "source": {"pullRequest": "https://github.com/pingdotgg/t3code/pull/23531", "reviewComment": "https://github.com/pingdotgg/t3code/pull/23531#discussion_r4415614093"}, "privacy": "commit-safe", "setup": {"role": "top", "context": "The current project grouping mode is repository. Both environments are connected and writable. The copies contain the same non-Git workspace, with Git repositories only in child directories. The following inventory is current.", "priorTurns": [{"role": "assistant", "toolCalls": [{"tool": "t3_project_list", "input": {"environmentId": "environment:mac"}, "result": {"projects": [{"id": "project:sdk-mac", "title": "SDK", "workspaceRoot": "/home/user/sdk", "groupingIdentity": "a87b5fa1-6ade-4f2d-8ef2-4cfe1eb9b8ac", "repositoryIdentity": null, "defaultModelSelection": null, "scripts": [], "createdAt": "2026-07-01T00:00:00.000Z", "updatedAt": "2026-07-01T00:00:00.000Z", "deletedAt": null}], "nextCursor": null}}, {"tool": "t3_project_list", "input": {"environmentId": "environment:linux"}, "result": {"projects": [{"id": "project:sdk-linux", "title": "SDK", "workspaceRoot": "/home/user/work/sdk", "groupingIdentity": null, "repositoryIdentity": null, "defaultModelSelection": null, "scripts": [], "createdAt": "2026-07-01T00:00:00.000Z", "updatedAt": "2026-07-01T00:00:00.000Z", "deletedAt": null}], "nextCursor": null}}]}], "mocks": {"t3_project_update": {"id": "project:sdk-linux", "title": "SDK", "workspaceRoot": "/home/user/work/sdk", "groupingIdentity": "a87b5fa1-6ade-4f2d-8ef2-4cfe1eb9b8ac", "repositoryIdentity": null, "defaultModelSelection": null, "scripts": [], "createdAt": "2026-07-01T00:00:00.000Z", "updatedAt": "2026-07-01T00:00:00.000Z", "deletedAt": null}}}, "prompt": "Group these two non-Git SDK copies as one logical project. The Mac copy already has the shared identity; keep it and all other settings unchanged. Update the other physical project through MCP.", "expect": {"calls": [{"tool": "t3_project_update", "args": {"environmentId": "environment:linux", "projectId": "project:sdk-linux", "groupingIdentity": "a87b5fa1-6ade-4f2d-8ef2-4cfe1eb9b8ac"}, "allowedArgs": ["environmentId", "projectId", "groupingIdentity"]}], "maxCalls": {"t3_project_update": 1}, "callsMustNot": [{"tool": "t3_project_update", "args": {"environmentId": "environment:mac"}}], "notCalls": ["t3_project_create", "t3_project_delete", "t3_project_clone"], "shellMustNot": ["\\bgit\\s+init\\b"]}, "maxSteps": 8} +{"id": "git-identity-precedes-explicit-grouping", "area": "projects", "kind": "guard", "pattern": "explicit grouping must not override Git repository identity", "experience": "The explicit grouping key applies to non-Git roots; agents must not claim it merges unrelated Git repositories.", "source": {"pullRequest": "https://github.com/pingdotgg/t3code/pull/23531", "reviewComment": "https://github.com/pingdotgg/t3code/pull/23531#discussion_r4415614093"}, "privacy": "commit-safe", "setup": {"role": "top", "context": "Both environments are connected and writable, in repository grouping mode. These are the current project records.", "priorTurns": [{"role": "assistant", "toolCalls": [{"tool": "t3_project_list", "input": {"environmentId": "environment:mac"}, "result": {"projects": [{"id": "project:app", "title": "SDK", "workspaceRoot": "/home/user/app", "groupingIdentity": null, "repositoryIdentity": {"canonicalKey": "github.com/acme/app", "locator": {"source": "git-remote", "remoteName": "origin", "remoteUrl": "https://github.com/acme/app.git"}}, "defaultModelSelection": null, "scripts": [], "createdAt": "2026-07-01T00:00:00.000Z", "updatedAt": "2026-07-01T00:00:00.000Z", "deletedAt": null}], "nextCursor": null}}, {"tool": "t3_project_list", "input": {"environmentId": "environment:linux"}, "result": {"projects": [{"id": "project:other", "title": "SDK", "workspaceRoot": "/home/user/other", "groupingIdentity": null, "repositoryIdentity": {"canonicalKey": "github.com/acme/other", "locator": {"source": "git-remote", "remoteName": "origin", "remoteUrl": "https://github.com/acme/other.git"}}, "defaultModelSelection": null, "scripts": [], "createdAt": "2026-07-01T00:00:00.000Z", "updatedAt": "2026-07-01T00:00:00.000Z", "deletedAt": null}], "nextCursor": null}}]}], "mocks": {"t3_project_update": {"ok": true}}}, "prompt": "Make these two Git projects one logical project by setting the same explicit grouping key, without changing their Git remotes. If this feature cannot do that, explain instead of modifying the projects.", "expect": {"notCalls": ["t3_project_update", "t3_project_create", "t3_project_delete", "t3_project_clone"], "textMust": ["(?:(?:[Gg]it(?:\\s+repository)?\\s+identity|[Rr]epository\\s+identity)\\s+(?:takes?\\s+(?:precedence|priority)\\s+over|wins?\\s+over|overrides?)\\s+(?:the\\s+)?(?:[Ee]xplicit\\s+grouping|groupingIdentity|[Gg]rouping\\s+(?:keys?|identity))|(?:[Ee]xplicit\\s+grouping|groupingIdentity|[Gg]rouping\\s+(?:keys?|identity))\\s+(?:cannot|can[\\x27\\u2019]t|does\\s+not|doesn[\\x27\\u2019]t)\\s+override\\s+(?:the\\s+)?(?:[Gg]it(?:\\s+repository)?\\s+identity|[Rr]epository\\s+identity))", "(?:[Cc]annot|[Cc]an[\\x27\\u2019]t|[Ww]on[\\x27\\u2019]t|[Ww]ill\\s+not)[^\\n.!?]{0,60}(?:merg\\w*|group\\w*|combin\\w*)"], "shellMustNot": ["\\bgit\\s+remote\\s+(set-url|add|remove)\\b", "\\bgit\\s+init\\b", "\\bt3\\s+project\\s+grouping\\b[^\\n]*(--identity|--clear)", "\\bt3\\s+project\\s+add\\b[^\\n]*--grouping-identity"]}, "maxSteps": 8} +{"id": "clear-non-git-grouping-identity", "area": "projects", "kind": "control", "pattern": "clear the optional key without deleting the project", "experience": "Clearing grouping is a normal metadata update and must retain the physical workspace registration.", "source": {"pullRequest": "https://github.com/pingdotgg/t3code/pull/23531", "reviewComment": "https://github.com/pingdotgg/t3code/pull/23531#discussion_r4415614093"}, "privacy": "commit-safe", "setup": {"role": "top", "context": "The Mac environment is writable. SDK is a non-Git project with an explicit shared grouping identity.", "priorTurns": [{"role": "assistant", "toolCalls": [{"tool": "t3_project_list", "input": {"environmentId": "environment:mac"}, "result": {"projects": [{"id": "project:sdk-mac", "title": "SDK", "workspaceRoot": "/home/user/sdk", "groupingIdentity": "a87b5fa1-6ade-4f2d-8ef2-4cfe1eb9b8ac", "repositoryIdentity": null, "defaultModelSelection": null, "scripts": [], "createdAt": "2026-07-01T00:00:00.000Z", "updatedAt": "2026-07-01T00:00:00.000Z", "deletedAt": null}], "nextCursor": null}}]}], "mocks": {"t3_project_update": {"id": "project:sdk-mac", "title": "SDK", "workspaceRoot": "/home/user/sdk", "groupingIdentity": null, "repositoryIdentity": null, "defaultModelSelection": null, "scripts": [], "createdAt": "2026-07-01T00:00:00.000Z", "updatedAt": "2026-07-01T00:00:00.000Z", "deletedAt": null}}}, "prompt": "Remove the explicit grouping identity from project:sdk-mac on environment:mac. Keep its folder, title, scripts and existing threads.", "expect": {"calls": [{"tool": "t3_project_update", "args": {"environmentId": "environment:mac", "projectId": "project:sdk-mac", "groupingIdentity": null}, "requiredArgs": ["groupingIdentity"], "allowedArgs": ["environmentId", "projectId", "groupingIdentity"]}], "maxCalls": {"t3_project_update": 1}, "notCalls": ["t3_project_create", "t3_project_delete", "t3_project_clone"]}, "maxSteps": 8} +{"id": "title-only-project-creation-side-effects", "area": "projects", "kind": "guard", "pattern": "describe scaffold writes and the initial commit before approval", "experience": "The shortened project-create description omitted the README, icon and initial-commit effects of title-only creation.", "source": {"pullRequest": "https://github.com/pingdotgg/t3code/pull/23531", "reviewComment": "https://github.com/pingdotgg/t3code/pull/23531#discussion_r4415614078"}, "privacy": "commit-safe", "setup": {"role": "top", "context": "Explain the current tool behavior from its description. This is a preview of a proposed creation, not approval to execute it."}, "prompt": "I plan to call t3_project_create with title Docs and no workspaceRoot. Before I approve it, tell me which files and Git operations it creates. Do not create anything yet.", "expect": {"textMust": ["(?:(?:[Cc]reat(?:e|es|ing)|[Ww]rit(?:e|es|ing)|[Aa]dd(?:s|ing)?|[Gg]enerat(?:e|es|ing)|[Ss]caffold(?:s|ing)?)[^\\n]{0,100}README\\.md|README\\.md[^\\n]{0,100}(?:is|are|gets?)\\s+(?:created|written|added|generated|scaffolded))", "(?:(?:[Cc]reat(?:e|es|ing)|[Ww]rit(?:e|es|ing)|[Aa]dd(?:s|ing)?|[Gg]enerat(?:e|es|ing)|[Ss]caffold(?:s|ing)?)[^\\n]{0,100}assets/icon\\.svg|assets/icon\\.svg[^\\n]{0,100}(?:is|are|gets?)\\s+(?:created|written|added|generated|scaffolded))", "(?:[Aa]ttempt\\w*|[Tt]r(?:y|ies)|[Mm]ak\\w*|[Cc]reat\\w*|[Pp]erform\\w*|[Rr]un\\w*)[^\\n]{0,80}(?:[Ii]nitial|[Ff]irst)\\s+(?:Git\\s+)?commit|[Ii]nitial commit[^\\n]{0,40}(?:attempt|tried|made|created|performed|run)", "(?:[Ii]nitiali[sz](?:e|es|ing)\\s+(?:(?:a|the)\\s+)?(?:new\\s+)?[Gg]it(?:\\s+repo(?:sitory)?)?|[Rr]uns?\\s+`?git\\s+init\\b|[Gg]it(?:\\s+repo(?:sitory)?)?\\s+(?:is|gets?)\\s+initiali[sz]ed)"], "notCalls": ["t3_project_create", "t3_project_update", "t3_project_clone", "t3_project_delete"], "shellMustNot": ["\\bgit\\s+init\\b"], "textMustNot": ["(?:\\b(?:[Nn]ot|[Nn]ever|[Nn]o)\\b|n[\\x27\\u2019]t)[^\\n.!?]{0,35}\\b(?:[Cc]reat\\w*|[Ww]rit\\w*|[Ww]ritt\\w*|[Gg]enerat\\w*|[Ss]caffold\\w*|[Aa]dd\\w*|[Aa]ttempt\\w*|[Tt]r(?:y|ies)|[Mm]ak\\w*|made|[Pp]erform\\w*|[Rr]un\\w*)[^\\n.!?]{0,80}(?:README\\.md|assets/icon\\.svg|(?:[Ii]nitial\\s+)?commit)", "(?:README\\.md|assets/icon\\.svg|(?:[Ii]nitial\\s+)?commit)[^\\n.!?]{0,80}(?:\\b(?:[Nn]ot|[Nn]ever|[Nn]o)\\b|n[\\x27\\u2019]t)[^\\n.!?]{0,35}\\b(?:[Cc]reat\\w*|[Ww]rit\\w*|[Ww]ritt\\w*|[Gg]enerat\\w*|[Ss]caffold\\w*|[Aa]dd\\w*|[Aa]ttempt\\w*|[Tt]r(?:y|ies)|[Mm]ak\\w*|made|[Pp]erform\\w*|[Rr]un\\w*)", "(?:\\b(?:[Nn]ot|[Nn]ever|[Nn]o)\\b|n[\\x27\\u2019]t)[^\\n.!?]{0,35}\\b(?:[Ii]nitiali[sz]\\w*|[Rr]un\\w*)[^\\n.!?]{0,40}\\b[Gg]it\\b", "\\b[Gg]it(?:\\s+repo(?:sitory)?)?[^\\n.!?]{0,35}(?:\\b(?:[Nn]ot|[Nn]ever|[Nn]o)\\b|n[\\x27\\u2019]t)[^\\n.!?]{0,35}\\b(?:[Ii]nitiali[sz]\\w*|[Rr]un\\w*)"]}, "maxSteps": 8} diff --git a/evals/mcp-tool-use/run.test.ts b/evals/mcp-tool-use/run.test.ts index ec1a121fee485..7359948077a59 100644 --- a/evals/mcp-tool-use/run.test.ts +++ b/evals/mcp-tool-use/run.test.ts @@ -45,6 +45,8 @@ import { import { briefRoute, gradeDevice, shellInvocations } from "./device-grading.ts"; import { taskPrompt } from "../../apps/server/src/mcp/delegatedTaskModes.ts"; +const decodeCase = Schema.decodeSync(Schema.fromJsonString(McpToolUseCase)); + it("grades Codex shell and subagent calls with the shared rules", () => { const calls = [ { name: "exec_command", input: { cmd: "sleep 60" } }, @@ -88,6 +90,20 @@ it("grades Codex shell and subagent calls with the shared rules", () => { })); const run = (calls: SampleRun["calls"]) => ({ calls, text: "", stopReason: "end_turn" }); assert.deepEqual(failed({ distinctCalls }, run(cards)), []); + const constrained = distinctCalls.map((call) => ({ + ...call, + requiredArgs: ["value"], + allowedArgs: Object.keys(call.args), + })); + assert.deepEqual(failed({ distinctCalls: constrained }, run(cards)), []); + const { value: _value, ...missingValue } = cards[0]!.input; + for (const input of [missingValue, { ...cards[0]!.input, extra: true }]) { + assert.deepEqual( + failed({ distinctCalls: constrained }, run([{ ...cards[0]!, input }, ...cards.slice(1)])), + ["distinctCalls"], + ); + } + assert.deepEqual( failed( { distinctCalls }, @@ -415,6 +431,113 @@ it("passes a good run and names every rule a bad run breaks", () => { "linked ~/pull/124", "linkedNumbers", ]); + + const projectCases = NodeFS.readFileSync( + new URL("./cases/projects.ndjson", import.meta.url), + "utf8", + ) + .trim() + .split("\n") + .map((line) => decodeCase(line)); + const clear = projectCases.find((entry) => entry.id === "clear-non-git-grouping-identity")!; + const clearArgs = clear.expect.calls![0]!.args!; + const clearRun = (input: Record): SampleRun => ({ + calls: [{ step: 0, tool: "t3_project_update", input }], + text: "The grouping identity is cleared.", + stopReason: "end_turn", + }); + assert.deepEqual(failed(clear.expect, clearRun(clearArgs)), []); + const { groupingIdentity: _identity, ...omittedIdentity } = clearArgs; + for (const input of [ + omittedIdentity, + { ...clearArgs, groupingIdentity: undefined }, + { ...clearArgs, groupingIdentity: "a87b5fa1-6ade-4f2d-8ef2-4cfe1eb9b8ac" }, + ]) { + assert.isNotEmpty(failed(clear.expect, clearRun(input))); + } + const preview = projectCases.find( + (entry) => entry.id === "title-only-project-creation-side-effects", + )!; + const disclosure = + "It initializes a Git repository, creates README.md and assets/icon.svg, then attempts an Initial commit."; + for (const text of [ + disclosure, + "A Git repository is initialized; README.md and assets/icon.svg are created; an Initial commit is attempted.", + "Runs `git init`.\nWrites `README.md`.\nGenerates `assets/icon.svg`.\nAttempts an initial commit.", + "I won't create anything yet. After approval, the tool initializes Git, creates README.md and assets/icon.svg, then attempts an Initial commit.", + "After approval, Git is initialized, README.md and assets/icon.svg are created and an Initial commit is attempted. I won't create anything yet.", + ]) { + assert.deepEqual(failed(preview.expect, { calls: [], text, stopReason: "end_turn" }), [], text); + } + const precedence = projectCases.find( + (entry) => entry.id === "git-identity-precedes-explicit-grouping", + )!; + for (const text of [ + "Git identity takes precedence over explicit grouping, so these projects cannot be merged this way.", + "Repository identity wins over the grouping key; I can't combine these projects using it.", + "Explicit grouping cannot override Git repository identity. I cannot merge them with this key.", + ]) { + assert.deepEqual( + failed(precedence.expect, { calls: [], text, stopReason: "end_turn" }), + [], + text, + ); + } + const acceptedBadExplanations: Array = []; + for (const text of [ + "Git identity can be overridden by explicit grouping, but I cannot make the change now.", + "Explicit grouping overrides Git identity, but I cannot merge them now.", + "Git identity does not take precedence over explicit grouping. I cannot merge them now.", + ]) { + if (failed(precedence.expect, { calls: [], text, stopReason: "end_turn" }).length === 0) { + acceptedBadExplanations.push(text); + } + } + for (const text of [ + "It creates README.md and assets/icon.svg, then attempts an Initial commit.", + "It creates README.md and assets/icon.svg, then attempts an Initial commit, but does not initialize Git.", + "Git is not initialized. It creates README.md and assets/icon.svg, then attempts an Initial commit.", + "It initializes Git and creates README.md and assets/icon.svg, then attempts an Initial commit. The Git repository will not be initialized.", + ]) { + if (failed(preview.expect, { calls: [], text, stopReason: "end_turn" }).length === 0) { + acceptedBadExplanations.push(text); + } + } + assert.deepEqual(acceptedBadExplanations, []); + for (const run of [ + { + calls: [{ step: 0, tool: "Bash", input: { command: "git init /home/user/docs" } }], + text: disclosure, + stopReason: "end_turn", + }, + { + calls: [], + text: "It does not create README.md or assets/icon.svg and does not attempt an initial commit.", + stopReason: "end_turn", + }, + { + calls: [], + text: "README.md and assets/icon.svg are not created; an initial commit is not attempted.", + stopReason: "end_turn", + }, + { + calls: [], + text: "Creates README.md and assets/icon.svg, but does not attempt an initial commit.", + stopReason: "end_turn", + }, + { + calls: [], + text: "It does not create README.md, but writes assets/icon.svg and attempts an Initial commit.", + stopReason: "end_turn", + }, + { + calls: [], + text: "Creates README.md but never generates assets/icon.svg; attempts an Initial commit.", + stopReason: "end_turn", + }, + ]) { + assert.isNotEmpty(failed(preview.expect, run), JSON.stringify(run)); + } }); it("grades a page passed by path on the file the agent last wrote there", () => { @@ -558,6 +681,28 @@ it("bounds numbers and reads target.options in either schema form", () => { assert.deepEqual(failed(expect, run(5, { effort: "max" }, 90_000)), []); assert.lengthOf(failed(expect, run(21, [{ id: "effort", value: "high" }], 59_999)), 2); assert.lengthOf(failed(expect, run("10", {}, 60_000)), 2); + + const args = { + environmentId: "environment:linux", + projectId: "project:sdk", + groupingIdentity: "a87b5fa1-6ade-4f2d-8ef2-4cfe1eb9b8ac", + }; + const update = { tool: "t3_project_update", args }; + const restricted = { calls: [{ ...update, allowedArgs: Object.keys(args) }] }; + const updated = (extra: Record): SampleRun => ({ + calls: [{ step: 0, tool: update.tool, input: { ...args, ...extra } }], + text: "", + stopReason: "end_turn", + }); + assert.deepEqual(failed(restricted, updated({})), []); + for (const extra of [ + { title: "Other" }, + { workspaceRoot: "/home/user/other" }, + { scripts: [] }, + ]) { + assert.deepEqual(failed({ calls: [update] }, updated(extra)), []); + assert.lengthOf(failed(restricted, updated(extra)), 1); + } }); const template = { @@ -1111,7 +1256,7 @@ const deviceCases = () => NodeFS.readFileSync(new URL("./cases/device-manager.ndjson", import.meta.url), "utf8") .trim() .split("\n") - .map((line) => decodeDeviceCase(line)); + .map((line) => decodeCase(line)); it.effect("builds device cases from product-backed worlds and synthetic prior turns", () => Effect.gen(function* () { @@ -1187,7 +1332,7 @@ const secretCases = () => NodeFS.readFileSync(new URL("./cases/secrets.ndjson", import.meta.url), "utf8") .trim() .split("\n") - .map((line) => decodeDeviceCase(line)); + .map((line) => decodeCase(line)); it.effect("builds the secrets cases, each citing a finding and grading secret handling", () => Effect.gen(function* () { diff --git a/evals/mcp-tool-use/run.ts b/evals/mcp-tool-use/run.ts index 5786de748eaf1..14b4f6e94db91 100644 --- a/evals/mcp-tool-use/run.ts +++ b/evals/mcp-tool-use/run.ts @@ -187,6 +187,9 @@ const PriorToolCall = Schema.Struct({ const McpToolUseCall = Schema.Struct({ tool: Schema.String, args: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), + /** Top-level allowlist; required values still come from args. */ + allowedArgs: Schema.optional(Schema.Array(Schema.String)), + requiredArgs: Schema.optional(Schema.Array(Schema.String)), }); type McpToolUseCall = typeof McpToolUseCall.Type; @@ -1769,18 +1772,19 @@ const newThreadRequests = (calls: ReadonlyArray) => }); /** Whether every expected value can take a different request it matches. */ -const matchesDistinct = ( - expected: ReadonlyArray, - requests: ReturnType, +const matchesDistinct = ( + expected: ReadonlyArray, + requests: ReadonlyArray, + match: (expected: Expected, actual: Actual) => boolean, taken: ReadonlySet = new Set(), ): boolean => { const [first, ...rest] = expected; if (expected.length === 0) return true; return requests.some( - ({ request, brief }, i) => + (request, i) => !taken.has(i) && - matches(first, typeof first === "string" ? brief : request) && - matchesDistinct(rest, requests, new Set([...taken, i])), + match(first!, request) && + matchesDistinct(rest, requests, match, new Set([...taken, i])), ); }; @@ -1857,21 +1861,29 @@ export const gradeSample = ( // `tool` may list alternatives, as in "link_pull_request|watch_pull_request"; counts add up. const count = (tool: string) => calls.filter((call) => tool.split("|").includes(call.tool)).length; - const found = ({ tool, args = {} }: McpToolUseCall, among = calls) => - among.some((call) => tool.split("|").includes(call.tool) && matches(args, call.input)); + const matchesCall = ( + { tool, args = {}, allowedArgs, requiredArgs }: McpToolUseCall, + { tool: callTool, input }: Call, + ) => + tool.split("|").includes(callTool) && + matches(args, input) && + (requiredArgs === undefined || + (isRecord(input) && requiredArgs.every((key) => input[key] !== undefined))) && + (allowedArgs === undefined || + (isRecord(input) && Object.keys(input).every((key) => allowedArgs.includes(key)))); + const found = (expected: McpToolUseCall, among = calls) => + among.some((call) => matchesCall(expected, call)); const label = ({ tool, args }: McpToolUseCall) => (args ? `${tool} ${valueText(args)}` : tool); for (const call of expect.calls ?? []) add(`calls ${label(call)}`, found(call)); if (expect.distinctCalls !== undefined) { - const expected = expect.distinctCalls.map(({ tool, args }) => ({ tool, input: args ?? {} })); + const expected = expect.distinctCalls; add( "distinctCalls", - calls.every((call) => expected.filter((entry) => matches(entry, call)).length <= 1) && - matchesDistinct( - expected, - calls.map((request) => ({ request, brief: "" })), - ), + calls.every((call) => expected.filter((entry) => matchesCall(entry, call)).length <= 1) && + matchesDistinct(expected, calls, matchesCall), ); } + for (const call of expect.callsMustNot ?? []) add(`callsMustNot ${label(call)}`, !found(call, attempted)); // A page check must not pass because the grader never saw the file a path call shows. @@ -1901,7 +1913,10 @@ export const gradeSample = ( const requests = newThreadRequests(calls); add( `newThreads ${valueText(expect.newThreads)}`, - requests.length === expect.newThreads.length && matchesDistinct(expect.newThreads, requests), + requests.length === expect.newThreads.length && + matchesDistinct(expect.newThreads, requests, (expected, { request, brief }) => + matches(expected, typeof expected === "string" ? brief : request), + ), ); } if (expect.endsTurnAfter !== undefined) { diff --git a/packages/client-runtime/src/operations/commands.test.ts b/packages/client-runtime/src/operations/commands.test.ts index 87c8b785563b3..283362f4895b8 100644 --- a/packages/client-runtime/src/operations/commands.test.ts +++ b/packages/client-runtime/src/operations/commands.test.ts @@ -102,7 +102,10 @@ const makeSupervisor = Effect.fn("TestEnvironmentCommands.makeSupervisor")(funct Effect.sync(() => { input.projects.push(mutation); return { - id: mutation.projectId, + id: + mutation.type === "project.grouping" + ? ProjectId.make(mutation.identifier) + : mutation.projectId, title: mutation.type === "project.create" ? mutation.title : "Project", workspaceRoot: mutation.type === "project.create" ? mutation.workspaceRoot : "/workspace/project", diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index 50717d0cafd41..10e0626601109 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -58,6 +58,7 @@ export interface CreateProjectInput extends CommandMetadata { readonly workspaceRoot: string; readonly createWorkspaceRootIfMissing?: boolean; readonly defaultModelSelection?: ModelSelection | null; + readonly groupingIdentity?: string | null; readonly scripts?: ReadonlyArray; } @@ -70,6 +71,7 @@ export interface UpdateProjectInput extends CommandMetadata { readonly projectIcon?: ProjectIconOverride | null; readonly faviconPath?: string | null; readonly defaultThreadEnvMode?: ThreadEnvMode | null; + readonly groupingIdentity?: string | null; readonly scripts?: ReadonlyArray; readonly pinned?: boolean; readonly archived?: boolean; @@ -390,6 +392,7 @@ const mutateProject = Effect.fn("EnvironmentCommands.mutateProject")(function* ( readonly workspaceRoot: string; readonly createWorkspaceRootIfMissing?: boolean; readonly defaultModelSelection?: ModelSelection | null; + readonly groupingIdentity?: string | null; readonly scripts?: ReadonlyArray; } | { @@ -399,6 +402,7 @@ const mutateProject = Effect.fn("EnvironmentCommands.mutateProject")(function* ( readonly title?: string; readonly workspaceRoot?: string; readonly defaultModelSelection?: ModelSelection | null; + readonly groupingIdentity?: string | null; readonly scripts?: ReadonlyArray; } | { @@ -426,6 +430,7 @@ export const createProject = Effect.fn("EnvironmentCommands.createProject")(func ...(input.defaultModelSelection === undefined ? {} : { defaultModelSelection: input.defaultModelSelection }), + ...(input.groupingIdentity === undefined ? {} : { groupingIdentity: input.groupingIdentity }), ...(input.scripts === undefined ? {} : { scripts: input.scripts }), }); }); @@ -448,6 +453,7 @@ export const updateProject = Effect.fn("EnvironmentCommands.updateProject")(func ...(input.defaultThreadEnvMode === undefined ? {} : { defaultThreadEnvMode: input.defaultThreadEnvMode }), + ...(input.groupingIdentity === undefined ? {} : { groupingIdentity: input.groupingIdentity }), ...(input.scripts === undefined ? {} : { scripts: input.scripts }), ...(input.pinned === undefined ? {} : { pinned: input.pinned }), ...(input.archived === undefined ? {} : { archived: input.archived }), diff --git a/packages/client-runtime/src/state/projectGrouping.test.ts b/packages/client-runtime/src/state/projectGrouping.test.ts index a991e7a45cb22..7b39cf2e73799 100644 --- a/packages/client-runtime/src/state/projectGrouping.test.ts +++ b/packages/client-runtime/src/state/projectGrouping.test.ts @@ -9,6 +9,7 @@ import { pickableProjectGroups, derivePhysicalProjectKey, resolveScratchProjectScopeKey, + resolveProjectGroup, sortProjectsByGroupOrder, type ProjectGroupingSettings, } from "./projectGrouping.ts"; @@ -191,6 +192,32 @@ describe("buildProjectGroups", () => { expect(resolveScratchProjectScopeKey(key, [scratch, ordinary])).toBe(key); }); + it.each(["repository", "repository_path", "separate"] as const)( + "migrates an explicitly grouped scratch selection in %s mode without stealing an ordinary group", + (mode) => { + const project = makeProject("sdk", "/sdk", { + repositoryIdentity: null, + groupingIdentity: "39c61a1b-8a64-43a6-9da5-65c15da93d52", + }); + const groupingSettings = settings(mode); + const key = deriveLogicalProjectKeyFromSettings(project, groupingSettings); + const scratch = { ...project, isScratch: true as const }; + expect(resolveScratchProjectScopeKey(key, [scratch], groupingSettings)).toBe("\0no-project"); + const ordinary = { ...project, workspaceRoot: "/ordinary" }; + if (mode !== "separate") { + expect(resolveScratchProjectScopeKey(key, [scratch, ordinary], groupingSettings)).toBe(key); + } else { + expect( + resolveScratchProjectScopeKey( + `explicit:${project.groupingIdentity}`, + [scratch], + groupingSettings, + ), + ).toBe(`explicit:${project.groupingIdentity}`); + } + }, + ); + it.each(["repository", "repository_path", "separate"] as const)( "coalesces scratch homes in %s mode without merging ordinary same-title projects", (mode) => { @@ -494,3 +521,92 @@ describe("sortProjectsByGroupOrder", () => { expect(sorted.map((entry) => entry.id)).toEqual(["pinned", "recent", "archived", "unlisted"]); }); }); + +describe("explicit non-Git grouping", () => { + const sdk = "39c61a1b-8a64-43a6-9da5-65c15da93d52"; + const spacetime = "e703c118-3547-41b1-88c5-9774cf40ae86"; + const copies = ["windows", "linux", "mac"].flatMap((machine) => + [sdk, spacetime].map((groupingIdentity, index) => + makeProject(`${machine}-${index}`, `/${machine}/${index}`, { + environmentId: EnvironmentId.make(machine), + repositoryIdentity: null, + groupingIdentity, + title: index === 0 ? "sdk" : "spacetimedb", + }), + ), + ); + it.each(["repository", "repository_path"] as const)( + "preserves all physical refs across three machines in %s mode", + (mode) => { + const groups = buildProjectGroups({ projects: copies, settings: settings(mode) }); + expect(groups).toHaveLength(2); + for (const group of groups) { + expect(group.members).toHaveLength(3); + expect(group.memberProjectRefs).toHaveLength(3); + expect(new Set(group.members.map(({ project }) => project.environmentId)).size).toBe(3); + } + }, + ); + it("honors separate mode, per-project overrides, and clearing", () => { + expect(buildProjectGroups({ projects: copies, settings: settings("separate") })).toHaveLength( + 6, + ); + expect( + buildProjectGroups({ + projects: copies, + settings: settings("repository", { [derivePhysicalProjectKey(copies[0]!)]: "separate" }), + }), + ).toHaveLength(3); + expect( + buildProjectGroups({ + projects: copies.map((p, i) => (i === 0 ? { ...p, groupingIdentity: null } : p)), + settings: settings("repository"), + }), + ).toHaveLength(3); + }); + it("never groups by name and keeps Git identity authoritative", () => { + const sameNames = copies.map((p) => ({ ...p, groupingIdentity: null, title: "sdk" })); + expect( + buildProjectGroups({ projects: sameNames, settings: settings("repository") }), + ).toHaveLength(6); + const git = copies.map((p) => ({ ...p, repositoryIdentity })); + expect(buildProjectGroups({ projects: git, settings: settings("repository") })).toHaveLength(1); + }); + it("keeps known Git identity authoritative while the newest duplicate is still enriching", () => { + const stale = { ...copies[0]!, repositoryIdentity }; + const fresh = { + ...copies[0]!, + id: ProjectId.make("fresh-git"), + updatedAt: "2026-10-20T00:00:00.000Z", + }; + const remote = { ...copies[2]!, repositoryIdentity }; + expect( + buildProjectGroups({ projects: [stale, fresh, remote], settings: settings("repository") }), + ).toHaveLength(1); + expect( + resolveProjectGroup(fresh, [stale, fresh, remote], settings("repository"))?.members.map( + (member) => member.project, + ), + ).toEqual([fresh, remote]); + }); + it("does not resurrect a cleared identity from an older duplicate registration", () => { + const first = copies[0]!; + const fresh = { + ...first, + id: ProjectId.make("fresh"), + groupingIdentity: null, + updatedAt: "2026-10-20T00:00:00.000Z", + }; + const groups = buildProjectGroups({ + projects: [first, fresh, copies[2]!], + settings: settings("repository"), + }); + expect(groups).toHaveLength(2); + expect(groups.flatMap((g) => g.memberProjectRefs)).toHaveLength(3); + expect( + resolveProjectGroup(first, [first, fresh, copies[2]!], settings("repository"))?.members.map( + (member) => member.project, + ), + ).toEqual([fresh]); + }); +}); diff --git a/packages/client-runtime/src/state/projectGrouping.ts b/packages/client-runtime/src/state/projectGrouping.ts index 607f28dc8ee10..763cab2db7cde 100644 --- a/packages/client-runtime/src/state/projectGrouping.ts +++ b/packages/client-runtime/src/state/projectGrouping.ts @@ -129,7 +129,12 @@ function deriveRepositoryScopedKey( function deriveLogicalProjectKey( project: Pick< EnvironmentProject, - "environmentId" | "id" | "workspaceRoot" | "repositoryIdentity" | "isScratch" + | "environmentId" + | "id" + | "workspaceRoot" + | "repositoryIdentity" + | "isScratch" + | "groupingIdentity" >, options?: { readonly groupingMode?: SidebarProjectGroupingMode; @@ -144,6 +149,7 @@ function deriveLogicalProjectKey( return ( deriveRepositoryScopedKey(project, groupingMode) ?? + (project.groupingIdentity ? `explicit:${project.groupingIdentity}` : null) ?? derivePhysicalProjectKey(project) ?? scopedProjectKey(scopeProjectRef(project.environmentId, project.id)) ); @@ -152,7 +158,12 @@ function deriveLogicalProjectKey( export function deriveLogicalProjectKeyFromSettings( project: Pick< EnvironmentProject, - "environmentId" | "id" | "workspaceRoot" | "repositoryIdentity" | "isScratch" + | "environmentId" + | "id" + | "workspaceRoot" + | "repositoryIdentity" + | "isScratch" + | "groupingIdentity" >, settings: ProjectGroupingSettings, options?: { readonly groupScratchProjects?: boolean }, @@ -168,7 +179,12 @@ export function resolveScratchProjectScopeKey( projects: ReadonlyArray< Pick< EnvironmentProject, - "environmentId" | "id" | "workspaceRoot" | "repositoryIdentity" | "isScratch" + | "environmentId" + | "id" + | "workspaceRoot" + | "repositoryIdentity" + | "isScratch" + | "groupingIdentity" > >, settings: ProjectGroupingSettings = { @@ -188,10 +204,9 @@ export function resolveScratchProjectScopeKey( } return projects.some((project) => { if (!project.isScratch) return false; - const mode = resolveProjectGroupingMode(project, settings); return ( derivePhysicalProjectKey(project) === key || - (mode !== "separate" && deriveRepositoryScopedKey(project, mode) === key) + deriveLogicalProjectKeyFromSettings(project, settings) === key ); }) ? SCRATCH_PROJECT_SCOPE_KEY @@ -292,13 +307,13 @@ function selectProjectIdentitySource( projects: ReadonlyArray, winner: TProject, ): TProject { - if (winner.repositoryIdentity !== null) { + if (winner.repositoryIdentity != null) { return winner; } let freshestIdentifiedProject: TProject | null = null; for (const project of projects) { - if (project.repositoryIdentity === null) { + if (project.repositoryIdentity == null) { continue; } if ( @@ -335,10 +350,14 @@ export function buildProjectGroups(input: { shouldReplacePhysicalProjectWinner(current, candidate) ? candidate : current, ); const identitySource = selectProjectIdentitySource(physicalProjects, winner); - const logicalKey = deriveLogicalProjectKey(identitySource, { - groupingMode: resolveProjectGroupingMode(winner, input.settings), - groupScratchProjects: input.groupScratchProjects ?? false, - }); + // Saved metadata (including a clear) always comes from the newest physical record. + const logicalKey = deriveLogicalProjectKey( + { ...winner, repositoryIdentity: identitySource.repositoryIdentity }, + { + groupingMode: resolveProjectGroupingMode(winner, input.settings), + groupScratchProjects: input.groupScratchProjects ?? false, + }, + ); logicalKeyByPhysicalKey.set(physicalProjectKey, logicalKey); const member = { physicalProjectKey, project: winner }; const existing = groupedMembers.get(logicalKey); @@ -397,6 +416,22 @@ export function buildProjectGroups(input: { }); } +/** Physical duplicates must resolve through the group's winning saved metadata. */ +export function resolveProjectGroup( + project: EnvironmentProject, + projects: ReadonlyArray, + settings: ProjectGroupingSettings, +): ProjectGroup | undefined { + const groups = buildProjectGroups({ projects, settings }); + const physicalKey = derivePhysicalProjectKey(project); + return ( + groups.find((group) => + group.members.some((member) => member.physicalProjectKey === physicalKey), + ) ?? + groups.find((group) => group.key === deriveLogicalProjectKeyFromSettings(project, settings)) + ); +} + export function sortProjectsByGroupOrder< TProject extends { readonly id: string; readonly environmentId: string }, >( diff --git a/packages/client-runtime/src/state/shellReducer.test.ts b/packages/client-runtime/src/state/shellReducer.test.ts index f98a14c3ed32d..c0cae23271303 100644 --- a/packages/client-runtime/src/state/shellReducer.test.ts +++ b/packages/client-runtime/src/state/shellReducer.test.ts @@ -430,3 +430,23 @@ describe("applyShellStreamEvents", () => { ); }); }); + +describe("explicit project grouping metadata synchronization", () => { + it("applies clearing immediately and does not restore metadata from an enrichment refresh", () => { + const identified = { ...v2Project, groupingIdentity: "39c61a1b-8a64-43a6-9da5-65c15da93d52" }; + const snapshot = { ...v2ShellSnapshot, projects: [identified] }; + const cleared = applyShellStreamEvent(snapshot, { + kind: "project.updated", + sequence: 1, + project: { ...identified, groupingIdentity: null }, + }); + expect(cleared.projects[0]?.groupingIdentity).toBeNull(); + const enriched = mergeShellSnapshotProjects(cleared, snapshot, { + resolvedRepositoryIdentityRoots: [v2Project.workspaceRoot], + }); + expect(enriched.projects[0]?.groupingIdentity).toBeNull(); + expect(enriched.threads).toBe(snapshot.threads); + const refreshed = mergeShellSnapshotProjects(snapshot, cleared); + expect(refreshed.projects[0]?.groupingIdentity).toBeNull(); + }); +}); diff --git a/packages/contracts/src/applicationEvent.ts b/packages/contracts/src/applicationEvent.ts index 30150b9af5247..fc6195c69c9f7 100644 --- a/packages/contracts/src/applicationEvent.ts +++ b/packages/contracts/src/applicationEvent.ts @@ -14,7 +14,7 @@ import { import { RepositoryIdentity, ThreadEnvMode } from "./environment.ts"; import { ModelSelection } from "./modelSelection.ts"; import type { OrchestrationV2StoredEvent } from "./orchestrationV2.ts"; -import { ProjectScript, StoredProjectIcon } from "./project.ts"; +import { ProjectGroupingIdentity, ProjectScript, StoredProjectIcon } from "./project.ts"; export const OrchestrationClientOrigin = Schema.Struct({ surface: Schema.optional(ClientSurface), @@ -37,6 +37,7 @@ export const ApplicationProjectCreatedPayload = Schema.Struct({ projectId: ProjectId, title: TrimmedNonEmptyString, workspaceRoot: TrimmedNonEmptyString, + groupingIdentity: Schema.optional(Schema.NullOr(ProjectGroupingIdentity)), repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), defaultModelSelection: Schema.NullOr(ModelSelection), defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), @@ -52,6 +53,7 @@ export const ApplicationProjectMetaUpdatedPayload = Schema.Struct({ projectId: ProjectId, title: Schema.optional(TrimmedNonEmptyString), workspaceRoot: Schema.optional(TrimmedNonEmptyString), + groupingIdentity: Schema.optional(Schema.NullOr(ProjectGroupingIdentity)), repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), // Absent = leave unchanged; null = clear the override. diff --git a/packages/contracts/src/orchestrationProject.ts b/packages/contracts/src/orchestrationProject.ts index 35cee805b8812..e2d9f8ea3a03b 100644 --- a/packages/contracts/src/orchestrationProject.ts +++ b/packages/contracts/src/orchestrationProject.ts @@ -3,7 +3,7 @@ import * as Schema from "effect/Schema"; import { IsoDateTime, ProjectId, TrimmedNonEmptyString } from "./baseSchemas.ts"; import { RepositoryIdentity, ThreadEnvMode } from "./environment.ts"; import { ModelSelection } from "./modelSelection.ts"; -import { ProjectScript, ReceivedProjectIcon } from "./project.ts"; +import { ProjectGroupingIdentity, ProjectScript, ReceivedProjectIcon } from "./project.ts"; export const OrchestrationProjectShell = Schema.Struct({ id: ProjectId, @@ -12,6 +12,7 @@ export const OrchestrationProjectShell = Schema.Struct({ // repository name, so grouped labels need this to tell a chosen name apart. hasCustomTitle: Schema.optional(Schema.Boolean), workspaceRoot: TrimmedNonEmptyString, + groupingIdentity: Schema.optional(Schema.NullOr(ProjectGroupingIdentity)), repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), defaultModelSelection: Schema.NullOr(ModelSelection), defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), diff --git a/packages/contracts/src/project.test.ts b/packages/contracts/src/project.test.ts index c3da9f2fe95cf..3b049f095d06a 100644 --- a/packages/contracts/src/project.test.ts +++ b/packages/contracts/src/project.test.ts @@ -220,3 +220,20 @@ effectIt.effect("an icon kind from a newer server shows the default icon", () => assert.strictEqual(broken._tag, "Failure"); }), ); + +describe("explicit grouping identity contracts", () => { + it("accepts an explicit UUID, distinguishes clear from omission, and rejects names", () => { + const groupingIdentity = "39c61a1b-8a64-43a6-9da5-65c15da93d52"; + expect( + decodeProjectCreatePayload({ title: "sdk", workspaceRoot: "/sdk", groupingIdentity }) + .groupingIdentity, + ).toBe(groupingIdentity); + expect(decodeProjectUpdatePayload({ groupingIdentity: null })).toEqual({ + groupingIdentity: null, + }); + expect(decodeProjectUpdatePayload({})).not.toHaveProperty("groupingIdentity"); + for (const value of ["sdk", "group/sdk", "", "39c61a1b-8a64-13a6-9da5-65c15da93d52"]) { + expect(() => decodeProjectUpdatePayload({ groupingIdentity: value })).toThrow(); + } + }); +}); diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index 80848ba81f557..f4182aef325f7 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -151,10 +151,16 @@ export const ReceivedProjectIcon = ForwardCompatibleUnion(projectIconMembers, "k ), ); +/** Explicit cross-environment identity. Allocate once and share only with copies of this project. */ +export const ProjectGroupingIdentity = TrimmedNonEmptyString.check( + Schema.isPattern(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/), +); + export const Project = Schema.Struct({ id: ProjectId, title: TrimmedNonEmptyString, workspaceRoot: TrimmedNonEmptyString, + groupingIdentity: Schema.optional(Schema.NullOr(ProjectGroupingIdentity)), repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), projectIcon: Schema.optional(Schema.NullOr(ReceivedProjectIcon)), @@ -178,6 +184,7 @@ export const ProjectSnapshot = Schema.Struct({ export type ProjectSnapshot = typeof ProjectSnapshot.Type; export const ProjectCreatePayload = Schema.Struct({ + groupingIdentity: Schema.optional(Schema.NullOr(ProjectGroupingIdentity)), title: TrimmedNonEmptyString, workspaceRoot: TrimmedNonEmptyString, createWorkspaceRootIfMissing: Schema.optional(Schema.Boolean), @@ -187,6 +194,7 @@ export const ProjectCreatePayload = Schema.Struct({ export type ProjectCreatePayload = typeof ProjectCreatePayload.Type; export const ProjectUpdatePayload = Schema.Struct({ + groupingIdentity: Schema.optional(Schema.NullOr(ProjectGroupingIdentity)), title: Schema.optional(TrimmedNonEmptyString), workspaceRoot: Schema.optional(TrimmedNonEmptyString), defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), @@ -205,6 +213,21 @@ export const ProjectUpdatePayload = Schema.Struct({ export type ProjectUpdatePayload = typeof ProjectUpdatePayload.Type; export const ProjectMutation = Schema.Union([ + Schema.Struct({ + type: Schema.Literal("project.register"), + commandId: CommandId, + projectId: ProjectId, + workspaceRoot: Schema.String, + title: Schema.optional(Schema.String), + groupingIdentity: Schema.optional(ProjectGroupingIdentity), + }), + Schema.Struct({ + type: Schema.Literal("project.grouping"), + commandId: CommandId, + identifier: Schema.String, + cwd: Schema.String, + groupingIdentity: Schema.optional(Schema.NullOr(ProjectGroupingIdentity)), + }), Schema.Struct({ type: Schema.Literal("project.create"), commandId: CommandId, diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 1db913e38ba3b..d9a1ec8c87594 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -497,6 +497,24 @@ describe("requiredScopesForProjectMutation", () => { expect( requiredScopesForProjectMutation({ type: "project.update", commandId, projectId }), ).toEqual(["orchestration:operate"]); + expect( + requiredScopesForProjectMutation({ + type: "project.register", + commandId, + projectId, + workspaceRoot: "/workspace", + }), + ).toEqual(["orchestration:operate"]); + expect( + requiredScopesForProjectMutation({ + type: "project.grouping", + commandId, + identifier: projectId, + cwd: "/", + groupingIdentity: null, + }), + ).toEqual(["orchestration:operate"]); + expect( requiredScopesForProjectMutation({ type: "project.update", diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 7bbb03595d4bb..18acc7f32f63a 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -2022,7 +2022,7 @@ export function requiredScopesForServerSettingsPatch( export function requiredScopesForProjectMutation( mutation: ProjectMutation, ): ReadonlyArray { - if (mutation.type === "project.delete" || mutation.scripts === undefined) { + if (!("scripts" in mutation) || mutation.scripts === undefined) { return [AuthOrchestrationOperateScope]; } return [