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 0b7ecf7900c2b..1323ce2bb8d74 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 @@ -63,7 +63,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("./use-legacy-plan-mode-enabled", () => ({ useLegacyPlanModeState: () => ({ enabled: false, loaded: true }), @@ -132,7 +135,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, @@ -288,9 +296,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 3876fd2f83dbb..78b82a706955c 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -16,7 +16,6 @@ import { CommandId, DEFAULT_PROVIDER_INTERACTION_MODE, MessageId, - repositoryGroupingKeyOf, ThreadId, } from "@t3tools/contracts"; import { threadContextsOutsideEnvironment } from "@t3tools/shared/composerContextReferences"; @@ -93,7 +92,10 @@ import { resolveNewTaskBranchWorktreePath, resolveNewTaskLocalWorkspaceSelection, } from "./new-task-context-presentation"; -import { resolveEnvironmentProjectMatch } from "./new-task-project-selection"; +import { + 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"; @@ -589,12 +591,16 @@ export function NewTaskFlowProvider( const match = resolveEnvironmentProjectMatch( projects.filter((project) => project.environmentId === environmentId), selectedProject, + groupingSettings, + projects, ); + if (!match) return false; updateFlow({ - draftKey: match ? carryDraftContentTo(match) : readFlow().draftKey, + draftKey: carryDraftContentTo(match), environmentId, - projectKey: match ? scopedProjectKey(match.environmentId, match.id) : null, + projectKey: scopedProjectKey(match.environmentId, match.id), }); + return true; }; const openScratch = useAtomCommand(projectEnvironment.openScratch, { reportFailure: false }); @@ -609,8 +615,7 @@ export function NewTaskFlowProvider( return true; } if (!isScratchDraft) { - selectEnvironment(environmentId); - return true; + return selectEnvironment(environmentId); } const request = {}; latestSwitchRef.current = request; @@ -799,12 +804,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) => ({ @@ -812,44 +811,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 99cf9e7d37ef4..205413cb73a11 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 @@ -9,6 +9,7 @@ import { resolveDraftProjectSelection, resolveEnvironmentProjectMatch, resolveNewTaskScratchOption, + resolveNewTaskEnvironments, } from "./new-task-project-selection"; function makeProject( @@ -87,34 +88,114 @@ describe("resolveEnvironmentProjectMatch", () => { expect(resolveEnvironmentProjectMatch(target, selected)).toBe(target[1]); }); - 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]); - - 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( + 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" }); + expect( + resolveEnvironmentProjectMatch( + [makeProject("sdk", "server", { repositoryKey: "github.com/other/sdk" })], + selected, + ), + ).toBeNull(); + 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); + 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 93c8f1d5f51d6..eb80cddd1751c 100644 --- a/apps/mobile/src/features/threads/new-task-project-selection.ts +++ b/apps/mobile/src/features/threads/new-task-project-selection.ts @@ -1,7 +1,12 @@ 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"; +import { type EnvironmentId, type ServerConfig } from "@t3tools/contracts"; import { scopedProjectKey } from "../../lib/scopedEntities"; import type { HomeProjectScope } from "../home/homeThreadList"; @@ -78,45 +83,41 @@ 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 ); } +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 9367ce7bdf1fe..d0d8abea0a2d7 100644 --- a/apps/server/src/cli/project.test.ts +++ b/apps/server/src/cli/project.test.ts @@ -26,6 +26,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"; @@ -462,3 +463,118 @@ it.layer(NodeServices.layer)("project lookup with unavailable workspaces", (it) }), ); }); + +it.effect( + "assigns grouping through add and can inspect, change, and clear it while preserving threads", + () => + Effect.gen(function* () { + const { baseDir, workspaceRoot, project } = yield* makeProjectLookupFixture(); + const threadId = ThreadId.make("grouping-preserved-thread"); + yield* seedNativeThreads(baseDir, [{ id: threadId, projectId: project.id, archived: false }]); + const before = yield* readNativeThreadState(baseDir, threadId); + const identity = "39c61a1b-8a64-43a6-9da5-65c15da93d52"; + for (let attempt = 0; attempt < 2; attempt++) { + yield* runCli([ + "project", + "add", + workspaceRoot, + "--title", + "Ignored for existing", + "--grouping-identity", + identity, + "--base-dir", + baseDir, + ]); + } + const assigned = (yield* readProjects(baseDir)).projects; + assert.equal(assigned.length, 1); + assert.equal(assigned[0]?.id, project.id); + assert.equal(assigned[0]?.title, project.title); + assert.equal(assigned[0]?.groupingIdentity, identity); + 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); + yield* runCli([ + "project", + "grouping", + workspaceRoot, + "--identity", + "e703c118-3547-41b1-88c5-9774cf40ae86", + "--base-dir", + baseDir, + ]); + assert.equal( + (yield* readProjects(baseDir)).projects[0]?.groupingIdentity, + "e703c118-3547-41b1-88c5-9774cf40ae86", + ); + yield* runCli(["project", "grouping", workspaceRoot, "--clear", "--base-dir", baseDir]); + assert.isNull((yield* readProjects(baseDir)).projects[0]?.groupingIdentity); + const invalid = yield* runCli([ + "project", + "grouping", + workspaceRoot, + "--identity", + "group/sdk", + "--base-dir", + baseDir, + ]).pipe(Effect.result); + assert.equal(invalid._tag, "Failure"); + if (invalid._tag === "Failure") { + assert.equal(invalid.failure.message, "Grouping identity must be a lowercase UUID v4."); + } + assert.isNull((yield* readProjects(baseDir)).projects[0]?.groupingIdentity); + assert.deepEqual(yield* readNativeThreadState(baseDir, threadId), before); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("rejects malformed grouping flags before opening project state", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-invalid-grouping-cli-" }); + const baseDir = NodePath.join(root, "unopened-state"); + for (const command of [ + ["add", root, "--grouping-identity", "group/sdk"], + ["grouping", root, "--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.isFalse(yield* fs.exists(baseDir)); + } + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); + +it.effect("malformed grouping flags cannot create or change project registrations", () => + Effect.gen(function* () { + const { baseDir, workspaceRoot } = yield* makeProjectLookupFixture(); + const before = yield* readProjects(baseDir); + const fs = yield* FileSystem.FileSystem; + 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); + } + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/cli/project.ts b/apps/server/src/cli/project.ts index d1c8c8a4959ae..50ff1dcd6e9c5 100644 --- a/apps/server/src/cli/project.ts +++ b/apps/server/src/cli/project.ts @@ -3,6 +3,8 @@ import { CommandId, EnvironmentHttpApi, EnvironmentHttpCommonError, + Project, + ProjectGroupingIdentity, type ProjectMutation, type ProjectSnapshot, ProjectId, @@ -55,6 +57,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", { @@ -463,45 +485,77 @@ 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* ({ + snapshot, + dispatch, + }: { + readonly snapshot: ProjectSnapshot; + readonly dispatch: ( + command: ProjectCliDispatchCommand, + ) => Effect.Effect< + void, + Error, + FileSystem.FileSystem | HttpClient.HttpClient | Path.Path + >; + }) { + const workspaceRoot = yield* normalizeWorkspaceRootForProjectCommand(flags.workspaceRoot); + const existingProject = snapshot.projects.find( + (project) => project.deletedAt === null && project.workspaceRoot === workspaceRoot, + ); + if (existingProject && groupingIdentity !== undefined) { + if (existingProject.groupingIdentity === groupingIdentity) + return `Project ${existingProject.id} already has this grouping identity.`; + yield* dispatch({ + type: "project.update", + commandId: CommandId.make(yield* projectCommandUuid), + projectId: existingProject.id, + groupingIdentity, + }); + return `Updated grouping identity for project ${existingProject.id}.`; + } + if (existingProject) { + return yield* new ProjectAlreadyExistsError({ + operation: "addProject", + projectId: existingProject.id, + workspaceRoot, + }); + } + + 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, + ...(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 `Added project ${projectId} (${title}) at ${workspaceRoot}.`; + }), + ); + }), ), ); @@ -585,7 +639,79 @@ 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* ({ snapshot, dispatch }) { + const project = yield* findActiveProjectTarget({ snapshot, identifier: flags.project }); + if (groupingIdentity === undefined) { + return yield* encodeGroupingResult({ + projectId: project.id, + groupingIdentity: + snapshot.projects.find((entry) => entry.id === project.id)?.groupingIdentity ?? + null, + }); + } + yield* dispatch({ + type: "project.update", + commandId: CommandId.make(yield* projectCommandUuid), + projectId: project.id, + groupingIdentity, + }); + return yield* encodeGroupingResult({ projectId: project.id, groupingIdentity }); + }), + ); + }), + ), +); + 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 59399c7febdea..642e6db94dcc9 100644 --- a/apps/server/src/mcp/toolkits/project/handlers.ts +++ b/apps/server/src/mcp/toolkits/project/handlers.ts @@ -313,6 +313,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 @@ -320,7 +321,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.", }); 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 bd7c986f4c48a..9034f005fc2a5 100644 --- a/apps/server/src/mcp/toolkits/project/tools.ts +++ b/apps/server/src/mcp/toolkits/project/tools.ts @@ -65,7 +65,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, @@ -76,7 +76,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, @@ -87,7 +87,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, @@ -107,7 +108,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 96249ece4c106..fc8b693fcde04 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, autoPull: 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 2be0a765c784f..d2a2a7004453e 100644 --- a/apps/server/src/orchestration-v2/ProjectStore.ts +++ b/apps/server/src/orchestration-v2/ProjectStore.ts @@ -33,6 +33,7 @@ export const ProjectRow = Schema.Struct({ projectId: ProjectId, title: Schema.String, workspaceRoot: Schema.String, + groupingIdentity: Schema.NullOr(Schema.String), defaultModelSelection: Schema.NullOr(ModelSelection), defaultThreadEnvMode: Schema.NullOr(ThreadEnvMode), autoPull: Schema.Boolean, @@ -60,6 +61,7 @@ function toShell(row: ProjectRow): OrchestrationProjectShell { id: row.projectId, title: row.title, workspaceRoot: row.workspaceRoot, + groupingIdentity: row.groupingIdentity, repositoryIdentity: null, defaultModelSelection: row.defaultModelSelection, defaultThreadEnvMode: row.defaultThreadEnvMode, @@ -118,6 +120,7 @@ export const make = Effect.gen(function* () { project_id AS "projectId", title, workspace_root AS "workspaceRoot", + grouping_identity AS "groupingIdentity", default_model_selection_json AS "defaultModelSelection", default_thread_env_mode AS "defaultThreadEnvMode", auto_pull AS "autoPull", @@ -150,6 +153,7 @@ export const make = Effect.gen(function* () { project_id, title, workspace_root, + grouping_identity, default_model_selection_json, default_thread_env_mode, auto_pull, @@ -166,6 +170,7 @@ export const make = Effect.gen(function* () { ${encoded.projectId}, ${encoded.title}, ${encoded.workspaceRoot}, + ${encoded.groupingIdentity}, ${encoded.defaultModelSelection}, ${encoded.defaultThreadEnvMode}, ${encoded.autoPull}, @@ -182,6 +187,7 @@ export const make = Effect.gen(function* () { DO UPDATE SET title = excluded.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, @@ -232,6 +238,7 @@ export const make = Effect.gen(function* () { projectId: payload.projectId, title: payload.title, workspaceRoot: payload.workspaceRoot, + groupingIdentity: payload.groupingIdentity ?? null, defaultModelSelection: payload.defaultModelSelection, defaultThreadEnvMode: payload.defaultThreadEnvMode ?? null, autoPull: false, @@ -258,6 +265,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 }), ...(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 21647ef49bf28..7a54f9d47a130 100644 --- a/apps/server/src/orchestration-v2/RuntimePolicy.test.ts +++ b/apps/server/src/orchestration-v2/RuntimePolicy.test.ts @@ -94,6 +94,7 @@ const layerWithPolicy = (policy?: ManagedPolicySummary) => projectId, title: "Project", workspaceRoot: "/project-root", + groupingIdentity: null, defaultModelSelection: null, defaultThreadEnvMode: null, autoPull: false, diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index 06438082acddf..6c5f58454d7c1 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -244,6 +244,7 @@ function makeHarness(options: HarnessOptions = {}) { projectId, title: project.title, workspaceRoot: project.workspaceRoot, + groupingIdentity: null, defaultModelSelection: project.defaultModelSelection, defaultThreadEnvMode: null, autoPull: false, diff --git a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts index 8f7de819f5718..f4df30430a6d4 100644 --- a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts @@ -76,6 +76,7 @@ function makeHarness( projectId, title: "Project", workspaceRoot: "/repo", + groupingIdentity: null, defaultModelSelection: modelSelection, defaultThreadEnvMode: null, autoPull: false, diff --git a/apps/server/src/persistence/Migrations.test.ts b/apps/server/src/persistence/Migrations.test.ts index 54e4a711aa8a1..0cd32b3d4f0c9 100644 --- a/apps/server/src/persistence/Migrations.test.ts +++ b/apps/server/src/persistence/Migrations.test.ts @@ -29,6 +29,45 @@ const v2TableCount = Effect.gen(function* () { }); describe("runMigrations", () => { + it.effect("adds explicit grouping after project pinning and archiving", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 76 }); + const before = yield* history; + yield* sql` + INSERT INTO projection_projects + (project_id, title, workspace_root, scripts_json, pinned_at, archived_at, created_at, updated_at) + VALUES ('sdk-project', 'SDK', '/work/sdk', '[]', '2026-10-27', '2026-10-28', '2026-10-27', '2026-10-28') + `; + + assert.deepStrictEqual(yield* runMigrations(), [[77, "ProjectGroupingIdentity"]]); + assert.deepStrictEqual(yield* history, [...before, [77, "ProjectGroupingIdentity"]]); + assert.deepStrictEqual( + yield* sql`SELECT project_id, title, workspace_root, pinned_at, archived_at, grouping_identity FROM projection_projects`, + [ + { + project_id: "sdk-project", + title: "SDK", + 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 e13c2b449d9e7..8ef03c30480e7 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -79,6 +79,7 @@ import Migration0073 from "./Migrations/073_ActiveTurnItemIndexes.ts"; import Migration0074 from "./Migrations/074_ExcludeAttachmentPlaceholdersFromSearch.ts"; import Migration0075 from "./Migrations/075_SubagentDeliveryState.ts"; import Migration0076 from "./Migrations/076_ProjectionProjectsPinArchive.ts"; +import Migration0077 from "./Migrations/077_ProjectGroupingIdentity.ts"; const migrationEntries = [ [1, "OrchestrationEvents", Migration0001], @@ -158,6 +159,7 @@ const migrationEntries = [ [74, "ExcludeAttachmentPlaceholdersFromSearch", Migration0074], [75, "SubagentDeliveryState", Migration0075], [76, "ProjectionProjectsPinArchive", Migration0076], + [77, "ProjectGroupingIdentity", Migration0077], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/077_ProjectGroupingIdentity.ts b/apps/server/src/persistence/Migrations/077_ProjectGroupingIdentity.ts new file mode 100644 index 0000000000000..c32e41e32d0e8 --- /dev/null +++ b/apps/server/src/persistence/Migrations/077_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 08c5094bf49c8..06baceb2abd33 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -56,6 +56,7 @@ describe("V2 preview upgrade", () => { [74, "ExcludeAttachmentPlaceholdersFromSearch"], [75, "SubagentDeliveryState"], [76, "ProjectionProjectsPinArchive"], + [77, "ProjectGroupingIdentity"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -155,6 +156,7 @@ describe("V2 preview upgrade", () => { [74, "ExcludeAttachmentPlaceholdersFromSearch"], [75, "SubagentDeliveryState"], [76, "ProjectionProjectsPinArchive"], + [77, "ProjectGroupingIdentity"], ]); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); diff --git a/apps/server/src/project/ProjectMutation.ts b/apps/server/src/project/ProjectMutation.ts index 550738b24baae..a84e4caa88770 100644 --- a/apps/server/src/project/ProjectMutation.ts +++ b/apps/server/src/project/ProjectMutation.ts @@ -23,6 +23,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 +44,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 331332a84bc95..6d1e2ed7b8451 100644 --- a/apps/server/src/project/ProjectService.test.ts +++ b/apps/server/src/project/ProjectService.test.ts @@ -738,3 +738,62 @@ 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.create({ + commandId: CommandId.make("group-create"), + projectId, + title: "sdk", + workspaceRoot: "/groups/sdk", + groupingIdentity: identity, + }); + assert.equal(created.groupingIdentity, identity); + 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); + const changed = yield* service.update({ + commandId: CommandId.make("group-change"), + projectId, + groupingIdentity: "e703c118-3547-41b1-88c5-9774cf40ae86", + }); + assert.equal(changed.groupingIdentity, "e703c118-3547-41b1-88c5-9774cf40ae86"); + const cleared = yield* service.update({ + commandId: CommandId.make("group-clear"), + projectId, + groupingIdentity: null, + }); + assert.isNull(cleared.groupingIdentity); + 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); + }), + ); +}); diff --git a/apps/server/src/project/ProjectService.ts b/apps/server/src/project/ProjectService.ts index ee7318fb5d5b7..226e16856a2a0 100644 --- a/apps/server/src/project/ProjectService.ts +++ b/apps/server/src/project/ProjectService.ts @@ -172,6 +172,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, @@ -351,6 +352,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]); @@ -387,6 +391,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 }), diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index 1719d3ca6bd19..1d19191f9a0f4 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5153, "codexAdditionalContextBytes": 6883, - "mcpToolListJsonBytes": 47538 + "mcpToolListJsonBytes": 47536 } 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 ddecdc40fafc1..0e51278758ad0 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -249,3 +249,19 @@ 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. + +Generate a lowercase UUID v4 once (for example, `python -c "import uuid; print(uuid.uuid4())"`) and reuse it only for copies of that project: + +```sh +t3 project add /path/to/sdk --grouping-identity +t3 project grouping /path/to/sdk +t3 project grouping /path/to/sdk --identity +t3 project grouping /path/to/sdk --clear +t3 project list +``` + +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..d677ad7290149 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}, "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 69de4f0c6c4b2..d9ecd768a9d5b 100644 --- a/evals/mcp-tool-use/run.test.ts +++ b/evals/mcp-tool-use/run.test.ts @@ -30,6 +30,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" } }, @@ -161,6 +163,97 @@ 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 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("bounds numbers and reads target.options in either schema form", () => { @@ -182,6 +275,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 = { @@ -426,12 +541,11 @@ it("stops a sample only once a must-not check has failed", () => { assert.isTrue(failedEarly({ textMustNot: ["paste"] }, run("Bash", "paste it here"))); }); -const decodeDeviceCase = Schema.decodeSync(Schema.fromJsonString(McpToolUseCase)); 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* () { diff --git a/evals/mcp-tool-use/run.ts b/evals/mcp-tool-use/run.ts index b407b147b9be2..4305c23e667da 100644 --- a/evals/mcp-tool-use/run.ts +++ b/evals/mcp-tool-use/run.ts @@ -148,6 +148,8 @@ 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)), }); type McpToolUseCall = typeof McpToolUseCall.Type; @@ -1457,8 +1459,15 @@ export const gradeSample = (expect: McpToolUseCase["expect"], run: SampleRun) => const calls = run.calls.map((call) => ({ ...call, input: withOptionsObject(call.input) })); const count = (tool: string) => calls.filter((call) => call.tool === tool).length; // `tool` may list alternatives, as in "link_pull_request|watch_pull_request". - const found = ({ tool, args = {} }: McpToolUseCall) => - calls.some((call) => tool.split("|").includes(call.tool) && matches(args, call.input)); + const found = ({ tool, args = {}, allowedArgs }: McpToolUseCall) => + calls.some( + (call) => + tool.split("|").includes(call.tool) && + matches(args, call.input) && + (allowedArgs === undefined || + (isRecord(call.input) && + Object.keys(call.input).every((key) => allowedArgs.includes(key)))), + ); const label = ({ tool, args }: McpToolUseCall) => (args ? `${tool} ${valueText(args)}` : tool); for (const call of expect.calls ?? []) add(`calls ${label(call)}`, found(call)); for (const call of expect.callsMustNot ?? []) add(`callsMustNot ${label(call)}`, !found(call)); diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index e3cbecfc2b2c4..f695c5b91de16 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -47,6 +47,7 @@ export interface CreateProjectInput extends CommandMetadata { readonly workspaceRoot: string; readonly createWorkspaceRootIfMissing?: boolean; readonly defaultModelSelection?: ModelSelection | null; + readonly groupingIdentity?: string | null; readonly scripts?: ReadonlyArray; } @@ -59,6 +60,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; @@ -318,6 +320,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; } | { @@ -327,6 +330,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; } | { @@ -354,6 +358,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 }), }); }); @@ -376,6 +381,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 a5957d06e1b01..482add12fc1e8 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) => { @@ -421,3 +448,92 @@ describe("sortProjectsByGroupOrder", () => { expect(sorted.map((entry) => entry.environmentId)).toEqual([remote, environmentId]); }); }); + +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 46fdc4e3dd3ae..18ac74a649a8b 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 @@ -276,13 +291,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 ( @@ -319,10 +334,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); @@ -380,6 +399,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 3fbd9b1b8d3af..97374519695d2 100644 --- a/packages/client-runtime/src/state/shellReducer.test.ts +++ b/packages/client-runtime/src/state/shellReducer.test.ts @@ -421,3 +421,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 d98c50e56e8d1..57ce444607700 100644 --- a/packages/contracts/src/orchestrationProject.ts +++ b/packages/contracts/src/orchestrationProject.ts @@ -3,12 +3,13 @@ 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, 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)), diff --git a/packages/contracts/src/project.test.ts b/packages/contracts/src/project.test.ts index 168886ebe441a..9e05ac2880bc1 100644 --- a/packages/contracts/src/project.test.ts +++ b/packages/contracts/src/project.test.ts @@ -177,3 +177,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 39696c62e4c99..bbe1eb9d37cb5 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -149,10 +149,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)), @@ -176,6 +182,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), @@ -185,6 +192,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)),