Skip to content

Commit 410dbe8

Browse files
Surface environment and repository identity metadata
- Persist a stable server environment ID and descriptor - Resolve repository identity from git remotes and enrich orchestration events - Thread environment metadata through desktop and web startup flows
1 parent 1234708 commit 410dbe8

46 files changed

Lines changed: 1922 additions & 263 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/desktop/src/main.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
6060
const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
6161
const UPDATE_CHECK_CHANNEL = "desktop:update-check";
6262
const GET_WS_URL_CHANNEL = "desktop:get-ws-url";
63+
const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap";
6364
const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3");
6465
const STATE_DIR = Path.join(BASE_DIR, "userdata");
6566
const DESKTOP_SCHEME = "t3";
@@ -1172,6 +1173,14 @@ function registerIpcHandlers(): void {
11721173
event.returnValue = backendWsUrl;
11731174
});
11741175

1176+
ipcMain.removeAllListeners(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL);
1177+
ipcMain.on(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL, (event) => {
1178+
event.returnValue = {
1179+
label: "Local environment",
1180+
wsUrl: backendWsUrl || null,
1181+
} as const;
1182+
});
1183+
11751184
ipcMain.removeHandler(PICK_FOLDER_CHANNEL);
11761185
ipcMain.handle(PICK_FOLDER_CHANNEL, async () => {
11771186
const owner = BrowserWindow.getFocusedWindow() ?? mainWindow;

apps/desktop/src/preload.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,20 @@ const UPDATE_CHECK_CHANNEL = "desktop:update-check";
1313
const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
1414
const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
1515
const GET_WS_URL_CHANNEL = "desktop:get-ws-url";
16+
const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap";
1617

1718
contextBridge.exposeInMainWorld("desktopBridge", {
1819
getWsUrl: () => {
1920
const result = ipcRenderer.sendSync(GET_WS_URL_CHANNEL);
2021
return typeof result === "string" ? result : null;
2122
},
23+
getLocalEnvironmentBootstrap: () => {
24+
const result = ipcRenderer.sendSync(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL);
25+
if (typeof result !== "object" || result === null) {
26+
return null;
27+
}
28+
return result as ReturnType<DesktopBridge["getLocalEnvironmentBootstrap"]>;
29+
},
2230
pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL),
2331
confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message),
2432
setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme),
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import * as NodeServices from "@effect/platform-node/NodeServices";
2+
import { expect, it } from "@effect/vitest";
3+
import { Effect, FileSystem, Layer } from "effect";
4+
5+
import { ServerConfig } from "../../config.ts";
6+
import { ServerEnvironment } from "../Services/ServerEnvironment.ts";
7+
import { ServerEnvironmentLive } from "./ServerEnvironment.ts";
8+
9+
const makeServerEnvironmentLayer = (baseDir: string) =>
10+
ServerEnvironmentLive.pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), baseDir)));
11+
12+
it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
13+
it.effect("persists the environment id across service restarts", () =>
14+
Effect.gen(function* () {
15+
const fileSystem = yield* FileSystem.FileSystem;
16+
const baseDir = yield* fileSystem.makeTempDirectoryScoped({
17+
prefix: "t3-server-environment-test-",
18+
});
19+
20+
const first = yield* Effect.gen(function* () {
21+
const serverEnvironment = yield* ServerEnvironment;
22+
return yield* serverEnvironment.getDescriptor;
23+
}).pipe(Effect.provide(makeServerEnvironmentLayer(baseDir)));
24+
const second = yield* Effect.gen(function* () {
25+
const serverEnvironment = yield* ServerEnvironment;
26+
return yield* serverEnvironment.getDescriptor;
27+
}).pipe(Effect.provide(makeServerEnvironmentLayer(baseDir)));
28+
29+
expect(first.environmentId).toBe(second.environmentId);
30+
expect(second.capabilities.repositoryIdentity).toBe(true);
31+
}),
32+
);
33+
});
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { randomUUID } from "node:crypto";
2+
import { EnvironmentId, type ExecutionEnvironmentDescriptor } from "@t3tools/contracts";
3+
import { Effect, FileSystem, Layer, Path } from "effect";
4+
5+
import { ServerConfig } from "../../config.ts";
6+
import { ServerEnvironment, type ServerEnvironmentShape } from "../Services/ServerEnvironment.ts";
7+
import { version } from "../../../package.json" with { type: "json" };
8+
9+
const ENVIRONMENT_ID_FILENAME = "environment-id";
10+
11+
function platformOs(): ExecutionEnvironmentDescriptor["platform"]["os"] {
12+
switch (process.platform) {
13+
case "darwin":
14+
return "darwin";
15+
case "linux":
16+
return "linux";
17+
case "win32":
18+
return "windows";
19+
default:
20+
return "unknown";
21+
}
22+
}
23+
24+
function platformArch(): ExecutionEnvironmentDescriptor["platform"]["arch"] {
25+
switch (process.arch) {
26+
case "arm64":
27+
return "arm64";
28+
case "x64":
29+
return "x64";
30+
default:
31+
return "other";
32+
}
33+
}
34+
35+
export const makeServerEnvironment = Effect.gen(function* () {
36+
const fileSystem = yield* FileSystem.FileSystem;
37+
const path = yield* Path.Path;
38+
const serverConfig = yield* ServerConfig;
39+
const environmentIdPath = path.join(serverConfig.stateDir, ENVIRONMENT_ID_FILENAME);
40+
41+
const readPersistedEnvironmentId = Effect.gen(function* () {
42+
const exists = yield* fileSystem
43+
.exists(environmentIdPath)
44+
.pipe(Effect.orElseSucceed(() => false));
45+
if (!exists) {
46+
return null;
47+
}
48+
49+
const raw = yield* fileSystem.readFileString(environmentIdPath).pipe(
50+
Effect.orElseSucceed(() => ""),
51+
Effect.map((value) => value.trim()),
52+
);
53+
54+
return raw.length > 0 ? raw : null;
55+
});
56+
57+
const persistEnvironmentId = (value: string) =>
58+
fileSystem.writeFileString(environmentIdPath, `${value}\n`);
59+
60+
const environmentIdRaw = yield* readPersistedEnvironmentId.pipe(
61+
Effect.flatMap((persisted) => {
62+
if (persisted) {
63+
return Effect.succeed(persisted);
64+
}
65+
66+
const generated = randomUUID();
67+
return persistEnvironmentId(generated).pipe(Effect.as(generated));
68+
}),
69+
);
70+
71+
const environmentId = EnvironmentId.makeUnsafe(environmentIdRaw);
72+
const cwdBaseName = path.basename(serverConfig.cwd).trim();
73+
const label =
74+
serverConfig.mode === "desktop"
75+
? "Local environment"
76+
: cwdBaseName.length > 0
77+
? cwdBaseName
78+
: "T3 environment";
79+
80+
const descriptor: ExecutionEnvironmentDescriptor = {
81+
environmentId,
82+
label,
83+
platform: {
84+
os: platformOs(),
85+
arch: platformArch(),
86+
},
87+
serverVersion: version,
88+
capabilities: {
89+
repositoryIdentity: true,
90+
},
91+
};
92+
93+
return {
94+
getEnvironmentId: Effect.succeed(environmentId),
95+
getDescriptor: Effect.succeed(descriptor),
96+
} satisfies ServerEnvironmentShape;
97+
});
98+
99+
export const ServerEnvironmentLive = Layer.effect(ServerEnvironment, makeServerEnvironment);
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import type { EnvironmentId, ExecutionEnvironmentDescriptor } from "@t3tools/contracts";
2+
import { ServiceMap } from "effect";
3+
import type { Effect } from "effect";
4+
5+
export interface ServerEnvironmentShape {
6+
readonly getEnvironmentId: Effect.Effect<EnvironmentId>;
7+
readonly getDescriptor: Effect.Effect<ExecutionEnvironmentDescriptor>;
8+
}
9+
10+
export class ServerEnvironment extends ServiceMap.Service<
11+
ServerEnvironment,
12+
ServerEnvironmentShape
13+
>()("t3/environment/Services/ServerEnvironment") {}

