Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 70 additions & 9 deletions apps/mobile/src/features/threads/ThreadRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,25 @@ import { makeTurnCommandMetadata } from "../../lib/commandMetadata";
import { buildProjectThreadStartTurnInput } from "../../lib/projectThreadStartTurn";
import { useWorktreeSetup } from "./use-worktree-setup";
import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup";
import {
isAtomCommandInterrupted,
squashAtomCommandFailure,
} from "@t3tools/client-runtime/state/runtime";
import { AppBar } from "../../components/ui/AppBar";
import {
StackActions,
useNavigation,
useRoute,
type StaticScreenProps,
} from "@react-navigation/native";
import { useEffect, useEffectEvent, useRef, useState, type ReactNode } from "react";
import {
useEffect,
useEffectEvent,
useLayoutEffect,
useRef,
useState,
type ReactNode,
} from "react";
import * as Option from "effect/Option";
import {
AuthOrchestrationOperateScope,
Expand All @@ -25,6 +36,7 @@ import {
} from "@t3tools/contracts";
import {
projectScriptCwd,
projectScriptReservesPort,
projectScriptRuntimeEnv,
projectScriptTerminalInput,
resolveProjectScripts,
Expand Down Expand Up @@ -83,6 +95,7 @@ import { resolveLatestMergeBackRun } from "@t3tools/client-runtime/state/thread-
import { threadEnvironment, useThreadFamily } from "../../state/threads";
import { EMPTY_ENVIRONMENT_THREAD_STATE } from "@t3tools/client-runtime/state/threads";
import { useProjects } from "../../state/entities";
import { projectEnvironment } from "../../state/projects";
import { projectThreadContentPresentation } from "./threadContentPresentation";
import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider";
import {
Expand Down Expand Up @@ -385,6 +398,7 @@ function ThreadRouteContent(
const gitActions = useSelectedThreadGitActions();
const requests = useSelectedThreadRequests();
const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt");
const runScript = useAtomCommand(projectEnvironment.runScript, { reportFailure: false });
const resumeThreadQueue = useAtomCommand(threadEnvironment.resumeThreadQueue, "resume queue");
const loadEarlierHistory = useAtomCommand(threadEnvironment.loadEarlierHistory, {
label: "load earlier thread history",
Expand Down Expand Up @@ -456,6 +470,14 @@ function ThreadRouteContent(
const threadId = firstRouteParam(params.threadId);
const routeThreadIdentity =
environmentIdRaw !== null && threadId !== null ? `${environmentIdRaw}:${threadId}` : null;
const currentRouteThreadIdentityRef = useRef<string | null>(null);
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- publishes the committed route identity to a ref that pending action results check before opening
useLayoutEffect(() => {
currentRouteThreadIdentityRef.current = routeThreadIdentity;
return () => {
currentRouteThreadIdentityRef.current = null;
};
}, [routeThreadIdentity]);
// The chosen mode survives thread switches; "route" only applies while the route
// supplies an inspector, which is also the default whenever it does.
const [chosenInspectorMode, setChosenInspectorMode] = useState<ThreadInspectorMode | null>(null);
Expand Down Expand Up @@ -744,6 +766,53 @@ function ThreadRouteContent(
return;
}

const preferredWorktreePath = resolvePreferredThreadWorktreePath({
threadShellWorktreePath: selectedThread.worktreePath ?? null,
threadDetailWorktreePath: selectedThreadDetailWorktreePath,
});
const cwd = projectScriptCwd({
project: { cwd: selectedThreadProject.workspaceRoot },
worktreePath: preferredWorktreePath,
});
if (projectScriptReservesPort(script)) {
const result = await runScript({
environmentId: selectedThread.environmentId,
input: {
threadId: selectedThread.id,
projectId: selectedThreadProject.id,
worktreePath: preferredWorktreePath,
scriptId: script.id,
},
});
if (!navigation.isFocused() || currentRouteThreadIdentityRef.current !== routeThreadIdentity)
return;
if (result._tag !== "Success") {
if (!isAtomCommandInterrupted(result)) {
const error = squashAtomCommandFailure(result);
Dialog.alert(
"Couldn't run script",
error instanceof Error ? error.message : String(error),
);
}
return;
}
if (result.value.status === "no-script") {
Dialog.alert(
"Couldn't run script",
`"${script.name}" is no longer available. Check the action and try again.`,
);
return;
}
if (result.value.threadId === selectedThread.id) {
handleOpenTerminal(result.value.terminalId);
} else {
Dialog.alert(
"Already running",
`${script.name} is running in another thread for this checkout.`,
);
}
return;
}
const targetTerminalId = resolveProjectScriptTerminalId({
existingTerminalIds: terminalMenuSessions.map((session) => session.terminalId),
...(knownTerminalSessions === null ||
Expand All @@ -754,14 +823,6 @@ function ThreadRouteContent(
(session) => session.status === "running" || session.status === "starting",
),
});
const preferredWorktreePath = resolvePreferredThreadWorktreePath({
threadShellWorktreePath: selectedThread.worktreePath ?? null,
threadDetailWorktreePath: selectedThreadDetailWorktreePath,
});
const cwd = projectScriptCwd({
project: { cwd: selectedThreadProject.workspaceRoot },
worktreePath: preferredWorktreePath,
});
const env = projectScriptRuntimeEnv({
project: { cwd: selectedThreadProject.workspaceRoot },
worktreePath: preferredWorktreePath,
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/auth/RpcAuthorization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ describe("RPC authorization scopes", () => {
WS_METHODS.terminalClear,
WS_METHODS.terminalClose,
WS_METHODS.previewStopLocalServer,
WS_METHODS.projectsRunScript,
WS_METHODS.projectsGetScriptPorts,
]) {
expect(requiredScopeForRpcMethod(method)).toBe(AuthTerminalOperateScope);
}
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,8 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.projectsReadFile]: AuthFilesystemReadScope,
[WS_METHODS.projectsSearchContents]: AuthFilesystemReadScope,
[WS_METHODS.projectsSearchEntries]: AuthFilesystemReadScope,
[WS_METHODS.projectsGetScriptPorts]: AuthTerminalOperateScope,
[WS_METHODS.projectsRunScript]: AuthTerminalOperateScope,
[WS_METHODS.projectsWriteFile]: AuthFilesystemWriteScope,
[WS_METHODS.projectsEnsureScratch]: AuthOrchestrationOperateScope,
[WS_METHODS.projectsCreateNew]: AuthOrchestrationOperateScope,
Expand Down
8 changes: 5 additions & 3 deletions apps/server/src/git/GitManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -590,7 +590,10 @@ function makeManager(input?: {
sourceControlProvider?: SourceControlProvider["Service"];
textGeneration?: Partial<FakeGitTextGeneration>;
serverSettings?: Parameters<typeof ServerSettings.layerTest>[0];
setupScriptRunner?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"];
setupScriptRunner?: Pick<
ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"],
"runForThread"
>;
gitConfigReads?: string[];
gitCommands?: string[];
gitSpawnGate?: {
Expand Down Expand Up @@ -698,8 +701,7 @@ function makeManager(input?: {
Layer.mock(ProviderRegistry.ProviderRegistry)({
getProviders: Effect.succeed([]),
}),
Layer.succeed(
ProjectSetupScriptRunner.ProjectSetupScriptRunner,
Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)(
input?.setupScriptRunner ?? {
runForThread: () => Effect.succeed({ status: "no-script" as const }),
},
Expand Down
84 changes: 81 additions & 3 deletions apps/server/src/mcp/WorktreeMcpService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
type OrchestrationV2ThreadProjection,
type Project,
ProjectId,
ProjectScriptPortUnavailableError,
type ProjectScript,
ProviderInstanceId,
ThreadId,
WorktreeMcpHandoffInput,
Expand All @@ -29,6 +31,7 @@ import {
} from "../orchestration-v2/Orchestrator.ts";
import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
import * as ProjectService from "../project/ProjectService.ts";
import * as ProjectScriptPorts from "../project/ProjectScriptPorts.ts";
import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts";
import * as ServerSettings from "../serverSettings.ts";
import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts";
Expand Down Expand Up @@ -95,6 +98,9 @@ interface HarnessOptions {
readonly currentBranch?: string | null;
readonly notARepo?: boolean;
readonly newWorktreesStartFromOrigin?: boolean;
readonly scripts?: ReadonlyArray<ProjectScript>;
readonly settings?: Parameters<typeof ServerSettings.layerTest>[0];
readonly previewPortFails?: boolean;
readonly setupScript?: "started" | "no-script" | "fails" | "dies";
readonly dispatchFails?: boolean;
readonly dispatchDies?: boolean;
Expand Down Expand Up @@ -196,7 +202,7 @@ const makeHarness = (options: HarnessOptions = {}) => {
? (Effect.fail("simulated project read failure") as never)
: Effect.succeed(
id === projectId && options.projectMissing !== true
? Option.some(project)
? Option.some({ ...project, scripts: options.scripts ?? project.scripts })
: Option.none(),
),
);
Expand Down Expand Up @@ -262,7 +268,7 @@ const makeHarness = (options: HarnessOptions = {}) => {
}),
);
const refreshStatus = vi.fn((_: string) => Effect.die("refreshStatus stub"));
const runForThread = vi.fn((input: { readonly worktreePath: string }) => {
const runForThread = vi.fn((input: { readonly worktreePath: string | null }) => {
switch (options.setupScript ?? "started") {
case "no-script":
return Effect.succeed({ status: "no-script" } as const);
Expand All @@ -283,11 +289,16 @@ const makeHarness = (options: HarnessOptions = {}) => {
scriptName: "Setup",
scriptCommand: "vp install",
terminalId: "setup-terminal",
cwd: input.worktreePath,
cwd: input.worktreePath ?? workspaceRoot,
} as const);
}
});

const reserve = vi.fn((_input: { readonly cwd: string; readonly scriptId: string }) =>
options.previewPortFails
? Effect.fail(new ProjectScriptPortUnavailableError({ startPort: 20000, attempts: 10000 }))
: Effect.succeed(43123),
);
const layer = WorktreeMcpService.layer.pipe(
Layer.provide(
Layer.mergeAll(
Expand All @@ -301,7 +312,9 @@ const makeHarness = (options: HarnessOptions = {}) => {
} satisfies Partial<ProjectService.ProjectService["Service"]>),
ServerSettings.layerTest({
newWorktreesStartFromOrigin: options.newWorktreesStartFromOrigin ?? false,
...options.settings,
}),
Layer.mock(ProjectScriptPorts.ProjectScriptPorts)({ reserve }),
Layer.mock(GitWorkflowService.GitWorkflowService)({
listRefs,
listLocalBranchNames,
Expand Down Expand Up @@ -334,6 +347,7 @@ const makeHarness = (options: HarnessOptions = {}) => {
deleteLocalBranch,
localStatus,
runForThread,
reserve,
};
};

Expand Down Expand Up @@ -671,6 +685,70 @@ describe("t3_worktree_handoff", () => {
});

describe("t3_worktree_status", () => {
it.effect("WorktreeStatus-WithEffectivePreviewScripts-ReservesPortsInTheThreadWorkspace", () =>
Effect.gen(function* () {
const previewScript: ProjectScript = {
id: "preview",
name: "Preview",
command: "vp dev",
icon: "play",
runOnWorktreeCreate: false,
previewUrl: "http://localhost:${T3CODE_PREVIEW_PORT}/preview",
};
const effectiveScripts = [
previewScript,
{ ...previewScript, id: "fixed", previewUrl: "http://localhost:5173" },
{ ...previewScript, id: "no-preview", previewUrl: undefined },
];
for (const worktreePath of [null, "/worktrees/project/existing"]) {
const harness = makeHarness({
thread: { worktreePath },
scripts: [{ ...previewScript, id: "project-script" }],
settings: worktreePath
? {
projectSettingsOverrides: {
[projectId]: { defaultProjectScripts: effectiveScripts },
},
}
: {
defaultProjectScripts: effectiveScripts,
projectSettingsFolded: true,
},
});
const result = yield* runStatus(harness);
expect(result.scriptPreviews).toEqual([
{
scriptId: "preview",
name: "Preview",
port: 43123,
url: "http://localhost:43123/preview",
},
]);
expect(harness.reserve.mock.calls).toEqual([
[
{
cwd: worktreePath ?? workspaceRoot,
scriptId: "preview",
},
],
]);
expect(harness.runForThread).not.toHaveBeenCalled();
}
const unavailable = makeHarness({
thread: { worktreePath: "/worktrees/project/existing", branch: "feature/existing" },
scripts: [previewScript],
previewPortFails: true,
});
expect(yield* runStatus(unavailable)).toMatchObject({
attached: true,
worktreePath: "/worktrees/project/existing",
branch: "feature/existing",
projectWorkspaceRoot: workspaceRoot,
scriptPreviews: [],
});
}),
);

it.effect("reports an attached thread's worktree and branch", () => {
const harness = makeHarness({
thread: {
Expand Down
Loading
Loading