History

Prepare datamodel for multi-environment (#1765)

Julius Marminge committed 7 months ago

Commit b96308f1 parent 72b7f90121 files · +10206 −4969Browse files

Showing the changes to apps/server/src (20 of 121 files). Show all

Unified · Split

20 files
  1. apps/server/src/config.ts +2 −0
  2. apps/server/src/environment/Layers/ServerEnvironment.test.ts +124 −0
  3. apps/server/src/environment/Layers/ServerEnvironment.ts +94 −0
  4. apps/server/src/environment/Services/ServerEnvironment.ts +13 −0
  5. apps/server/src/git/Layers/GitManager.test.ts +3 −3
  6. apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +2 −0
  7. apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +5 −0
  8. apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +2 −0
  9. apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +6 −1
  10. apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +278 −256
  11. apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +2 −0
  12. apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +2 −0
  13. apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts +193 −0
  14. apps/server/src/project/Layers/RepositoryIdentityResolver.ts +147 −0
  15. apps/server/src/project/Services/RepositoryIdentityResolver.ts +12 −0
  16. apps/server/src/server.test.ts +245 −1
  17. apps/server/src/server.ts +4 −0
  18. apps/server/src/serverLifecycleEvents.test.ts +10 −0
  19. apps/server/src/serverRuntimeStartup.ts +12 −2
  20. apps/server/src/ws.ts +55 −1
apps/server/src/config.ts+2−0
@@ -30,6 +30,7 @@ export interface ServerDerivedPaths { · 29 unmodified lines
3030 readonly providerEventLogPath: string;
3131 readonly terminalLogsDir: string;
3232 readonly anonymousIdPath: string;
33+ readonly environmentIdPath: string;
3334 }
3435
3536 /**
@@ -83,6 +84,7 @@ export const deriveServerPaths = Effect.fn(function* ( · 47 unmodified lines
8384 providerEventLogPath: join(providerLogsDir, "events.log"),
8485 terminalLogsDir: join(logsDir, "terminals"),
8586 anonymousIdPath: join(stateDir, "anonymous-id"),
87+ environmentIdPath: join(stateDir, "environment-id"),
8688 };
8789 });
8890
apps/server/src/environment/Layers/ServerEnvironment.test.ts+124−0
@@ -0,0 +1,124 @@
1+import * as nodePath from "node:path";
2+import * as NodeServices from "@effect/platform-node/NodeServices";
3+import { expect, it } from "@effect/vitest";
4+import { Effect, Exit, FileSystem, Layer, PlatformError } from "effect";
5+
6+import { deriveServerPaths, ServerConfig, type ServerConfigShape } from "../../config.ts";
7+import { ServerEnvironment } from "../Services/ServerEnvironment.ts";
8+import { ServerEnvironmentLive } from "./ServerEnvironment.ts";
9+
10+const makeServerEnvironmentLayer = (baseDir: string) =>
11+ ServerEnvironmentLive.pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), baseDir)));
12+
13+const makeServerConfig = Effect.fn(function* (baseDir: string) {
14+ const derivedPaths = yield* deriveServerPaths(baseDir, undefined);
15+
16+ return {
17+ ...derivedPaths,
18+ logLevel: "Error",
19+ traceMinLevel: "Info",
20+ traceTimingEnabled: true,
21+ traceBatchWindowMs: 200,
22+ traceMaxBytes: 10 * 1024 * 1024,
23+ traceMaxFiles: 10,
24+ otlpTracesUrl: undefined,
25+ otlpMetricsUrl: undefined,
26+ otlpExportIntervalMs: 10_000,
27+ otlpServiceName: "t3-server",
28+ cwd: process.cwd(),
29+ baseDir,
30+ mode: "web",
31+ autoBootstrapProjectFromCwd: false,
32+ logWebSocketEvents: false,
33+ port: 0,
34+ host: undefined,
35+ authToken: undefined,
36+ staticDir: undefined,
37+ devUrl: undefined,
38+ noBrowser: false,
39+ } satisfies ServerConfigShape;
40+});
41+
42+it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
43+ it.effect("persists the environment id across service restarts", () =>
44+ Effect.gen(function* () {
45+ const fileSystem = yield* FileSystem.FileSystem;
46+ const baseDir = yield* fileSystem.makeTempDirectoryScoped({
47+ prefix: "t3-server-environment-test-",
48+ });
49+
50+ const first = yield* Effect.gen(function* () {
51+ const serverEnvironment = yield* ServerEnvironment;
52+ return yield* serverEnvironment.getDescriptor;
53+ }).pipe(Effect.provide(makeServerEnvironmentLayer(baseDir)));
54+ const second = yield* Effect.gen(function* () {
55+ const serverEnvironment = yield* ServerEnvironment;
56+ return yield* serverEnvironment.getDescriptor;
57+ }).pipe(Effect.provide(makeServerEnvironmentLayer(baseDir)));
58+
59+ expect(first.environmentId).toBe(second.environmentId);
60+ expect(second.capabilities.repositoryIdentity).toBe(true);
61+ }),
62+ );
63+
64+ it.effect("fails instead of overwriting a persisted id when reading the file errors", () =>
65+ Effect.gen(function* () {
66+ const fileSystem = yield* FileSystem.FileSystem;
67+ const baseDir = yield* fileSystem.makeTempDirectoryScoped({
68+ prefix: "t3-server-environment-read-error-test-",
69+ });
70+ const serverConfig = yield* makeServerConfig(baseDir);
71+ const environmentIdPath = serverConfig.environmentIdPath;
72+ yield* fileSystem.makeDirectory(nodePath.dirname(environmentIdPath), { recursive: true });
73+ yield* fileSystem.writeFileString(environmentIdPath, "persisted-environment-id\n");
74+ const writeAttempts: string[] = [];
75+ const failingFileSystemLayer = FileSystem.layerNoop({
76+ exists: (path) => Effect.succeed(path === environmentIdPath),
77+ readFileString: (path) =>
78+ path === environmentIdPath
79+ ? Effect.fail(
80+ PlatformError.systemError({
81+ _tag: "PermissionDenied",
82+ module: "FileSystem",
83+ method: "readFileString",
84+ description: "permission denied",
85+ pathOrDescriptor: path,
86+ }),
87+ )
88+ : Effect.fail(
89+ PlatformError.systemError({
90+ _tag: "NotFound",
91+ module: "FileSystem",
92+ method: "readFileString",
93+ description: "not found",
94+ pathOrDescriptor: path,
95+ }),
96+ ),
97+ writeFileString: (path) => {
98+ writeAttempts.push(path);
99+ return Effect.void;
100+ },
101+ });
102+
103+ const exit = yield* Effect.gen(function* () {
104+ const serverEnvironment = yield* ServerEnvironment;
105+ return yield* serverEnvironment.getDescriptor;
106+ }).pipe(
107+ Effect.provide(
108+ ServerEnvironmentLive.pipe(
109+ Layer.provide(
110+ Layer.merge(Layer.succeed(ServerConfig, serverConfig), failingFileSystemLayer),
111+ ),
112+ ),
113+ ),
114+ Effect.exit,
115+ );
116+
117+ expect(Exit.isFailure(exit)).toBe(true);
118+ expect(writeAttempts).toEqual([]);
119+ expect(yield* fileSystem.readFileString(environmentIdPath)).toBe(
120+ "persisted-environment-id\n",
121+ );
122+ }),
123+ );
124+});
apps/server/src/environment/Layers/ServerEnvironment.ts+94−0
@@ -0,0 +1,94 @@
1+import { EnvironmentId, type ExecutionEnvironmentDescriptor } from "@t3tools/contracts";
2+import { Effect, FileSystem, Layer, Path, Random } from "effect";
3+
4+import { ServerConfig } from "../../config.ts";
5+import { ServerEnvironment, type ServerEnvironmentShape } from "../Services/ServerEnvironment.ts";
6+import { version } from "../../../package.json" with { type: "json" };
7+
8+function platformOs(): ExecutionEnvironmentDescriptor["platform"]["os"] {
9+ switch (process.platform) {
10+ case "darwin":
11+ return "darwin";
12+ case "linux":
13+ return "linux";
14+ case "win32":
15+ return "windows";
16+ default:
17+ return "unknown";
18+ }
19+}
20+
21+function platformArch(): ExecutionEnvironmentDescriptor["platform"]["arch"] {
22+ switch (process.arch) {
23+ case "arm64":
24+ return "arm64";
25+ case "x64":
26+ return "x64";
27+ default:
28+ return "other";
29+ }
30+}
31+
32+export const makeServerEnvironment = Effect.fn("makeServerEnvironment")(function* () {
33+ const fileSystem = yield* FileSystem.FileSystem;
34+ const path = yield* Path.Path;
35+ const serverConfig = yield* ServerConfig;
36+
37+ const readPersistedEnvironmentId = Effect.gen(function* () {
38+ const exists = yield* fileSystem
39+ .exists(serverConfig.environmentIdPath)
40+ .pipe(Effect.orElseSucceed(() => false));
41+ if (!exists) {
42+ return null;
43+ }
44+
45+ const raw = yield* fileSystem
46+ .readFileString(serverConfig.environmentIdPath)
47+ .pipe(Effect.map((value) => value.trim()));
48+
49+ return raw.length > 0 ? raw : null;
50+ });
51+
52+ const persistEnvironmentId = (value: string) =>
53+ fileSystem.writeFileString(serverConfig.environmentIdPath, `${value}\n`);
54+
55+ const environmentIdRaw = yield* Effect.gen(function* () {
56+ const persisted = yield* readPersistedEnvironmentId;
57+ if (persisted) {
58+ return persisted;
59+ }
60+
61+ const generated = yield* Random.nextUUIDv4;
62+ yield* persistEnvironmentId(generated);
63+ return generated;
64+ });
65+
66+ const environmentId = EnvironmentId.makeUnsafe(environmentIdRaw);
67+ const cwdBaseName = path.basename(serverConfig.cwd).trim();
68+ const label =
69+ serverConfig.mode === "desktop"
70+ ? "Local environment"
71+ : cwdBaseName.length > 0
72+ ? cwdBaseName
73+ : "T3 environment";
74+
75+ const descriptor: ExecutionEnvironmentDescriptor = {
76+ environmentId,
77+ label,
78+ platform: {
79+ os: platformOs(),
80+ arch: platformArch(),
81+ },
82+ serverVersion: version,
83+ capabilities: {
84+ repositoryIdentity: true,
85+ },
86+ };
87+
88+ return {
89+ getEnvironmentId: Effect.succeed(environmentId),
90+ getDescriptor: Effect.succeed(descriptor),
91+ } satisfies ServerEnvironmentShape;
92+});
93+
94+export const ServerEnvironmentLive = Layer.effect(ServerEnvironment, makeServerEnvironment());
apps/server/src/environment/Services/ServerEnvironment.ts+13−0
@@ -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+3−3
@@ -854,7 +854,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { · 853 unmodified lines
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) => { · 101 unmodified lines
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) => { · 716 unmodified lines
16851685 false,
16861686 );
16871687 }),
1688− 12_000,
1688+ 20_000,
16891689 );
16901690
16911691 it.effect(
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+2−0
@@ -20,6 +20,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; · 19 unmodified lines
2020 import { CheckpointStoreLive } from "../../checkpointing/Layers/CheckpointStore.ts";
2121 import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts";
2222 import { GitCoreLive } from "../../git/Layers/GitCore.ts";
23+import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";
2324 import { CheckpointReactorLive } from "./CheckpointReactor.ts";
2425 import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
2526 import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
@@ -256,6 +257,7 @@ describe("CheckpointReactor", () => { · 230 unmodified lines
256257 Layer.provide(OrchestrationProjectionPipelineLive),
257258 Layer.provide(OrchestrationEventStoreLive),
258259 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
260+ Layer.provide(RepositoryIdentityResolverLive),
259261 Layer.provide(SqlitePersistenceMemory),
260262 );
261263
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+5−0
@@ -19,6 +19,7 @@ import { · 18 unmodified lines
1919 OrchestrationEventStore,
2020 type OrchestrationEventStoreShape,
2121 } from "../../persistence/Services/OrchestrationEventStore.ts";
22+import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";
2223 import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
2324 import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
2425 import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
@@ -45,6 +46,7 @@ async function createOrchestrationSystem() { · 20 unmodified lines
4546 Layer.provide(OrchestrationProjectionPipelineLive),
4647 Layer.provide(OrchestrationEventStoreLive),
4748 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
49+ Layer.provide(RepositoryIdentityResolverLive),
4850 Layer.provide(SqlitePersistenceMemory),
4951 Layer.provideMerge(ServerConfigLayer),
5052 Layer.provideMerge(NodeServices.layer),
@@ -623,6 +625,7 @@ describe("OrchestrationEngine", () => { · 572 unmodified lines
623625 Layer.provide(OrchestrationProjectionPipelineLive),
624626 Layer.provide(Layer.succeed(OrchestrationEventStore, flakyStore)),
625627 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
628+ Layer.provide(RepositoryIdentityResolverLive),
626629 Layer.provide(SqlitePersistenceMemory),
627630 Layer.provideMerge(ServerConfigLayer),
628631 Layer.provideMerge(NodeServices.layer),
@@ -719,6 +722,7 @@ describe("OrchestrationEngine", () => { · 90 unmodified lines
719722 Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)),
720723 Layer.provide(OrchestrationEventStoreLive),
721724 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
725+ Layer.provide(RepositoryIdentityResolverLive),
722726 Layer.provide(SqlitePersistenceMemory),
723727 ),
724728 );
@@ -861,6 +865,7 @@ describe("OrchestrationEngine", () => { · 136 unmodified lines
861865 Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)),
862866 Layer.provide(Layer.succeed(OrchestrationEventStore, nonTransactionalStore)),
863867 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
868+ Layer.provide(RepositoryIdentityResolverLive),
864869 Layer.provide(SqlitePersistenceMemory),
865870 ),
866871 );
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+2−0
@@ -20,6 +20,7 @@ import { · 19 unmodified lines
2020 SqlitePersistenceMemory,
2121 } from "../../persistence/Layers/Sqlite.ts";
2222 import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts";
23+import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";
2324 import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
2425 import {
2526 ORCHESTRATION_PROJECTOR_NAMES,
@@ -1846,6 +1847,7 @@ const engineLayer = it.layer( · 1820 unmodified lines
18461847 Layer.provide(OrchestrationProjectionPipelineLive),
18471848 Layer.provide(OrchestrationEventStoreLive),
18481849 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
1850+ Layer.provide(RepositoryIdentityResolverLive),
18491851 Layer.provideMerge(SqlitePersistenceMemory),
18501852 Layer.provideMerge(
18511853 ServerConfig.layerTest(process.cwd(), {
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+6−1
@@ -4,6 +4,7 @@ import { Effect, Layer } from "effect"; · 3 unmodified lines
44 import * as SqlClient from "effect/unstable/sql/SqlClient";
55
66 import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
7+import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";
78 import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts";
89 import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
910 import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";
@@ -15,7 +16,10 @@ const asEventId = (value: string): EventId => EventId.makeUnsafe(value); · 5 unmodified lines
1516 const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.makeUnsafe(value);
1617
1718 const projectionSnapshotLayer = it.layer(
18− OrchestrationProjectionSnapshotQueryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)),
19+ OrchestrationProjectionSnapshotQueryLive.pipe(
20+ Layer.provideMerge(RepositoryIdentityResolverLive),
21+ Layer.provideMerge(SqlitePersistenceMemory),
22+ ),
1923 );
2024
2125 projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
@@ -234,6 +238,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 212 unmodified lines
234238 id: asProjectId("project-1"),
235239 title: "Project 1",
236240 workspaceRoot: "/tmp/project-1",
241+ repositoryIdentity: null,
237242 defaultModelSelection: {
238243 provider: "codex",
239244 model: "gpt-5-codex",
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+278−256
@@ -38,6 +38,7 @@ import { ProjectionThreadMessage } from "../../persistence/Services/ProjectionTh · 37 unmodified lines
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 { RepositoryIdentityResolver } from "../../project/Services/RepositoryIdentityResolver.ts";
4142 import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts";
4243 import {
4344 ProjectionSnapshotQuery,
@@ -163,6 +164,8 @@ function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: st · 119 unmodified lines
163164
164165 const makeProjectionSnapshotQuery = Effect.gen(function* () {
165166 const sql = yield* SqlClient.SqlClient;
167+ const repositoryIdentityResolver = yield* RepositoryIdentityResolver;
168+ const repositoryIdentityResolutionConcurrency = 4;
166169
167170 const listProjectRows = SqlSchema.findAll({
168171 Request: Schema.Void,
@@ -436,269 +439,283 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 267 unmodified lines
436439 const getSnapshot: ProjectionSnapshotQueryShape["getSnapshot"] = () =>
437440 sql
438441 .withTransaction(
439− Effect.gen(function* () {
440− const [
441− projectRows,
442− threadRows,
443− messageRows,
444− proposedPlanRows,
445− activityRows,
446− sessionRows,
447− checkpointRows,
448− latestTurnRows,
449− stateRows,
450− ] = yield* Effect.all([
451− listProjectRows(undefined).pipe(
452− Effect.mapError(
453− toPersistenceSqlOrDecodeError(
454− "ProjectionSnapshotQuery.getSnapshot:listProjects:query",
455− "ProjectionSnapshotQuery.getSnapshot:listProjects:decodeRows",
456− ),
457− ),
458− ),
459− listThreadRows(undefined).pipe(
460− Effect.mapError(
461− toPersistenceSqlOrDecodeError(
462− "ProjectionSnapshotQuery.getSnapshot:listThreads:query",
463− "ProjectionSnapshotQuery.getSnapshot:listThreads:decodeRows",
464− ),
442+ Effect.all([
443+ listProjectRows(undefined).pipe(
444+ Effect.mapError(
445+ toPersistenceSqlOrDecodeError(
446+ "ProjectionSnapshotQuery.getSnapshot:listProjects:query",
447+ "ProjectionSnapshotQuery.getSnapshot:listProjects:decodeRows",
465448 ),
466449 ),
467− listThreadMessageRows(undefined).pipe(
468− Effect.mapError(
469− toPersistenceSqlOrDecodeError(
470− "ProjectionSnapshotQuery.getSnapshot:listThreadMessages:query",
471− "ProjectionSnapshotQuery.getSnapshot:listThreadMessages:decodeRows",
472− ),
450+ ),
451+ listThreadRows(undefined).pipe(
452+ Effect.mapError(
453+ toPersistenceSqlOrDecodeError(
454+ "ProjectionSnapshotQuery.getSnapshot:listThreads:query",
455+ "ProjectionSnapshotQuery.getSnapshot:listThreads:decodeRows",
473456 ),
474457 ),
475− listThreadProposedPlanRows(undefined).pipe(
476− Effect.mapError(
477− toPersistenceSqlOrDecodeError(
478− "ProjectionSnapshotQuery.getSnapshot:listThreadProposedPlans:query",
479− "ProjectionSnapshotQuery.getSnapshot:listThreadProposedPlans:decodeRows",
480− ),
458+ ),
459+ listThreadMessageRows(undefined).pipe(
460+ Effect.mapError(
461+ toPersistenceSqlOrDecodeError(
462+ "ProjectionSnapshotQuery.getSnapshot:listThreadMessages:query",
463+ "ProjectionSnapshotQuery.getSnapshot:listThreadMessages:decodeRows",
481464 ),
482465 ),
483− listThreadActivityRows(undefined).pipe(
484− Effect.mapError(
485− toPersistenceSqlOrDecodeError(
486− "ProjectionSnapshotQuery.getSnapshot:listThreadActivities:query",
487− "ProjectionSnapshotQuery.getSnapshot:listThreadActivities:decodeRows",
488− ),
466+ ),
467+ listThreadProposedPlanRows(undefined).pipe(
468+ Effect.mapError(
469+ toPersistenceSqlOrDecodeError(
470+ "ProjectionSnapshotQuery.getSnapshot:listThreadProposedPlans:query",
471+ "ProjectionSnapshotQuery.getSnapshot:listThreadProposedPlans:decodeRows",
489472 ),
490473 ),
491− listThreadSessionRows(undefined).pipe(
492− Effect.mapError(
493− toPersistenceSqlOrDecodeError(
494− "ProjectionSnapshotQuery.getSnapshot:listThreadSessions:query",
495− "ProjectionSnapshotQuery.getSnapshot:listThreadSessions:decodeRows",
496− ),
474+ ),
475+ listThreadActivityRows(undefined).pipe(
476+ Effect.mapError(
477+ toPersistenceSqlOrDecodeError(
478+ "ProjectionSnapshotQuery.getSnapshot:listThreadActivities:query",
479+ "ProjectionSnapshotQuery.getSnapshot:listThreadActivities:decodeRows",
497480 ),
498481 ),
499− listCheckpointRows(undefined).pipe(
500− Effect.mapError(
501− toPersistenceSqlOrDecodeError(
502− "ProjectionSnapshotQuery.getSnapshot:listCheckpoints:query",
503− "ProjectionSnapshotQuery.getSnapshot:listCheckpoints:decodeRows",
504− ),
482+ ),
483+ listThreadSessionRows(undefined).pipe(
484+ Effect.mapError(
485+ toPersistenceSqlOrDecodeError(
486+ "ProjectionSnapshotQuery.getSnapshot:listThreadSessions:query",
487+ "ProjectionSnapshotQuery.getSnapshot:listThreadSessions:decodeRows",
505488 ),
506489 ),
507− listLatestTurnRows(undefined).pipe(
508− Effect.mapError(
509− toPersistenceSqlOrDecodeError(
510− "ProjectionSnapshotQuery.getSnapshot:listLatestTurns:query",
511− "ProjectionSnapshotQuery.getSnapshot:listLatestTurns:decodeRows",
512− ),
490+ ),
491+ listCheckpointRows(undefined).pipe(
492+ Effect.mapError(
493+ toPersistenceSqlOrDecodeError(
494+ "ProjectionSnapshotQuery.getSnapshot:listCheckpoints:query",
495+ "ProjectionSnapshotQuery.getSnapshot:listCheckpoints:decodeRows",
513496 ),
514497 ),
515− listProjectionStateRows(undefined).pipe(
516− Effect.mapError(
517− toPersistenceSqlOrDecodeError(
518− "ProjectionSnapshotQuery.getSnapshot:listProjectionState:query",
519− "ProjectionSnapshotQuery.getSnapshot:listProjectionState:decodeRows",
520− ),
498+ ),
499+ listLatestTurnRows(undefined).pipe(
500+ Effect.mapError(
501+ toPersistenceSqlOrDecodeError(
502+ "ProjectionSnapshotQuery.getSnapshot:listLatestTurns:query",
503+ "ProjectionSnapshotQuery.getSnapshot:listLatestTurns:decodeRows",
521504 ),
522505 ),
523− ]);
524−
525− const messagesByThread = new Map<string, Array<OrchestrationMessage>>();
526− const proposedPlansByThread = new Map<string, Array<OrchestrationProposedPlan>>();
527− const activitiesByThread = new Map<string, Array<OrchestrationThreadActivity>>();
528− const checkpointsByThread = new Map<string, Array<OrchestrationCheckpointSummary>>();
529− const sessionsByThread = new Map<string, OrchestrationSession>();
530− const latestTurnByThread = new Map<string, OrchestrationLatestTurn>();
531−
532− let updatedAt: string | null = null;
533−
534− for (const row of projectRows) {
535− updatedAt = maxIso(updatedAt, row.updatedAt);
536− }
537− for (const row of threadRows) {
538− updatedAt = maxIso(updatedAt, row.updatedAt);
539− }
540− for (const row of stateRows) {
541− updatedAt = maxIso(updatedAt, row.updatedAt);
542− }
543−
544− for (const row of messageRows) {
545− updatedAt = maxIso(updatedAt, row.updatedAt);
546− const threadMessages = messagesByThread.get(row.threadId) ?? [];
547− threadMessages.push({
548− id: row.messageId,
549− role: row.role,
550− text: row.text,
551− ...(row.attachments !== null ? { attachments: row.attachments } : {}),
552− turnId: row.turnId,
553− streaming: row.isStreaming === 1,
554− createdAt: row.createdAt,
555− updatedAt: row.updatedAt,
556− });
557− messagesByThread.set(row.threadId, threadMessages);
558− }
559−
560− for (const row of proposedPlanRows) {
561− updatedAt = maxIso(updatedAt, row.updatedAt);
562− const threadProposedPlans = proposedPlansByThread.get(row.threadId) ?? [];
563− threadProposedPlans.push({
564− id: row.planId,
565− turnId: row.turnId,
566− planMarkdown: row.planMarkdown,
567− implementedAt: row.implementedAt,
568− implementationThreadId: row.implementationThreadId,
569− createdAt: row.createdAt,
570− updatedAt: row.updatedAt,
571− });
572− proposedPlansByThread.set(row.threadId, threadProposedPlans);
573− }
574−
575− for (const row of activityRows) {
576− updatedAt = maxIso(updatedAt, row.createdAt);
577− const threadActivities = activitiesByThread.get(row.threadId) ?? [];
578− threadActivities.push({
579− id: row.activityId,
580− tone: row.tone,
581− kind: row.kind,
582− summary: row.summary,
583− payload: row.payload,
584− turnId: row.turnId,
585− ...(row.sequence !== null ? { sequence: row.sequence } : {}),
586− createdAt: row.createdAt,
587− });
588− activitiesByThread.set(row.threadId, threadActivities);
589− }
590−
591− for (const row of checkpointRows) {
592− updatedAt = maxIso(updatedAt, row.completedAt);
593− const threadCheckpoints = checkpointsByThread.get(row.threadId) ?? [];
594− threadCheckpoints.push({
595− turnId: row.turnId,
596− checkpointTurnCount: row.checkpointTurnCount,
597− checkpointRef: row.checkpointRef,
598− status: row.status,
599− files: row.files,
600− assistantMessageId: row.assistantMessageId,
601− completedAt: row.completedAt,
602− });
603− checkpointsByThread.set(row.threadId, threadCheckpoints);
604− }
605−
606− for (const row of latestTurnRows) {
607− updatedAt = maxIso(updatedAt, row.requestedAt);
608− if (row.startedAt !== null) {
609− updatedAt = maxIso(updatedAt, row.startedAt);
610− }
611− if (row.completedAt !== null) {
612− updatedAt = maxIso(updatedAt, row.completedAt);
613− }
614− if (latestTurnByThread.has(row.threadId)) {
615− continue;
616− }
617− latestTurnByThread.set(row.threadId, {
618− turnId: row.turnId,
619− state:
620− row.state === "error"
621− ? "error"
622− : row.state === "interrupted"
623− ? "interrupted"
624− : row.state === "completed"
625− ? "completed"
626− : "running",
627− requestedAt: row.requestedAt,
628− startedAt: row.startedAt,
629− completedAt: row.completedAt,
630− assistantMessageId: row.assistantMessageId,
631− ...(row.sourceProposedPlanThreadId !== null && row.sourceProposedPlanId !== null
632− ? {
633− sourceProposedPlan: {
634− threadId: row.sourceProposedPlanThreadId,
635− planId: row.sourceProposedPlanId,
636− },
637− }
638− : {}),
639− });
640− }
641−
642− for (const row of sessionRows) {
643− updatedAt = maxIso(updatedAt, row.updatedAt);
644− sessionsByThread.set(row.threadId, {
645− threadId: row.threadId,
646− status: row.status,
647− providerName: row.providerName,
648− runtimeMode: row.runtimeMode,
649− activeTurnId: row.activeTurnId,
650− lastError: row.lastError,
651− updatedAt: row.updatedAt,
652− });
653− }
654−
655− const projects: ReadonlyArray<OrchestrationProject> = projectRows.map((row) => ({
656− id: row.projectId,
657− title: row.title,
658− workspaceRoot: row.workspaceRoot,
659− defaultModelSelection: row.defaultModelSelection,
660− scripts: row.scripts,
661− createdAt: row.createdAt,
662− updatedAt: row.updatedAt,
663− deletedAt: row.deletedAt,
664− }));
665−
666− const threads: ReadonlyArray<OrchestrationThread> = threadRows.map((row) => ({
667− id: row.threadId,
668− projectId: row.projectId,
669− title: row.title,
670− modelSelection: row.modelSelection,
671− runtimeMode: row.runtimeMode,
672− interactionMode: row.interactionMode,
673− branch: row.branch,
674− worktreePath: row.worktreePath,
675− latestTurn: latestTurnByThread.get(row.threadId) ?? null,
676− createdAt: row.createdAt,
677− updatedAt: row.updatedAt,
678− archivedAt: row.archivedAt,
679− deletedAt: row.deletedAt,
680− messages: messagesByThread.get(row.threadId) ?? [],
681− proposedPlans: proposedPlansByThread.get(row.threadId) ?? [],
682− activities: activitiesByThread.get(row.threadId) ?? [],
683− checkpoints: checkpointsByThread.get(row.threadId) ?? [],
684− session: sessionsByThread.get(row.threadId) ?? null,
685− }));
686−
687− const snapshot = {
688− snapshotSequence: computeSnapshotSequence(stateRows),
689− projects,
690− threads,
691− updatedAt: updatedAt ?? new Date(0).toISOString(),
692− };
693−
694− return yield* decodeReadModel(snapshot).pipe(
506+ ),
507+ listProjectionStateRows(undefined).pipe(
695508 Effect.mapError(
696− toPersistenceDecodeError("ProjectionSnapshotQuery.getSnapshot:decodeReadModel"),
509+ toPersistenceSqlOrDecodeError(
510+ "ProjectionSnapshotQuery.getSnapshot:listProjectionState:query",
511+ "ProjectionSnapshotQuery.getSnapshot:listProjectionState:decodeRows",
512+ ),
697513 ),
698− );
699− }),
514+ ),
515+ ]),
700516 )
701517 .pipe(
518+ Effect.flatMap(
519+ ([
520+ projectRows,
521+ threadRows,
522+ messageRows,
523+ proposedPlanRows,
524+ activityRows,
525+ sessionRows,
526+ checkpointRows,
527+ latestTurnRows,
528+ stateRows,
529+ ]) =>
530+ Effect.gen(function* () {
531+ const messagesByThread = new Map<string, Array<OrchestrationMessage>>();
532+ const proposedPlansByThread = new Map<string, Array<OrchestrationProposedPlan>>();
533+ const activitiesByThread = new Map<string, Array<OrchestrationThreadActivity>>();
534+ const checkpointsByThread = new Map<string, Array<OrchestrationCheckpointSummary>>();
535+ const sessionsByThread = new Map<string, OrchestrationSession>();
536+ const latestTurnByThread = new Map<string, OrchestrationLatestTurn>();
537+
538+ let updatedAt: string | null = null;
539+
540+ for (const row of projectRows) {
541+ updatedAt = maxIso(updatedAt, row.updatedAt);
542+ }
543+ for (const row of threadRows) {
544+ updatedAt = maxIso(updatedAt, row.updatedAt);
545+ }
546+ for (const row of stateRows) {
547+ updatedAt = maxIso(updatedAt, row.updatedAt);
548+ }
549+
550+ for (const row of messageRows) {
551+ updatedAt = maxIso(updatedAt, row.updatedAt);
552+ const threadMessages = messagesByThread.get(row.threadId) ?? [];
553+ threadMessages.push({
554+ id: row.messageId,
555+ role: row.role,
556+ text: row.text,
557+ ...(row.attachments !== null ? { attachments: row.attachments } : {}),
558+ turnId: row.turnId,
559+ streaming: row.isStreaming === 1,
560+ createdAt: row.createdAt,
561+ updatedAt: row.updatedAt,
562+ });
563+ messagesByThread.set(row.threadId, threadMessages);
564+ }
565+
566+ for (const row of proposedPlanRows) {
567+ updatedAt = maxIso(updatedAt, row.updatedAt);
568+ const threadProposedPlans = proposedPlansByThread.get(row.threadId) ?? [];
569+ threadProposedPlans.push({
570+ id: row.planId,
571+ turnId: row.turnId,
572+ planMarkdown: row.planMarkdown,
573+ implementedAt: row.implementedAt,
574+ implementationThreadId: row.implementationThreadId,
575+ createdAt: row.createdAt,
576+ updatedAt: row.updatedAt,
577+ });
578+ proposedPlansByThread.set(row.threadId, threadProposedPlans);
579+ }
580+
581+ for (const row of activityRows) {
582+ updatedAt = maxIso(updatedAt, row.createdAt);
583+ const threadActivities = activitiesByThread.get(row.threadId) ?? [];
584+ threadActivities.push({
585+ id: row.activityId,
586+ tone: row.tone,
587+ kind: row.kind,
588+ summary: row.summary,
589+ payload: row.payload,
590+ turnId: row.turnId,
591+ ...(row.sequence !== null ? { sequence: row.sequence } : {}),
592+ createdAt: row.createdAt,
593+ });
594+ activitiesByThread.set(row.threadId, threadActivities);
595+ }
596+
597+ for (const row of checkpointRows) {
598+ updatedAt = maxIso(updatedAt, row.completedAt);
599+ const threadCheckpoints = checkpointsByThread.get(row.threadId) ?? [];
600+ threadCheckpoints.push({
601+ turnId: row.turnId,
602+ checkpointTurnCount: row.checkpointTurnCount,
603+ checkpointRef: row.checkpointRef,
604+ status: row.status,
605+ files: row.files,
606+ assistantMessageId: row.assistantMessageId,
607+ completedAt: row.completedAt,
608+ });
609+ checkpointsByThread.set(row.threadId, threadCheckpoints);
610+ }
611+
612+ for (const row of latestTurnRows) {
613+ updatedAt = maxIso(updatedAt, row.requestedAt);
614+ if (row.startedAt !== null) {
615+ updatedAt = maxIso(updatedAt, row.startedAt);
616+ }
617+ if (row.completedAt !== null) {
618+ updatedAt = maxIso(updatedAt, row.completedAt);
619+ }
620+ if (latestTurnByThread.has(row.threadId)) {
621+ continue;
622+ }
623+ latestTurnByThread.set(row.threadId, {
624+ turnId: row.turnId,
625+ state:
626+ row.state === "error"
627+ ? "error"
628+ : row.state === "interrupted"
629+ ? "interrupted"
630+ : row.state === "completed"
631+ ? "completed"
632+ : "running",
633+ requestedAt: row.requestedAt,
634+ startedAt: row.startedAt,
635+ completedAt: row.completedAt,
636+ assistantMessageId: row.assistantMessageId,
637+ ...(row.sourceProposedPlanThreadId !== null && row.sourceProposedPlanId !== null
638+ ? {
639+ sourceProposedPlan: {
640+ threadId: row.sourceProposedPlanThreadId,
641+ planId: row.sourceProposedPlanId,
642+ },
643+ }
644+ : {}),
645+ });
646+ }
647+
648+ for (const row of sessionRows) {
649+ updatedAt = maxIso(updatedAt, row.updatedAt);
650+ sessionsByThread.set(row.threadId, {
651+ threadId: row.threadId,
652+ status: row.status,
653+ providerName: row.providerName,
654+ runtimeMode: row.runtimeMode,
655+ activeTurnId: row.activeTurnId,
656+ lastError: row.lastError,
657+ updatedAt: row.updatedAt,
658+ });
659+ }
660+
661+ const repositoryIdentities = new Map(
662+ yield* Effect.forEach(
663+ projectRows,
664+ (row) =>
665+ repositoryIdentityResolver
666+ .resolve(row.workspaceRoot)
667+ .pipe(Effect.map((identity) => [row.projectId, identity] as const)),
668+ { concurrency: repositoryIdentityResolutionConcurrency },
669+ ),
670+ );
671+
672+ const projects: ReadonlyArray<OrchestrationProject> = projectRows.map((row) => ({
673+ id: row.projectId,
674+ title: row.title,
675+ workspaceRoot: row.workspaceRoot,
676+ repositoryIdentity: repositoryIdentities.get(row.projectId) ?? null,
677+ defaultModelSelection: row.defaultModelSelection,
678+ scripts: row.scripts,
679+ createdAt: row.createdAt,
680+ updatedAt: row.updatedAt,
681+ deletedAt: row.deletedAt,
682+ }));
683+
684+ const threads: ReadonlyArray<OrchestrationThread> = threadRows.map((row) => ({
685+ id: row.threadId,
686+ projectId: row.projectId,
687+ title: row.title,
688+ modelSelection: row.modelSelection,
689+ runtimeMode: row.runtimeMode,
690+ interactionMode: row.interactionMode,
691+ branch: row.branch,
692+ worktreePath: row.worktreePath,
693+ latestTurn: latestTurnByThread.get(row.threadId) ?? null,
694+ createdAt: row.createdAt,
695+ updatedAt: row.updatedAt,
696+ archivedAt: row.archivedAt,
697+ deletedAt: row.deletedAt,
698+ messages: messagesByThread.get(row.threadId) ?? [],
699+ proposedPlans: proposedPlansByThread.get(row.threadId) ?? [],
700+ activities: activitiesByThread.get(row.threadId) ?? [],
701+ checkpoints: checkpointsByThread.get(row.threadId) ?? [],
702+ session: sessionsByThread.get(row.threadId) ?? null,
703+ }));
704+
705+ const snapshot = {
706+ snapshotSequence: computeSnapshotSequence(stateRows),
707+ projects,
708+ threads,
709+ updatedAt: updatedAt ?? new Date(0).toISOString(),
710+ };
711+
712+ return yield* decodeReadModel(snapshot).pipe(
713+ Effect.mapError(
714+ toPersistenceDecodeError("ProjectionSnapshotQuery.getSnapshot:decodeReadModel"),
715+ ),
716+ );
717+ }),
718+ ),
702719 Effect.mapError((error) => {
703720 if (isPersistenceError(error)) {
704721 return error;
@@ -732,19 +749,24 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 27 unmodified lines
732749 "ProjectionSnapshotQuery.getActiveProjectByWorkspaceRoot:decodeRow",
733750 ),
734751 ),
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− ),
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
apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+2−0
@@ -28,6 +28,7 @@ import { · 27 unmodified lines
2828 } from "../../provider/Services/ProviderService.ts";
2929 import { GitCore, type GitCoreShape } from "../../git/Services/GitCore.ts";
3030 import { TextGeneration, type TextGenerationShape } from "../../git/Services/TextGeneration.ts";
31+import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";
3132 import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
3233 import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
3334 import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
@@ -218,6 +219,7 @@ describe("ProviderCommandReactor", () => { · 184 unmodified lines
218219 Layer.provide(OrchestrationProjectionPipelineLive),
219220 Layer.provide(OrchestrationEventStoreLive),
220221 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
222+ Layer.provide(RepositoryIdentityResolverLive),
221223 Layer.provide(SqlitePersistenceMemory),
222224 );
223225 const layer = ProviderCommandReactorLive.pipe(
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+2−0
@@ -29,6 +29,7 @@ import { · 28 unmodified lines
2929 ProviderService,
3030 type ProviderServiceShape,
3131 } from "../../provider/Services/ProviderService.ts";
32+import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";
3233 import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
3334 import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
3435 import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
@@ -204,6 +205,7 @@ describe("ProviderRuntimeIngestion", () => { · 169 unmodified lines
204205 Layer.provide(OrchestrationProjectionPipelineLive),
205206 Layer.provide(OrchestrationEventStoreLive),
206207 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
208+ Layer.provide(RepositoryIdentityResolverLive),
207209 Layer.provide(SqlitePersistenceMemory),
208210 );
209211 const layer = ProviderRuntimeIngestionLive.pipe(
apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts+193−0
@@ -0,0 +1,193 @@
1+import * as NodeServices from "@effect/platform-node/NodeServices";
2+import { expect, it } from "@effect/vitest";
3+import { Duration, Effect, FileSystem, Layer } from "effect";
4+import { TestClock } from "effect/testing";
5+
6+import { runProcess } from "../../processRunner.ts";
7+import { RepositoryIdentityResolver } from "../Services/RepositoryIdentityResolver.ts";
8+import {
9+ makeRepositoryIdentityResolver,
10+ RepositoryIdentityResolverLive,
11+} from "./RepositoryIdentityResolver.ts";
12+
13+const git = (cwd: string, args: ReadonlyArray<string>) =>
14+ Effect.promise(() => runProcess("git", ["-C", cwd, ...args]));
15+
16+const makeRepositoryIdentityResolverTestLayer = (options: {
17+ readonly positiveCacheTtl?: Duration.Input;
18+ readonly negativeCacheTtl?: Duration.Input;
19+}) =>
20+ Layer.effect(
21+ RepositoryIdentityResolver,
22+ makeRepositoryIdentityResolver({
23+ cacheCapacity: 16,
24+ ...options,
25+ }),
26+ );
27+
28+it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
29+ it.effect("normalizes equivalent GitHub remotes into a stable repository identity", () =>
30+ Effect.gen(function* () {
31+ const fileSystem = yield* FileSystem.FileSystem;
32+ const cwd = yield* fileSystem.makeTempDirectoryScoped({
33+ prefix: "t3-repository-identity-test-",
34+ });
35+
36+ yield* git(cwd, ["init"]);
37+ yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]);
38+
39+ const resolver = yield* RepositoryIdentityResolver;
40+ const identity = yield* resolver.resolve(cwd);
41+
42+ expect(identity).not.toBeNull();
43+ expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code");
44+ expect(identity?.displayName).toBe("t3tools/t3code");
45+ expect(identity?.provider).toBe("github");
46+ expect(identity?.owner).toBe("t3tools");
47+ expect(identity?.name).toBe("t3code");
48+ }).pipe(Effect.provide(RepositoryIdentityResolverLive)),
49+ );
50+
51+ it.effect("returns null for non-git folders and repos without remotes", () =>
52+ Effect.gen(function* () {
53+ const fileSystem = yield* FileSystem.FileSystem;
54+ const nonGitDir = yield* fileSystem.makeTempDirectoryScoped({
55+ prefix: "t3-repository-identity-non-git-",
56+ });
57+ const gitDir = yield* fileSystem.makeTempDirectoryScoped({
58+ prefix: "t3-repository-identity-no-remote-",
59+ });
60+
61+ yield* git(gitDir, ["init"]);
62+
63+ const resolver = yield* RepositoryIdentityResolver;
64+ const nonGitIdentity = yield* resolver.resolve(nonGitDir);
65+ const noRemoteIdentity = yield* resolver.resolve(gitDir);
66+
67+ expect(nonGitIdentity).toBeNull();
68+ expect(noRemoteIdentity).toBeNull();
69+ }).pipe(Effect.provide(RepositoryIdentityResolverLive)),
70+ );
71+
72+ it.effect("prefers upstream over origin when both remotes are configured", () =>
73+ Effect.gen(function* () {
74+ const fileSystem = yield* FileSystem.FileSystem;
75+ const cwd = yield* fileSystem.makeTempDirectoryScoped({
76+ prefix: "t3-repository-identity-upstream-test-",
77+ });
78+
79+ yield* git(cwd, ["init"]);
80+ yield* git(cwd, ["remote", "add", "origin", "git@github.com:julius/t3code.git"]);
81+ yield* git(cwd, ["remote", "add", "upstream", "git@github.com:T3Tools/t3code.git"]);
82+
83+ const resolver = yield* RepositoryIdentityResolver;
84+ const identity = yield* resolver.resolve(cwd);
85+
86+ expect(identity).not.toBeNull();
87+ expect(identity?.locator.remoteName).toBe("upstream");
88+ expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code");
89+ expect(identity?.displayName).toBe("t3tools/t3code");
90+ }).pipe(Effect.provide(RepositoryIdentityResolverLive)),
91+ );
92+
93+ it.effect("uses the last remote path segment as the repository name for nested groups", () =>
94+ Effect.gen(function* () {
95+ const fileSystem = yield* FileSystem.FileSystem;
96+ const cwd = yield* fileSystem.makeTempDirectoryScoped({
97+ prefix: "t3-repository-identity-nested-group-test-",
98+ });
99+
100+ yield* git(cwd, ["init"]);
101+ yield* git(cwd, ["remote", "add", "origin", "git@gitlab.com:T3Tools/platform/t3code.git"]);
102+
103+ const resolver = yield* RepositoryIdentityResolver;
104+ const identity = yield* resolver.resolve(cwd);
105+
106+ expect(identity).not.toBeNull();
107+ expect(identity?.canonicalKey).toBe("gitlab.com/t3tools/platform/t3code");
108+ expect(identity?.displayName).toBe("t3tools/platform/t3code");
109+ expect(identity?.owner).toBe("t3tools");
110+ expect(identity?.name).toBe("t3code");
111+ }).pipe(Effect.provide(RepositoryIdentityResolverLive)),
112+ );
113+
114+ it.effect(
115+ "refreshes cached null identities after the negative TTL when a remote is configured later",
116+ () =>
117+ Effect.gen(function* () {
118+ const fileSystem = yield* FileSystem.FileSystem;
119+ const cwd = yield* fileSystem.makeTempDirectoryScoped({
120+ prefix: "t3-repository-identity-late-remote-test-",
121+ });
122+
123+ yield* git(cwd, ["init"]);
124+
125+ const resolver = yield* RepositoryIdentityResolver;
126+ const initialIdentity = yield* resolver.resolve(cwd);
127+ expect(initialIdentity).toBeNull();
128+
129+ yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]);
130+
131+ const cachedIdentity = yield* resolver.resolve(cwd);
132+ expect(cachedIdentity).toBeNull();
133+
134+ yield* TestClock.adjust(Duration.millis(120));
135+
136+ const refreshedIdentity = yield* resolver.resolve(cwd);
137+ expect(refreshedIdentity).not.toBeNull();
138+ expect(refreshedIdentity?.canonicalKey).toBe("github.com/t3tools/t3code");
139+ expect(refreshedIdentity?.name).toBe("t3code");
140+ }).pipe(
141+ Effect.provide(
142+ Layer.merge(
143+ TestClock.layer(),
144+ makeRepositoryIdentityResolverTestLayer({
145+ negativeCacheTtl: Duration.millis(50),
146+ positiveCacheTtl: Duration.seconds(1),
147+ }),
148+ ),
149+ ),
150+ ),
151+ );
152+
153+ it.effect("refreshes cached identities after the positive TTL when a remote changes", () =>
154+ Effect.gen(function* () {
155+ const fileSystem = yield* FileSystem.FileSystem;
156+ const cwd = yield* fileSystem.makeTempDirectoryScoped({
157+ prefix: "t3-repository-identity-remote-change-test-",
158+ });
159+
160+ yield* git(cwd, ["init"]);
161+ yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]);
162+
163+ const resolver = yield* RepositoryIdentityResolver;
164+ const initialIdentity = yield* resolver.resolve(cwd);
165+ expect(initialIdentity).not.toBeNull();
166+ expect(initialIdentity?.canonicalKey).toBe("github.com/t3tools/t3code");
167+
168+ yield* git(cwd, ["remote", "set-url", "origin", "git@github.com:T3Tools/t3code-next.git"]);
169+
170+ const cachedIdentity = yield* resolver.resolve(cwd);
171+ expect(cachedIdentity).not.toBeNull();
172+ expect(cachedIdentity?.canonicalKey).toBe("github.com/t3tools/t3code");
173+
174+ yield* TestClock.adjust(Duration.millis(180));
175+
176+ const refreshedIdentity = yield* resolver.resolve(cwd);
177+ expect(refreshedIdentity).not.toBeNull();
178+ expect(refreshedIdentity?.canonicalKey).toBe("github.com/t3tools/t3code-next");
179+ expect(refreshedIdentity?.displayName).toBe("t3tools/t3code-next");
180+ expect(refreshedIdentity?.name).toBe("t3code-next");
181+ }).pipe(
182+ Effect.provide(
183+ Layer.merge(
184+ TestClock.layer(),
185+ makeRepositoryIdentityResolverTestLayer({
186+ negativeCacheTtl: Duration.millis(50),
187+ positiveCacheTtl: Duration.millis(100),
188+ }),
189+ ),
190+ ),
191+ ),
192+ );
193+});
apps/server/src/project/Layers/RepositoryIdentityResolver.ts+147−0
@@ -0,0 +1,147 @@
1+import type { RepositoryIdentity } from "@t3tools/contracts";
2+import { Cache, Duration, Effect, Exit, Layer } from "effect";
3+import { detectGitHostingProviderFromRemoteUrl, normalizeGitRemoteUrl } from "@t3tools/shared/git";
4+
5+import { runProcess } from "../../processRunner.ts";
6+import {
7+ RepositoryIdentityResolver,
8+ type RepositoryIdentityResolverShape,
9+} from "../Services/RepositoryIdentityResolver.ts";
10+
11+function parseRemoteFetchUrls(stdout: string): Map<string, string> {
12+ const remotes = new Map<string, string>();
13+ for (const line of stdout.split("\n")) {
14+ const trimmed = line.trim();
15+ if (trimmed.length === 0) continue;
16+ const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/.exec(trimmed);
17+ if (!match) continue;
18+ const [, remoteName = "", remoteUrl = "", direction = ""] = match;
19+ if (direction !== "fetch" || remoteName.length === 0 || remoteUrl.length === 0) {
20+ continue;
21+ }
22+ remotes.set(remoteName, remoteUrl);
23+ }
24+ return remotes;
25+}
26+
27+function pickPrimaryRemote(
28+ remotes: ReadonlyMap<string, string>,
29+): { readonly remoteName: string; readonly remoteUrl: string } | null {
30+ for (const preferredRemoteName of ["upstream", "origin"] as const) {
31+ const remoteUrl = remotes.get(preferredRemoteName);
32+ if (remoteUrl) {
33+ return { remoteName: preferredRemoteName, remoteUrl };
34+ }
35+ }
36+
37+ const [remoteName, remoteUrl] =
38+ [...remotes.entries()].toSorted(([left], [right]) => left.localeCompare(right))[0] ?? [];
39+ return remoteName && remoteUrl ? { remoteName, remoteUrl } : null;
40+}
41+
42+function buildRepositoryIdentity(input: {
43+ readonly remoteName: string;
44+ readonly remoteUrl: string;
45+}): RepositoryIdentity {
46+ const canonicalKey = normalizeGitRemoteUrl(input.remoteUrl);
47+ const hostingProvider = detectGitHostingProviderFromRemoteUrl(input.remoteUrl);
48+ const repositoryPath = canonicalKey.split("/").slice(1).join("/");
49+ const repositoryPathSegments = repositoryPath.split("/").filter((segment) => segment.length > 0);
50+ const [owner] = repositoryPathSegments;
51+ const repositoryName = repositoryPathSegments.at(-1);
52+
53+ return {
54+ canonicalKey,
55+ locator: {
56+ source: "git-remote",
57+ remoteName: input.remoteName,
58+ remoteUrl: input.remoteUrl,
59+ },
60+ ...(repositoryPath ? { displayName: repositoryPath } : {}),
61+ ...(hostingProvider ? { provider: hostingProvider.kind } : {}),
62+ ...(owner ? { owner } : {}),
63+ ...(repositoryName ? { name: repositoryName } : {}),
64+ };
65+}
66+
67+const DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY = 512;
68+const DEFAULT_POSITIVE_CACHE_TTL = Duration.minutes(1);
69+const DEFAULT_NEGATIVE_CACHE_TTL = Duration.seconds(10);
70+
71+interface RepositoryIdentityResolverOptions {
72+ readonly cacheCapacity?: number;
73+ readonly positiveCacheTtl?: Duration.Input;
74+ readonly negativeCacheTtl?: Duration.Input;
75+}
76+
77+async function resolveRepositoryIdentityCacheKey(cwd: string): Promise<string> {
78+ let cacheKey = cwd;
79+
80+ try {
81+ const topLevelResult = await runProcess("git", ["-C", cwd, "rev-parse", "--show-toplevel"], {
82+ allowNonZeroExit: true,
83+ });
84+ if (topLevelResult.code !== 0) {
85+ return cacheKey;
86+ }
87+
88+ const candidate = topLevelResult.stdout.trim();
89+ if (candidate.length > 0) {
90+ cacheKey = candidate;
91+ }
92+ } catch {
93+ return cacheKey;
94+ }
95+
96+ return cacheKey;
97+}
98+
99+async function resolveRepositoryIdentityFromCacheKey(
100+ cacheKey: string,
101+): Promise<RepositoryIdentity | null> {
102+ try {
103+ const remoteResult = await runProcess("git", ["-C", cacheKey, "remote", "-v"], {
104+ allowNonZeroExit: true,
105+ });
106+ if (remoteResult.code !== 0) {
107+ return null;
108+ }
109+
110+ const remote = pickPrimaryRemote(parseRemoteFetchUrls(remoteResult.stdout));
111+ return remote ? buildRepositoryIdentity(remote) : null;
112+ } catch {
113+ return null;
114+ }
115+}
116+
117+export const makeRepositoryIdentityResolver = Effect.fn("makeRepositoryIdentityResolver")(
118+ function* (options: RepositoryIdentityResolverOptions = {}) {
119+ const repositoryIdentityCache = yield* Cache.makeWith<string, RepositoryIdentity | null>({
120+ capacity: options.cacheCapacity ?? DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY,
121+ lookup: (cacheKey) => Effect.promise(() => resolveRepositoryIdentityFromCacheKey(cacheKey)),
122+ timeToLive: Exit.match({
123+ onSuccess: (value) =>
124+ value === null
125+ ? (options.negativeCacheTtl ?? DEFAULT_NEGATIVE_CACHE_TTL)
126+ : (options.positiveCacheTtl ?? DEFAULT_POSITIVE_CACHE_TTL),
127+ onFailure: () => Duration.zero,
128+ }),
129+ });
130+
131+ const resolve: RepositoryIdentityResolverShape["resolve"] = Effect.fn(
132+ "RepositoryIdentityResolver.resolve",
133+ )(function* (cwd) {
134+ const cacheKey = yield* Effect.promise(() => resolveRepositoryIdentityCacheKey(cwd));
135+ return yield* Cache.get(repositoryIdentityCache, cacheKey);
136+ });
137+
138+ return {
139+ resolve,
140+ } satisfies RepositoryIdentityResolverShape;
141+ },
142+);
143+
144+export const RepositoryIdentityResolverLive = Layer.effect(
145+ RepositoryIdentityResolver,
146+ makeRepositoryIdentityResolver(),
147+);
apps/server/src/project/Services/RepositoryIdentityResolver.ts+12−0
@@ -0,0 +1,12 @@
1+import type { RepositoryIdentity } from "@t3tools/contracts";
2+import { ServiceMap } from "effect";
3+import type { Effect } from "effect";
4+
5+export interface RepositoryIdentityResolverShape {
6+ readonly resolve: (cwd: string) => Effect.Effect<RepositoryIdentity | null>;
7+}
8+
9+export class RepositoryIdentityResolver extends ServiceMap.Service<
10+ RepositoryIdentityResolver,
11+ RepositoryIdentityResolverShape
12+>()("t3/project/Services/RepositoryIdentityResolver") {}
apps/server/src/server.test.ts+245−1
@@ -5,6 +5,8 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; · 4 unmodified lines
55 import {
66 CommandId,
77 DEFAULT_SERVER_SETTINGS,
8+ EnvironmentId,
9+ EventId,
810 GitCommandError,
911 KeybindingRule,
1012 MessageId,
@@ -83,6 +85,14 @@ import { · 72 unmodified lines
8385 ProjectSetupScriptRunner,
8486 type ProjectSetupScriptRunnerShape,
8587 } from "./project/Services/ProjectSetupScriptRunner.ts";
88+import {
89+ RepositoryIdentityResolver,
90+ type RepositoryIdentityResolverShape,
91+} from "./project/Services/RepositoryIdentityResolver.ts";
92+import {
93+ ServerEnvironment,
94+ type ServerEnvironmentShape,
95+} from "./environment/Services/ServerEnvironment.ts";
8696 import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts";
8797 import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts";
8898 import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts";
@@ -93,6 +103,18 @@ const defaultModelSelection = { · 4 unmodified lines
93103 provider: "codex",
94104 model: "gpt-5-codex",
95105 } as const;
106+const testEnvironmentDescriptor = {
107+ environmentId: EnvironmentId.makeUnsafe("environment-test"),
108+ label: "Test environment",
109+ platform: {
110+ os: "darwin" as const,
111+ arch: "arm64" as const,
112+ },
113+ serverVersion: "0.0.0-test",
114+ capabilities: {
115+ repositoryIdentity: true,
116+ },
117+};
96118
97119 const makeDefaultOrchestrationReadModel = () => {
98120 const now = new Date().toISOString();
@@ -270,6 +292,8 @@ const buildAppUnderTest = (options?: { · 171 unmodified lines
270292 browserTraceCollector?: Partial<BrowserTraceCollectorShape>;
271293 serverLifecycleEvents?: Partial<ServerLifecycleEventsShape>;
272294 serverRuntimeStartup?: Partial<ServerRuntimeStartupShape>;
295+ serverEnvironment?: Partial<ServerEnvironmentShape>;
296+ repositoryIdentityResolver?: Partial<RepositoryIdentityResolverShape>;
273297 };
274298 }) =>
275299 Effect.gen(function* () {
@@ -416,6 +440,19 @@ const buildAppUnderTest = (options?: { · 140 unmodified lines
416440 ...options?.layers?.serverRuntimeStartup,
417441 }),
418442 ),
443+ Layer.provide(
444+ Layer.mock(ServerEnvironment)({
445+ getEnvironmentId: Effect.succeed(testEnvironmentDescriptor.environmentId),
446+ getDescriptor: Effect.succeed(testEnvironmentDescriptor),
447+ ...options?.layers?.serverEnvironment,
448+ }),
449+ ),
450+ Layer.provide(
451+ Layer.mock(RepositoryIdentityResolver)({
452+ resolve: () => Effect.succeed(null),
453+ ...options?.layers?.repositoryIdentityResolver,
454+ }),
455+ ),
419456 Layer.provide(workspaceAndProjectServicesLayer),
420457 Layer.provideMerge(FetchHttpClient.layer),
421458 Layer.provide(layerConfig),
@@ -1069,6 +1106,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 647 unmodified lines
10691106 sequence: 1,
10701107 type: "welcome" as const,
10711108 payload: {
1109+ environment: testEnvironmentDescriptor,
10721110 cwd: "/tmp/project",
10731111 projectName: "project",
10741112 },
@@ -1078,7 +1116,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 3 unmodified lines
10781116 version: 1 as const,
10791117 sequence: 2,
10801118 type: "ready" as const,
1081− payload: { at: new Date().toISOString() },
1119+ payload: { at: new Date().toISOString(), environment: testEnvironmentDescriptor },
10821120 });
10831121
10841122 yield* buildAppUnderTest({
@@ -1996,6 +2034,73 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 911 unmodified lines
19962034 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
19972035 );
19982036
2037+ it.effect("enriches replayed project events with repository identity metadata", () =>
2038+ Effect.gen(function* () {
2039+ const repositoryIdentity = {
2040+ canonicalKey: "github.com/t3tools/t3code",
2041+ locator: {
2042+ source: "git-remote" as const,
2043+ remoteName: "origin",
2044+ remoteUrl: "git@github.com:T3Tools/t3code.git",
2045+ },
2046+ displayName: "T3Tools/t3code",
2047+ provider: "github",
2048+ owner: "T3Tools",
2049+ name: "t3code",
2050+ };
2051+
2052+ yield* buildAppUnderTest({
2053+ layers: {
2054+ orchestrationEngine: {
2055+ readEvents: (_fromSequenceExclusive) =>
2056+ Stream.make({
2057+ sequence: 1,
2058+ eventId: EventId.makeUnsafe("event-1"),
2059+ aggregateKind: "project",
2060+ aggregateId: defaultProjectId,
2061+ occurredAt: "2026-04-05T00:00:00.000Z",
2062+ commandId: null,
2063+ causationEventId: null,
2064+ correlationId: null,
2065+ metadata: {},
2066+ type: "project.created",
2067+ payload: {
2068+ projectId: defaultProjectId,
2069+ title: "Default Project",
2070+ workspaceRoot: "/tmp/default-project",
2071+ defaultModelSelection,
2072+ scripts: [],
2073+ createdAt: "2026-04-05T00:00:00.000Z",
2074+ updatedAt: "2026-04-05T00:00:00.000Z",
2075+ },
2076+ } satisfies Extract<OrchestrationEvent, { type: "project.created" }>),
2077+ },
2078+ repositoryIdentityResolver: {
2079+ resolve: () => Effect.succeed(repositoryIdentity),
2080+ },
2081+ },
2082+ });
2083+
2084+ const wsUrl = yield* getWsServerUrl("/ws");
2085+ const replayResult = yield* Effect.scoped(
2086+ withWsRpcClient(wsUrl, (client) =>
2087+ client[ORCHESTRATION_WS_METHODS.replayEvents]({
2088+ fromSequenceExclusive: 0,
2089+ }),
2090+ ),
2091+ );
2092+
2093+ const replayedEvent = replayResult[0];
2094+ assert.equal(replayedEvent?.type, "project.created");
2095+ assert.deepEqual(
2096+ replayedEvent && replayedEvent.type === "project.created"
2097+ ? replayedEvent.payload.repositoryIdentity
2098+ : null,
2099+ repositoryIdentity,
2100+ );
2101+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
2102+ );
2103+
19992104 it.effect("closes thread terminals after a successful archive command", () =>
20002105 Effect.gen(function* () {
20012106 const threadId = ThreadId.makeUnsafe("thread-archive");
@@ -2498,6 +2603,145 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 496 unmodified lines
24982603 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
24992604 );
25002605
2606+ it.effect("enriches replayed project events only once before streaming them to subscribers", () =>
2607+ Effect.gen(function* () {
2608+ let resolveCalls = 0;
2609+ const repositoryIdentity = {
2610+ canonicalKey: "github.com/t3tools/t3code",
2611+ locator: {
2612+ source: "git-remote" as const,
2613+ remoteName: "origin",
2614+ remoteUrl: "git@github.com:t3tools/t3code.git",
2615+ },
2616+ displayName: "t3tools/t3code",
2617+ provider: "github" as const,
2618+ owner: "t3tools",
2619+ name: "t3code",
2620+ };
2621+
2622+ yield* buildAppUnderTest({
2623+ layers: {
2624+ orchestrationEngine: {
2625+ getReadModel: () =>
2626+ Effect.succeed({
2627+ ...makeDefaultOrchestrationReadModel(),
2628+ snapshotSequence: 0,
2629+ }),
2630+ readEvents: () =>
2631+ Stream.make({
2632+ sequence: 1,
2633+ eventId: EventId.makeUnsafe("event-1"),
2634+ aggregateKind: "project",
2635+ aggregateId: defaultProjectId,
2636+ occurredAt: "2026-04-06T00:00:00.000Z",
2637+ commandId: null,
2638+ causationEventId: null,
2639+ correlationId: null,
2640+ metadata: {},
2641+ type: "project.meta-updated",
2642+ payload: {
2643+ projectId: defaultProjectId,
2644+ title: "Replayed Project",
2645+ updatedAt: "2026-04-06T00:00:00.000Z",
2646+ },
2647+ } satisfies Extract<OrchestrationEvent, { type: "project.meta-updated" }>),
2648+ streamDomainEvents: Stream.empty,
2649+ },
2650+ repositoryIdentityResolver: {
2651+ resolve: () => {
2652+ resolveCalls += 1;
2653+ return Effect.succeed(repositoryIdentity);
2654+ },
2655+ },
2656+ },
2657+ });
2658+
2659+ const wsUrl = yield* getWsServerUrl("/ws");
2660+ const events = yield* Effect.scoped(
2661+ withWsRpcClient(wsUrl, (client) =>
2662+ client[WS_METHODS.subscribeOrchestrationDomainEvents]({}).pipe(
2663+ Stream.take(1),
2664+ Stream.runCollect,
2665+ ),
2666+ ),
2667+ );
2668+
2669+ const event = Array.from(events)[0];
2670+ assert.equal(resolveCalls, 1);
2671+ assert.equal(event?.type, "project.meta-updated");
2672+ assert.deepEqual(
2673+ event && event.type === "project.meta-updated" ? event.payload.repositoryIdentity : null,
2674+ repositoryIdentity,
2675+ );
2676+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
2677+ );
2678+
2679+ it.effect("enriches subscribed project meta updates with repository identity metadata", () =>
2680+ Effect.gen(function* () {
2681+ const repositoryIdentity = {
2682+ canonicalKey: "github.com/t3tools/t3code",
2683+ locator: {
2684+ source: "git-remote" as const,
2685+ remoteName: "upstream",
2686+ remoteUrl: "git@github.com:T3Tools/t3code.git",
2687+ },
2688+ displayName: "T3Tools/t3code",
2689+ provider: "github",
2690+ owner: "T3Tools",
2691+ name: "t3code",
2692+ };
2693+
2694+ yield* buildAppUnderTest({
2695+ layers: {
2696+ orchestrationEngine: {
2697+ getReadModel: () =>
2698+ Effect.succeed({
2699+ ...makeDefaultOrchestrationReadModel(),
2700+ snapshotSequence: 0,
2701+ }),
2702+ streamDomainEvents: Stream.make({
2703+ sequence: 1,
2704+ eventId: EventId.makeUnsafe("event-1"),
2705+ aggregateKind: "project",
2706+ aggregateId: defaultProjectId,
2707+ occurredAt: "2026-04-05T00:00:00.000Z",
2708+ commandId: null,
2709+ causationEventId: null,
2710+ correlationId: null,
2711+ metadata: {},
2712+ type: "project.meta-updated",
2713+ payload: {
2714+ projectId: defaultProjectId,
2715+ title: "Renamed Project",
2716+ updatedAt: "2026-04-05T00:00:00.000Z",
2717+ },
2718+ } satisfies Extract<OrchestrationEvent, { type: "project.meta-updated" }>),
2719+ },
2720+ repositoryIdentityResolver: {
2721+ resolve: () => Effect.succeed(repositoryIdentity),
2722+ },
2723+ },
2724+ });
2725+
2726+ const wsUrl = yield* getWsServerUrl("/ws");
2727+ const events = yield* Effect.scoped(
2728+ withWsRpcClient(wsUrl, (client) =>
2729+ client[WS_METHODS.subscribeOrchestrationDomainEvents]({}).pipe(
2730+ Stream.take(1),
2731+ Stream.runCollect,
2732+ ),
2733+ ),
2734+ );
2735+
2736+ const event = Array.from(events)[0];
2737+ assert.equal(event?.type, "project.meta-updated");
2738+ assert.deepEqual(
2739+ event && event.type === "project.meta-updated" ? event.payload.repositoryIdentity : null,
2740+ repositoryIdentity,
2741+ );
2742+ }).pipe(Effect.provide(NodeHttpServer.layerTest)),
2743+ );
2744+
25012745 it.effect("routes websocket rpc orchestration.getSnapshot errors", () =>
25022746 Effect.gen(function* () {
25032747 yield* buildAppUnderTest({
apps/server/src/server.ts+4−0
@@ -44,11 +44,13 @@ import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor" · 43 unmodified lines
4444 import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry";
4545 import { ServerSettingsLive } from "./serverSettings";
4646 import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver";
47+import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver";
4748 import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries";
4849 import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem";
4950 import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths";
5051 import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner";
5152 import { ObservabilityLive } from "./observability/Layers/Observability";
53+import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment";
5254
5355 const PtyAdapterLive = Layer.unwrap(
5456 Effect.gen(function* () {
@@ -199,6 +201,8 @@ const RuntimeDependenciesLive = ReactorLayerLive.pipe( · 144 unmodified lines
199201 Layer.provideMerge(ServerSettingsLive),
200202 Layer.provideMerge(WorkspaceLayerLive),
201203 Layer.provideMerge(ProjectFaviconResolverLive),
204+ Layer.provideMerge(RepositoryIdentityResolverLive),
205+ Layer.provideMerge(ServerEnvironmentLive),
202206
203207 // Misc.
204208 Layer.provideMerge(AnalyticsServiceLayerLive),
apps/server/src/serverLifecycleEvents.test.ts+10−0
@@ -1,3 +1,4 @@
1+import { EnvironmentId } from "@t3tools/contracts";
12 import { assert, it } from "@effect/vitest";
23 import { assertTrue } from "@effect/vitest/utils";
34 import { Effect, Option } from "effect";
@@ -9,12 +10,20 @@ it.effect( · 5 unmodified lines
910 () =>
1011 Effect.gen(function* () {
1112 const lifecycleEvents = yield* ServerLifecycleEvents;
13+ const environment = {
14+ environmentId: EnvironmentId.makeUnsafe("environment-test"),
15+ label: "Test environment",
16+ platform: { os: "darwin" as const, arch: "arm64" as const },
17+ serverVersion: "0.0.0-test",
18+ capabilities: { repositoryIdentity: true },
19+ };
1220
1321 const welcome = yield* lifecycleEvents
1422 .publish({
1523 version: 1,
1624 type: "welcome",
1725 payload: {
26+ environment,
1827 cwd: "/tmp/project",
1928 projectName: "project",
2029 },
@@ -29,6 +38,7 @@ it.effect( · 8 unmodified lines
2938 type: "ready",
3039 payload: {
3140 at: new Date().toISOString(),
41+ environment,
3242 },
3343 })
3444 .pipe(Effect.timeoutOption("50 millis"));
apps/server/src/serverRuntimeStartup.ts+12−2
@@ -27,6 +27,7 @@ import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnap · 26 unmodified lines
2727 import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor";
2828 import { ServerLifecycleEvents } from "./serverLifecycleEvents";
2929 import { ServerSettingsService } from "./serverSettings";
30+import { ServerEnvironment } from "./environment/Services/ServerEnvironment";
3031 import { AnalyticsService } from "./telemetry/Services/AnalyticsService";
3132
3233 const isWildcardHost = (host: string | undefined): boolean =>
@@ -262,6 +263,7 @@ const makeServerRuntimeStartup = Effect.gen(function* () { · 229 unmodified lines
262263 const orchestrationReactor = yield* OrchestrationReactor;
263264 const lifecycleEvents = yield* ServerLifecycleEvents;
264265 const serverSettings = yield* ServerSettingsService;
266+ const serverEnvironment = yield* ServerEnvironment;
265267
266268 const commandGate = yield* makeCommandGate;
267269 const httpListening = yield* Deferred.make<void>();
@@ -308,7 +310,9 @@ const makeServerRuntimeStartup = Effect.gen(function* () { · 40 unmodified lines
308310
309311 yield* Effect.logDebug("startup phase: preparing welcome payload");
310312 const welcome = yield* runStartupPhase("welcome.prepare", autoBootstrapWelcome);
313+ const environment = yield* serverEnvironment.getDescriptor;
311314 yield* Effect.logDebug("startup phase: publishing welcome event", {
315+ environmentId: environment.environmentId,
312316 cwd: welcome.cwd,
313317 projectName: welcome.projectName,
314318 bootstrapProjectId: welcome.bootstrapProjectId,
@@ -319,7 +323,10 @@ const makeServerRuntimeStartup = Effect.gen(function* () { · 4 unmodified lines
319323 lifecycleEvents.publish({
320324 version: 1,
321325 type: "welcome",
322− payload: welcome,
326+ payload: {
327+ environment,
328+ ...welcome,
329+ },
323330 }),
324331 );
325332 }).pipe(
@@ -354,7 +361,10 @@ const makeServerRuntimeStartup = Effect.gen(function* () { · 28 unmodified lines
354361 lifecycleEvents.publish({
355362 version: 1,
356363 type: "ready",
357− payload: { at: new Date().toISOString() },
364+ payload: {
365+ at: new Date().toISOString(),
366+ environment: yield* serverEnvironment.getDescriptor,
367+ },
358368 }),
359369 );
360370
apps/server/src/ws.ts+55−1
@@ -47,6 +47,8 @@ import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries"; · 46 unmodified lines
4747 import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem";
4848 import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths";
4949 import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptRunner";
50+import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentityResolver";
51+import { ServerEnvironment } from "./environment/Services/ServerEnvironment";
5052
5153 const WsRpcLayer = WsRpcGroup.toLayer(
5254 Effect.gen(function* () {
@@ -67,6 +69,8 @@ const WsRpcLayer = WsRpcGroup.toLayer( · 14 unmodified lines
6769 const workspaceEntries = yield* WorkspaceEntries;
6870 const workspaceFileSystem = yield* WorkspaceFileSystem;
6971 const projectSetupScriptRunner = yield* ProjectSetupScriptRunner;
72+ const repositoryIdentityResolver = yield* RepositoryIdentityResolver;
73+ const serverEnvironment = yield* ServerEnvironment;
7074 const serverCommandId = (tag: string) =>
7175 CommandId.makeUnsafe(`server:${tag}:${crypto.randomUUID()}`);
7276
@@ -113,6 +117,49 @@ const WsRpcLayer = WsRpcGroup.toLayer( · 40 unmodified lines
113117 });
114118 };
115119
120+ const enrichProjectEvent = (
121+ event: OrchestrationEvent,
122+ ): Effect.Effect<OrchestrationEvent, never, never> => {
123+ switch (event.type) {
124+ case "project.created":
125+ return repositoryIdentityResolver.resolve(event.payload.workspaceRoot).pipe(
126+ Effect.map((repositoryIdentity) => ({
127+ ...event,
128+ payload: {
129+ ...event.payload,
130+ repositoryIdentity,
131+ },
132+ })),
133+ );
134+ case "project.meta-updated":
135+ return Effect.gen(function* () {
136+ const workspaceRoot =
137+ event.payload.workspaceRoot ??
138+ (yield* orchestrationEngine.getReadModel()).projects.find(
139+ (project) => project.id === event.payload.projectId,
140+ )?.workspaceRoot ??
141+ null;
142+ if (workspaceRoot === null) {
143+ return event;
144+ }
145+
146+ const repositoryIdentity = yield* repositoryIdentityResolver.resolve(workspaceRoot);
147+ return {
148+ ...event,
149+ payload: {
150+ ...event.payload,
151+ repositoryIdentity,
152+ },
153+ } satisfies OrchestrationEvent;
154+ });
155+ default:
156+ return Effect.succeed(event);
157+ }
158+ };
159+
160+ const enrichOrchestrationEvents = (events: ReadonlyArray<OrchestrationEvent>) =>
161+ Effect.forEach(events, enrichProjectEvent, { concurrency: 4 });
162+
116163 const dispatchBootstrapTurnStart = (
117164 command: Extract<OrchestrationCommand, { type: "thread.turn.start" }>,
118165 ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> =>
@@ -329,8 +376,10 @@ const WsRpcLayer = WsRpcGroup.toLayer( · 210 unmodified lines
329376 const keybindingsConfig = yield* keybindings.loadConfigState;
330377 const providers = yield* providerRegistry.getProviders;
331378 const settings = yield* serverSettings.getSettings;
379+ const environment = yield* serverEnvironment.getDescriptor;
332380
333381 return {
382+ environment,
334383 cwd: config.cwd,
335384 keybindingsConfigPath: config.keybindingsConfigPath,
336385 keybindings: keybindingsConfig.keybindings,
@@ -435,6 +484,7 @@ const WsRpcLayer = WsRpcGroup.toLayer( · 98 unmodified lines
435484 ),
436485 ).pipe(
437486 Effect.map((events) => Array.from(events)),
487+ Effect.flatMap(enrichOrchestrationEvents),
438488 Effect.mapError(
439489 (cause) =>
440490 new OrchestrationReplayEventsError({
@@ -455,10 +505,14 @@ const WsRpcLayer = WsRpcGroup.toLayer( · 14 unmodified lines
455505 orchestrationEngine.readEvents(fromSequenceExclusive),
456506 ).pipe(
457507 Effect.map((events) => Array.from(events)),
508+ Effect.flatMap(enrichOrchestrationEvents),
458509 Effect.catch(() => Effect.succeed([] as Array<OrchestrationEvent>)),
459510 );
460511 const replayStream = Stream.fromIterable(replayEvents);
461− const source = Stream.merge(replayStream, orchestrationEngine.streamDomainEvents);
512+ const liveStream = orchestrationEngine.streamDomainEvents.pipe(
513+ Stream.mapEffect(enrichProjectEvent),
514+ );
515+ const source = Stream.merge(replayStream, liveStream);
462516 type SequenceState = {
463517 readonly nextSequence: number;
464518 readonly pendingBySequence: Map<number, OrchestrationEvent>;