apps/server/src/git/Layers/GitManager.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -854,7 +854,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
854854
"pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
855855
);
856856
}),
857-
12_000,
857+
20_000,
858858
);
859859

860860
it.effect(
@@ -962,7 +962,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
962962
),
963963
).toBe(false);
964964
}),
965-
12_000,
965+
20_000,
966966
);
967967

968968
it.effect("status returns merged PR state when latest PR was merged", () =>
@@ -1685,7 +1685,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => {
16851685
false,
16861686
);
16871687
}),
1688-
12_000,
1688+
20_000,
16891689
);
16901690

16911691
it.effect(

apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
234234
id: asProjectId("project-1"),
235235
title: "Project 1",
236236
workspaceRoot: "/tmp/project-1",
237+
repositoryIdentity: null,
237238
defaultModelSelection: {
238239
provider: "codex",
239240
model: "gpt-5-codex",

apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts

Lines changed: 36 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ import { ProjectionThreadMessage } from "../../persistence/Services/ProjectionTh
3838
import { ProjectionThreadProposedPlan } from "../../persistence/Services/ProjectionThreadProposedPlans.ts";
3939
import { ProjectionThreadSession } from "../../persistence/Services/ProjectionThreadSessions.ts";
4040
import { ProjectionThread } from "../../persistence/Services/ProjectionThreads.ts";
41+
import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";
42+
import { RepositoryIdentityResolver } from "../../project/Services/RepositoryIdentityResolver.ts";
4143
import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts";
4244
import {
4345
ProjectionSnapshotQuery,
@@ -163,6 +165,8 @@ function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: st
163165

164166
const makeProjectionSnapshotQuery = Effect.gen(function* () {
165167
const sql = yield* SqlClient.SqlClient;
168+
const repositoryIdentityResolver = yield* RepositoryIdentityResolver;
169+
const repositoryIdentityResolutionConcurrency = 4;
166170

167171
const listProjectRows = SqlSchema.findAll({
168172
Request: Schema.Void,
@@ -652,10 +656,22 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
652656
});
653657
}
654658

659+
const repositoryIdentities = new Map(
660+
yield* Effect.forEach(
661+
projectRows,
662+
(row) =>
663+
repositoryIdentityResolver
664+
.resolve(row.workspaceRoot)
665+
.pipe(Effect.map((identity) => [row.projectId, identity] as const)),
666+
{ concurrency: repositoryIdentityResolutionConcurrency },
667+
),
668+
);
669+
655670
const projects: ReadonlyArray<OrchestrationProject> = projectRows.map((row) => ({
656671
id: row.projectId,
657672
title: row.title,
658673
workspaceRoot: row.workspaceRoot,
674+
repositoryIdentity: repositoryIdentities.get(row.projectId) ?? null,
659675
defaultModelSelection: row.defaultModelSelection,
660676
scripts: row.scripts,
661677
createdAt: row.createdAt,
@@ -732,19 +748,25 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
732748
"ProjectionSnapshotQuery.getActiveProjectByWorkspaceRoot:decodeRow",
733749
),
734750
),
735-
Effect.map(
736-
Option.map(
737-
(row): OrchestrationProject => ({
738-
id: row.projectId,
739-
title: row.title,
740-
workspaceRoot: row.workspaceRoot,
741-
defaultModelSelection: row.defaultModelSelection,
742-
scripts: row.scripts,
743-
createdAt: row.createdAt,
744-
updatedAt: row.updatedAt,
745-
deletedAt: row.deletedAt,
746-
}),
747-
),
751+
Effect.map((option) => option),
752+
Effect.flatMap((option) =>
753+
Option.isNone(option)
754+
? Effect.succeed(Option.none<OrchestrationProject>())
755+
: repositoryIdentityResolver.resolve(option.value.workspaceRoot).pipe(
756+
Effect.map((repositoryIdentity) =>
757+
Option.some({
758+
id: option.value.projectId,
759+
title: option.value.title,
760+
workspaceRoot: option.value.workspaceRoot,
761+
repositoryIdentity,
762+
defaultModelSelection: option.value.defaultModelSelection,
763+
scripts: option.value.scripts,
764+
createdAt: option.value.createdAt,
765+
updatedAt: option.value.updatedAt,
766+
deletedAt: option.value.deletedAt,
767+
} satisfies OrchestrationProject),
768+
),
769+
),
748770
),
749771
);
750772

@@ -816,4 +838,4 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
816838
export const OrchestrationProjectionSnapshotQueryLive = Layer.effect(
817839
ProjectionSnapshotQuery,
818840
makeProjectionSnapshotQuery,
819-
);
841+
).pipe(Layer.provideMerge(RepositoryIdentityResolverLive));
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import * as NodeServices from "@effect/platform-node/NodeServices";
2+
import { expect, it } from "@effect/vitest";
3+
import { Effect, FileSystem } from "effect";
4+
5+
import { runProcess } from "../../processRunner.ts";
6+
import { RepositoryIdentityResolver } from "../Services/RepositoryIdentityResolver.ts";
7+
import { RepositoryIdentityResolverLive } from "./RepositoryIdentityResolver.ts";
8+
9+
const git = (cwd: string, args: ReadonlyArray<string>) =>
10+
Effect.promise(() => runProcess("git", ["-C", cwd, ...args]));
11+
12+
it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
13+
it.effect("normalizes equivalent GitHub remotes into a stable repository identity", () =>
14+
Effect.gen(function* () {
15+
const fileSystem = yield* FileSystem.FileSystem;
16+
const cwd = yield* fileSystem.makeTempDirectoryScoped({
17+
prefix: "t3-repository-identity-test-",
18+
});
19+
20+
yield* git(cwd, ["init"]);
21+
yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]);
22+
23+
const resolver = yield* RepositoryIdentityResolver;
24+
const identity = yield* resolver.resolve(cwd);
25+
26+
expect(identity).not.toBeNull();
27+
expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code");
28+
expect(identity?.displayName).toBe("T3Tools/t3code");
29+
expect(identity?.provider).toBe("github");
30+
expect(identity?.owner).toBe("T3Tools");
31+
expect(identity?.name).toBe("t3code");
32+
}).pipe(Effect.provide(RepositoryIdentityResolverLive)),
33+
);
34+
35+
it.effect("returns null for non-git folders and repos without remotes", () =>
36+
Effect.gen(function* () {
37+
const fileSystem = yield* FileSystem.FileSystem;
38+
const nonGitDir = yield* fileSystem.makeTempDirectoryScoped({
39+
prefix: "t3-repository-identity-non-git-",
40+
});
41+
const gitDir = yield* fileSystem.makeTempDirectoryScoped({
42+
prefix: "t3-repository-identity-no-remote-",
43+
});
44+
45+
yield* git(gitDir, ["init"]);
46+
47+
const resolver = yield* RepositoryIdentityResolver;
48+
const nonGitIdentity = yield* resolver.resolve(nonGitDir);
49+
const noRemoteIdentity = yield* resolver.resolve(gitDir);
50+
51+
expect(nonGitIdentity).toBeNull();
52+
expect(noRemoteIdentity).toBeNull();
53+
}).pipe(Effect.provide(RepositoryIdentityResolverLive)),
54+
);
55+
56+
it.effect("prefers upstream over origin when both remotes are configured", () =>
57+
Effect.gen(function* () {
58+
const fileSystem = yield* FileSystem.FileSystem;
59+
const cwd = yield* fileSystem.makeTempDirectoryScoped({
60+
prefix: "t3-repository-identity-upstream-test-",
61+
});
62+
63+
yield* git(cwd, ["init"]);
64+
yield* git(cwd, ["remote", "add", "origin", "git@github.com:julius/t3code.git"]);
65+
yield* git(cwd, ["remote", "add", "upstream", "git@github.com:T3Tools/t3code.git"]);
66+
67+
const resolver = yield* RepositoryIdentityResolver;
68+
const identity = yield* resolver.resolve(cwd);
69+
70+
expect(identity).not.toBeNull();
71+
expect(identity?.locator.remoteName).toBe("upstream");
72+
expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code");
73+
expect(identity?.displayName).toBe("T3Tools/t3code");
74+
}).pipe(Effect.provide(RepositoryIdentityResolverLive)),
75+
);
76+
});

0 commit comments

Comments
 (0)