Skip to content
Draft
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
7 changes: 1 addition & 6 deletions apps/mobile/src/features/threads/ThreadRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import * as Arr from "effect/Array";
import * as Option from "effect/Option";
import { pipe } from "effect/Function";
import { EnvironmentId, type ProjectScript } from "@t3tools/contracts";
import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
import { projectScriptCwd } from "@t3tools/shared/projectScripts";
import { Pressable, ScrollView, Text as RNText, View, useColorScheme } from "react-native";
import { useThemeColor } from "../../lib/useThemeColor";
import { useVcsStatus } from "../../state/use-vcs-status";
Expand Down Expand Up @@ -198,10 +198,6 @@ export function ThreadRouteScreen() {
project: { cwd: selectedThreadProject.workspaceRoot },
worktreePath: preferredWorktreePath,
});
const env = projectScriptRuntimeEnv({
project: { cwd: selectedThreadProject.workspaceRoot },
worktreePath: preferredWorktreePath,
});
stagePendingTerminalLaunch({
target: {
environmentId: selectedThread.environmentId,
Expand All @@ -211,7 +207,6 @@ export function ThreadRouteScreen() {
launch: {
cwd,
worktreePath: preferredWorktreePath,
env,
initialInput: `${script.command}\r`,
},
});
Expand Down
3 changes: 1 addition & 2 deletions apps/mobile/src/state/use-terminal-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import {
} from "@t3tools/client-runtime";
import type {
EnvironmentId,
TerminalAttachInput,
TerminalAttachStreamEvent,
TerminalMetadataStreamEvent,
TerminalSessionSnapshot,
Expand Down Expand Up @@ -42,7 +41,7 @@ export function subscribeTerminalMetadata(input: {
export function attachTerminalSession(input: {
readonly environmentId: EnvironmentId;
readonly client: Parameters<typeof terminalSessionManager.attach>[0]["client"];
readonly terminal: TerminalAttachInput;
readonly terminal: Parameters<typeof terminalSessionManager.attach>[0]["terminal"];
readonly onSnapshot?: (snapshot: TerminalSessionSnapshot) => void;
readonly onEvent?: (event: TerminalAttachStreamEvent) => void;
}) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { makeAdapterRegistryMock } from "../src/provider/testUtils/providerAdapt
import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts";
import { makeProviderRegistryLayer } from "../src/provider/testUtils/providerRegistryMock.ts";
import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts";
import { LaunchEnvLive } from "../src/launchEnv/Services/LaunchEnv.ts";
import { ServerSettingsService } from "../src/serverSettings.ts";
import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts";
import { makeCodexAdapter } from "../src/provider/Layers/CodexAdapter.ts";
Expand Down Expand Up @@ -374,14 +375,22 @@ export const makeOrchestrationIntegrationHarness = (
}),
),
);
const serverConfigLayer = ServerConfig.layerTest(workspaceDir, rootDir);
const serverConfigLayer = ServerConfig.layerTest(workspaceDir, rootDir);
const layer = Layer.empty.pipe(
Layer.provideMerge(runtimeServicesLayer),
Layer.provideMerge(orchestrationReactorLayer),
Layer.provideMerge(providerRegistryLayer),
Layer.provide(persistenceLayer),
Layer.provideMerge(RepositoryIdentityResolverLive),
Layer.provideMerge(ServerSettingsService.layerTest()),
Layer.provideMerge(ServerConfig.layerTest(workspaceDir, rootDir)),
Layer.provideMerge(serverConfigLayer),
Layer.provideMerge(
LaunchEnvLive.pipe(
Layer.provide(serverConfigLayer),
Layer.provide(runtimeServicesLayer),
),
),
Layer.provideMerge(NodeServices.layer),
);

Expand Down
7 changes: 6 additions & 1 deletion apps/server/src/bin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import * as CliError from "effect/unstable/cli/CliError";
import * as TestConsole from "effect/testing/TestConsole";
import { Command } from "effect/unstable/cli";

import { defaultLaunchEnvTestLayer } from "./launchEnv/Layers/LaunchEnvTest.ts";
import { cli, makeCli } from "./bin.ts";
import { deriveServerPaths, ServerConfig, type ServerConfigShape } from "./config.ts";
import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts";
Expand All @@ -35,7 +36,11 @@ import * as ServerSecretStore from "./auth/ServerSecretStore.ts";
import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts";
import { environmentAuthenticatedAuthLayer } from "./auth/http.ts";

const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer);
const CliRuntimeLayer = Layer.mergeAll(
NodeServices.layer,
NetService.layer,
defaultLaunchEnvTestLayer,
);
class ProjectCliHttpApi extends HttpApi.make("environment").add(EnvironmentOrchestrationHttpApi) {}

const connectCli = makeCli({ cloudEnabled: true });
Expand Down
15 changes: 15 additions & 0 deletions apps/server/src/launchEnv/Layers/LaunchEnvLive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import * as Layer from "effect/Layer";

import { OrchestrationProjectionSnapshotQueryLive } from "../../orchestration/Layers/ProjectionSnapshotQuery.ts";
import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";
import { LaunchEnvLive } from "../Services/LaunchEnv.ts";

export const makeLaunchEnvLayerLive = <E, R>(persistenceLayer: Layer.Layer<never, E, R>) =>
LaunchEnvLive.pipe(
Layer.provide(
OrchestrationProjectionSnapshotQueryLive.pipe(
Layer.provide(persistenceLayer),
Layer.provide(RepositoryIdentityResolverLive),
),
),
);
74 changes: 74 additions & 0 deletions apps/server/src/launchEnv/Layers/LaunchEnvTest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import {
ProjectId,
ThreadId,
type OrchestrationProjectShell,
type OrchestrationThreadShell,
} from "@t3tools/contracts";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";

import {
LaunchEnv,
makeResolveForThread,
makeResolveLaunchEnv,
type LaunchEnvShape,
type ResolvedLaunchEnvForThread,
} from "../Services/LaunchEnv.ts";

export type LaunchEnvTestFixtures = {
readonly t3Home: string;
readonly projects?: ReadonlyArray<OrchestrationProjectShell>;
readonly threads?: ReadonlyArray<OrchestrationThreadShell>;
};

const toProjectMap = (projects: ReadonlyArray<OrchestrationProjectShell> | undefined) =>
new Map((projects ?? []).map((project) => [project.id, project] as const));

const toThreadMap = (threads: ReadonlyArray<OrchestrationThreadShell> | undefined) =>
new Map((threads ?? []).map((thread) => [thread.id, thread] as const));

export const makeLaunchEnvTestShape = (fixtures: LaunchEnvTestFixtures): LaunchEnvShape => {
const resolve = makeResolveLaunchEnv(fixtures.t3Home);
const projectsById = toProjectMap(fixtures.projects);
const threadsById = toThreadMap(fixtures.threads);

return {
resolve,
resolveForThread: makeResolveForThread(resolve, {
getThreadShellById: (threadId) =>
Effect.succeed(Option.fromNullishOr(threadsById.get(threadId))),
getProjectShellById: (projectId) =>
Effect.succeed(Option.fromNullishOr(projectsById.get(projectId))),
}),
};
};

export const launchEnvTestStub = (input: {
readonly t3Home: string;
readonly projectId: ProjectId;
}): LaunchEnvShape => ({
resolve: makeResolveLaunchEnv(input.t3Home),
resolveForThread: (resolveInput) =>
Effect.succeed({
projectId: input.projectId,
...(resolveInput.worktreePath !== undefined
? { worktreePath: resolveInput.worktreePath }
: {}),
env: (resolveInput.extraEnv ?? {}) as Record<string, string>,
} satisfies ResolvedLaunchEnvForThread),
});

export const LaunchEnvTestLayer = {
stub: (input: { readonly t3Home: string; readonly projectId: ProjectId }) =>
Layer.succeed(LaunchEnv, launchEnvTestStub(input)),

withFixtures: (fixtures: LaunchEnvTestFixtures) =>
Layer.succeed(LaunchEnv, makeLaunchEnvTestShape(fixtures)),
};

/** Default CLI/unit-test layer: resolve-only stub with a fixed project id. */
export const defaultLaunchEnvTestLayer = LaunchEnvTestLayer.stub({
t3Home: "/tmp/t3-launch-env-test",
projectId: ProjectId.make("project-1"),
});
141 changes: 141 additions & 0 deletions apps/server/src/launchEnv/Services/LaunchEnv.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import { assert, describe, it } from "@effect/vitest";
import {
DEFAULT_TERMINAL_ID,
ProjectId,
ProviderInstanceId,
ThreadId,
type OrchestrationProjectShell,
type OrchestrationThreadShell,
} from "@t3tools/contracts";
import * as Effect from "effect/Effect";

import { TerminalSessionLookupError } from "../../terminal/Services/Manager.ts";
import { LaunchEnv } from "../Services/LaunchEnv.ts";
import { LaunchEnvTestLayer } from "../Layers/LaunchEnvTest.ts";

const PROJECT_ID = ProjectId.make("project-1");
const THREAD_ID = ThreadId.make("thread-1");
const T3_HOME = "/tmp/t3-launch-env";
const NOW = "2026-01-01T00:00:00.000Z";
const DEFAULT_MODEL_SELECTION = {
instanceId: ProviderInstanceId.make("codex"),
model: "gpt-5-codex",
} as const;

const makeProject = (): OrchestrationProjectShell => ({
id: PROJECT_ID,
title: "Project",
workspaceRoot: "/repo/project",
defaultModelSelection: null,
scripts: [],
createdAt: NOW,
updatedAt: NOW,
});

const makeThread = (
overrides: Partial<OrchestrationThreadShell> = {},
): OrchestrationThreadShell => ({
id: THREAD_ID,
projectId: PROJECT_ID,
title: "Thread",
modelSelection: DEFAULT_MODEL_SELECTION,
runtimeMode: "full-access",
interactionMode: "default",
branch: null,
worktreePath: "/repo/worktrees/a",
latestTurn: null,
createdAt: NOW,
updatedAt: NOW,
archivedAt: null,
session: null,
latestUserMessageAt: null,
hasPendingApprovals: false,
hasPendingUserInput: false,
hasActionableProposedPlan: false,
...overrides,
});

const makeTestLayer = (threads: ReadonlyArray<OrchestrationThreadShell>) =>
LaunchEnvTestLayer.withFixtures({
t3Home: T3_HOME,
projects: [makeProject()],
threads,
});

describe("LaunchEnv.resolveForThread", () => {
it.effect("resolves launch env using the thread project id", () =>
Effect.gen(function* () {
const launchEnv = yield* LaunchEnv;
const result = yield* launchEnv.resolveForThread({
threadId: THREAD_ID,
terminalId: DEFAULT_TERMINAL_ID,
});

assert.deepStrictEqual(result.env, {
T3CODE_HOME: T3_HOME,
T3CODE_PROJECT_ROOT: "/repo/project",
T3CODE_PROJECT_ID: "project-1",
T3CODE_THREAD_ID: "thread-1",
T3CODE_WORKTREE_PATH: "/repo/worktrees/a",
});
assert.strictEqual(result.worktreePath, "/repo/worktrees/a");
}).pipe(Effect.provide(makeTestLayer([makeThread()]))),
);

it.effect("ignores client projectId when the thread already exists", () =>
Effect.gen(function* () {
const launchEnv = yield* LaunchEnv;
const spoofedProjectId = ProjectId.make("project-spoofed");
const result = yield* launchEnv.resolveForThread({
threadId: THREAD_ID,
terminalId: DEFAULT_TERMINAL_ID,
projectId: spoofedProjectId,
});

assert.strictEqual(result.env.T3CODE_PROJECT_ID, "project-1");
assert.strictEqual(result.projectId, PROJECT_ID);
}).pipe(Effect.provide(makeTestLayer([makeThread()]))),
);

it.effect("resolves launch env for draft threads using client projectId", () =>
Effect.gen(function* () {
const launchEnv = yield* LaunchEnv;
const result = yield* launchEnv.resolveForThread({
threadId: THREAD_ID,
terminalId: DEFAULT_TERMINAL_ID,
projectId: PROJECT_ID,
});

assert.strictEqual(result.env.T3CODE_PROJECT_ID, "project-1");
assert.strictEqual(result.env.T3CODE_THREAD_ID, "thread-1");
}).pipe(Effect.provide(makeTestLayer([]))),
);

it.effect("fails when the thread is not found and projectId is omitted", () =>
Effect.gen(function* () {
const launchEnv = yield* LaunchEnv;
const error = yield* Effect.flip(
launchEnv.resolveForThread({
threadId: THREAD_ID,
terminalId: DEFAULT_TERMINAL_ID,
}),
);

assert.instanceOf(error, TerminalSessionLookupError);
}).pipe(Effect.provide(makeTestLayer([]))),
);

it.effect("prefers explicit worktreePath over the thread default", () =>
Effect.gen(function* () {
const launchEnv = yield* LaunchEnv;
const result = yield* launchEnv.resolveForThread({
threadId: THREAD_ID,
terminalId: DEFAULT_TERMINAL_ID,
worktreePath: "/repo/worktrees/b",
});

assert.strictEqual(result.worktreePath, "/repo/worktrees/b");
assert.strictEqual(result.env.T3CODE_WORKTREE_PATH, "/repo/worktrees/b");
}).pipe(Effect.provide(makeTestLayer([makeThread()]))),
);
});
Loading
Loading