Showing the changes to apps/server/src (20 of 121 files). Show all
Unified · Split
| @@ -30,6 +30,7 @@ export interface ServerDerivedPaths { · 29 unmodified lines | ||
| 30 | 30 | readonly providerEventLogPath: string; |
| 31 | 31 | readonly terminalLogsDir: string; |
| 32 | 32 | readonly anonymousIdPath: string; |
| 33 | + readonly environmentIdPath: string; | |
| 33 | 34 | } |
| 34 | 35 | |
| 35 | 36 | /** |
| @@ -83,6 +84,7 @@ export const deriveServerPaths = Effect.fn(function* ( · 47 unmodified lines | ||
| 83 | 84 | providerEventLogPath: join(providerLogsDir, "events.log"), |
| 84 | 85 | terminalLogsDir: join(logsDir, "terminals"), |
| 85 | 86 | anonymousIdPath: join(stateDir, "anonymous-id"), |
| 87 | + environmentIdPath: join(stateDir, "environment-id"), | |
| 86 | 88 | }; |
| 87 | 89 | }); |
| 88 | 90 | |
| @@ -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 | +}); | |
| @@ -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()); | |
| @@ -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") {} | |
| @@ -854,7 +854,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { · 853 unmodified lines | ||
| 854 | 854 | "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", |
| 855 | 855 | ); |
| 856 | 856 | }), |
| 857 | − 12_000, | |
| 857 | + 20_000, | |
| 858 | 858 | ); |
| 859 | 859 | |
| 860 | 860 | it.effect( |
| @@ -962,7 +962,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { · 101 unmodified lines | ||
| 962 | 962 | ), |
| 963 | 963 | ).toBe(false); |
| 964 | 964 | }), |
| 965 | − 12_000, | |
| 965 | + 20_000, | |
| 966 | 966 | ); |
| 967 | 967 | |
| 968 | 968 | it.effect("status returns merged PR state when latest PR was merged", () => |
| @@ -1685,7 +1685,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { · 716 unmodified lines | ||
| 1685 | 1685 | false, |
| 1686 | 1686 | ); |
| 1687 | 1687 | }), |
| 1688 | − 12_000, | |
| 1688 | + 20_000, | |
| 1689 | 1689 | ); |
| 1690 | 1690 | |
| 1691 | 1691 | it.effect( |
| @@ -20,6 +20,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; · 19 unmodified lines | ||
| 20 | 20 | import { CheckpointStoreLive } from "../../checkpointing/Layers/CheckpointStore.ts"; |
| 21 | 21 | import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts"; |
| 22 | 22 | import { GitCoreLive } from "../../git/Layers/GitCore.ts"; |
| 23 | +import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; | |
| 23 | 24 | import { CheckpointReactorLive } from "./CheckpointReactor.ts"; |
| 24 | 25 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; |
| 25 | 26 | import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; |
| @@ -256,6 +257,7 @@ describe("CheckpointReactor", () => { · 230 unmodified lines | ||
| 256 | 257 | Layer.provide(OrchestrationProjectionPipelineLive), |
| 257 | 258 | Layer.provide(OrchestrationEventStoreLive), |
| 258 | 259 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
| 260 | + Layer.provide(RepositoryIdentityResolverLive), | |
| 259 | 261 | Layer.provide(SqlitePersistenceMemory), |
| 260 | 262 | ); |
| 261 | 263 | |
| @@ -19,6 +19,7 @@ import { · 18 unmodified lines | ||
| 19 | 19 | OrchestrationEventStore, |
| 20 | 20 | type OrchestrationEventStoreShape, |
| 21 | 21 | } from "../../persistence/Services/OrchestrationEventStore.ts"; |
| 22 | +import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; | |
| 22 | 23 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; |
| 23 | 24 | import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; |
| 24 | 25 | import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; |
| @@ -45,6 +46,7 @@ async function createOrchestrationSystem() { · 20 unmodified lines | ||
| 45 | 46 | Layer.provide(OrchestrationProjectionPipelineLive), |
| 46 | 47 | Layer.provide(OrchestrationEventStoreLive), |
| 47 | 48 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
| 49 | + Layer.provide(RepositoryIdentityResolverLive), | |
| 48 | 50 | Layer.provide(SqlitePersistenceMemory), |
| 49 | 51 | Layer.provideMerge(ServerConfigLayer), |
| 50 | 52 | Layer.provideMerge(NodeServices.layer), |
| @@ -623,6 +625,7 @@ describe("OrchestrationEngine", () => { · 572 unmodified lines | ||
| 623 | 625 | Layer.provide(OrchestrationProjectionPipelineLive), |
| 624 | 626 | Layer.provide(Layer.succeed(OrchestrationEventStore, flakyStore)), |
| 625 | 627 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
| 628 | + Layer.provide(RepositoryIdentityResolverLive), | |
| 626 | 629 | Layer.provide(SqlitePersistenceMemory), |
| 627 | 630 | Layer.provideMerge(ServerConfigLayer), |
| 628 | 631 | Layer.provideMerge(NodeServices.layer), |
| @@ -719,6 +722,7 @@ describe("OrchestrationEngine", () => { · 90 unmodified lines | ||
| 719 | 722 | Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)), |
| 720 | 723 | Layer.provide(OrchestrationEventStoreLive), |
| 721 | 724 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
| 725 | + Layer.provide(RepositoryIdentityResolverLive), | |
| 722 | 726 | Layer.provide(SqlitePersistenceMemory), |
| 723 | 727 | ), |
| 724 | 728 | ); |
| @@ -861,6 +865,7 @@ describe("OrchestrationEngine", () => { · 136 unmodified lines | ||
| 861 | 865 | Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)), |
| 862 | 866 | Layer.provide(Layer.succeed(OrchestrationEventStore, nonTransactionalStore)), |
| 863 | 867 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
| 868 | + Layer.provide(RepositoryIdentityResolverLive), | |
| 864 | 869 | Layer.provide(SqlitePersistenceMemory), |
| 865 | 870 | ), |
| 866 | 871 | ); |
| @@ -20,6 +20,7 @@ import { · 19 unmodified lines | ||
| 20 | 20 | SqlitePersistenceMemory, |
| 21 | 21 | } from "../../persistence/Layers/Sqlite.ts"; |
| 22 | 22 | import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts"; |
| 23 | +import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; | |
| 23 | 24 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; |
| 24 | 25 | import { |
| 25 | 26 | ORCHESTRATION_PROJECTOR_NAMES, |
| @@ -1846,6 +1847,7 @@ const engineLayer = it.layer( · 1820 unmodified lines | ||
| 1846 | 1847 | Layer.provide(OrchestrationProjectionPipelineLive), |
| 1847 | 1848 | Layer.provide(OrchestrationEventStoreLive), |
| 1848 | 1849 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
| 1850 | + Layer.provide(RepositoryIdentityResolverLive), | |
| 1849 | 1851 | Layer.provideMerge(SqlitePersistenceMemory), |
| 1850 | 1852 | Layer.provideMerge( |
| 1851 | 1853 | ServerConfig.layerTest(process.cwd(), { |
| @@ -4,6 +4,7 @@ import { Effect, Layer } from "effect"; · 3 unmodified lines | ||
| 4 | 4 | import * as SqlClient from "effect/unstable/sql/SqlClient"; |
| 5 | 5 | |
| 6 | 6 | import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; |
| 7 | +import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; | |
| 7 | 8 | import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; |
| 8 | 9 | import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; |
| 9 | 10 | import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts"; |
| @@ -15,7 +16,10 @@ const asEventId = (value: string): EventId => EventId.makeUnsafe(value); · 5 unmodified lines | ||
| 15 | 16 | const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.makeUnsafe(value); |
| 16 | 17 | |
| 17 | 18 | const projectionSnapshotLayer = it.layer( |
| 18 | − OrchestrationProjectionSnapshotQueryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), | |
| 19 | + OrchestrationProjectionSnapshotQueryLive.pipe( | |
| 20 | + Layer.provideMerge(RepositoryIdentityResolverLive), | |
| 21 | + Layer.provideMerge(SqlitePersistenceMemory), | |
| 22 | + ), | |
| 19 | 23 | ); |
| 20 | 24 | |
| 21 | 25 | projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { |
| @@ -234,6 +238,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 212 unmodified lines | ||
| 234 | 238 | id: asProjectId("project-1"), |
| 235 | 239 | title: "Project 1", |
| 236 | 240 | workspaceRoot: "/tmp/project-1", |
| 241 | + repositoryIdentity: null, | |
| 237 | 242 | defaultModelSelection: { |
| 238 | 243 | provider: "codex", |
| 239 | 244 | model: "gpt-5-codex", |
| @@ -38,6 +38,7 @@ import { ProjectionThreadMessage } from "../../persistence/Services/ProjectionTh · 37 unmodified lines | ||
| 38 | 38 | import { ProjectionThreadProposedPlan } from "../../persistence/Services/ProjectionThreadProposedPlans.ts"; |
| 39 | 39 | import { ProjectionThreadSession } from "../../persistence/Services/ProjectionThreadSessions.ts"; |
| 40 | 40 | import { ProjectionThread } from "../../persistence/Services/ProjectionThreads.ts"; |
| 41 | +import { RepositoryIdentityResolver } from "../../project/Services/RepositoryIdentityResolver.ts"; | |
| 41 | 42 | import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; |
| 42 | 43 | import { |
| 43 | 44 | ProjectionSnapshotQuery, |
| @@ -163,6 +164,8 @@ function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: st · 119 unmodified lines | ||
| 163 | 164 | |
| 164 | 165 | const makeProjectionSnapshotQuery = Effect.gen(function* () { |
| 165 | 166 | const sql = yield* SqlClient.SqlClient; |
| 167 | + const repositoryIdentityResolver = yield* RepositoryIdentityResolver; | |
| 168 | + const repositoryIdentityResolutionConcurrency = 4; | |
| 166 | 169 | |
| 167 | 170 | const listProjectRows = SqlSchema.findAll({ |
| 168 | 171 | Request: Schema.Void, |
| @@ -436,269 +439,283 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 267 unmodified lines | ||
| 436 | 439 | const getSnapshot: ProjectionSnapshotQueryShape["getSnapshot"] = () => |
| 437 | 440 | sql |
| 438 | 441 | .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", | |
| 465 | 448 | ), |
| 466 | 449 | ), |
| 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", | |
| 473 | 456 | ), |
| 474 | 457 | ), |
| 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", | |
| 481 | 464 | ), |
| 482 | 465 | ), |
| 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", | |
| 489 | 472 | ), |
| 490 | 473 | ), |
| 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", | |
| 497 | 480 | ), |
| 498 | 481 | ), |
| 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", | |
| 505 | 488 | ), |
| 506 | 489 | ), |
| 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", | |
| 513 | 496 | ), |
| 514 | 497 | ), |
| 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", | |
| 521 | 504 | ), |
| 522 | 505 | ), |
| 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( | |
| 695 | 508 | Effect.mapError( |
| 696 | − toPersistenceDecodeError("ProjectionSnapshotQuery.getSnapshot:decodeReadModel"), | |
| 509 | + toPersistenceSqlOrDecodeError( | |
| 510 | + "ProjectionSnapshotQuery.getSnapshot:listProjectionState:query", | |
| 511 | + "ProjectionSnapshotQuery.getSnapshot:listProjectionState:decodeRows", | |
| 512 | + ), | |
| 697 | 513 | ), |
| 698 | − ); | |
| 699 | − }), | |
| 514 | + ), | |
| 515 | + ]), | |
| 700 | 516 | ) |
| 701 | 517 | .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 | + ), | |
| 702 | 719 | Effect.mapError((error) => { |
| 703 | 720 | if (isPersistenceError(error)) { |
| 704 | 721 | return error; |
| @@ -732,19 +749,24 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { · 27 unmodified lines | ||
| 732 | 749 | "ProjectionSnapshotQuery.getActiveProjectByWorkspaceRoot:decodeRow", |
| 733 | 750 | ), |
| 734 | 751 | ), |
| 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 | + ), | |
| 748 | 770 | ), |
| 749 | 771 | ); |
| 750 | 772 | |
| @@ -28,6 +28,7 @@ import { · 27 unmodified lines | ||
| 28 | 28 | } from "../../provider/Services/ProviderService.ts"; |
| 29 | 29 | import { GitCore, type GitCoreShape } from "../../git/Services/GitCore.ts"; |
| 30 | 30 | import { TextGeneration, type TextGenerationShape } from "../../git/Services/TextGeneration.ts"; |
| 31 | +import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; | |
| 31 | 32 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; |
| 32 | 33 | import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; |
| 33 | 34 | import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; |
| @@ -218,6 +219,7 @@ describe("ProviderCommandReactor", () => { · 184 unmodified lines | ||
| 218 | 219 | Layer.provide(OrchestrationProjectionPipelineLive), |
| 219 | 220 | Layer.provide(OrchestrationEventStoreLive), |
| 220 | 221 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
| 222 | + Layer.provide(RepositoryIdentityResolverLive), | |
| 221 | 223 | Layer.provide(SqlitePersistenceMemory), |
| 222 | 224 | ); |
| 223 | 225 | const layer = ProviderCommandReactorLive.pipe( |
| @@ -29,6 +29,7 @@ import { · 28 unmodified lines | ||
| 29 | 29 | ProviderService, |
| 30 | 30 | type ProviderServiceShape, |
| 31 | 31 | } from "../../provider/Services/ProviderService.ts"; |
| 32 | +import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts"; | |
| 32 | 33 | import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; |
| 33 | 34 | import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; |
| 34 | 35 | import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; |
| @@ -204,6 +205,7 @@ describe("ProviderRuntimeIngestion", () => { · 169 unmodified lines | ||
| 204 | 205 | Layer.provide(OrchestrationProjectionPipelineLive), |
| 205 | 206 | Layer.provide(OrchestrationEventStoreLive), |
| 206 | 207 | Layer.provide(OrchestrationCommandReceiptRepositoryLive), |
| 208 | + Layer.provide(RepositoryIdentityResolverLive), | |
| 207 | 209 | Layer.provide(SqlitePersistenceMemory), |
| 208 | 210 | ); |
| 209 | 211 | const layer = ProviderRuntimeIngestionLive.pipe( |
| @@ -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 | +}); | |
| @@ -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 | +); | |
| @@ -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") {} | |
| @@ -5,6 +5,8 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; · 4 unmodified lines | ||
| 5 | 5 | import { |
| 6 | 6 | CommandId, |
| 7 | 7 | DEFAULT_SERVER_SETTINGS, |
| 8 | + EnvironmentId, | |
| 9 | + EventId, | |
| 8 | 10 | GitCommandError, |
| 9 | 11 | KeybindingRule, |
| 10 | 12 | MessageId, |
| @@ -83,6 +85,14 @@ import { · 72 unmodified lines | ||
| 83 | 85 | ProjectSetupScriptRunner, |
| 84 | 86 | type ProjectSetupScriptRunnerShape, |
| 85 | 87 | } 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"; | |
| 86 | 96 | import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries.ts"; |
| 87 | 97 | import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem.ts"; |
| 88 | 98 | import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts"; |
| @@ -93,6 +103,18 @@ const defaultModelSelection = { · 4 unmodified lines | ||
| 93 | 103 | provider: "codex", |
| 94 | 104 | model: "gpt-5-codex", |
| 95 | 105 | } 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 | +}; | |
| 96 | 118 | |
| 97 | 119 | const makeDefaultOrchestrationReadModel = () => { |
| 98 | 120 | const now = new Date().toISOString(); |
| @@ -270,6 +292,8 @@ const buildAppUnderTest = (options?: { · 171 unmodified lines | ||
| 270 | 292 | browserTraceCollector?: Partial<BrowserTraceCollectorShape>; |
| 271 | 293 | serverLifecycleEvents?: Partial<ServerLifecycleEventsShape>; |
| 272 | 294 | serverRuntimeStartup?: Partial<ServerRuntimeStartupShape>; |
| 295 | + serverEnvironment?: Partial<ServerEnvironmentShape>; | |
| 296 | + repositoryIdentityResolver?: Partial<RepositoryIdentityResolverShape>; | |
| 273 | 297 | }; |
| 274 | 298 | }) => |
| 275 | 299 | Effect.gen(function* () { |
| @@ -416,6 +440,19 @@ const buildAppUnderTest = (options?: { · 140 unmodified lines | ||
| 416 | 440 | ...options?.layers?.serverRuntimeStartup, |
| 417 | 441 | }), |
| 418 | 442 | ), |
| 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 | + ), | |
| 419 | 456 | Layer.provide(workspaceAndProjectServicesLayer), |
| 420 | 457 | Layer.provideMerge(FetchHttpClient.layer), |
| 421 | 458 | Layer.provide(layerConfig), |
| @@ -1069,6 +1106,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 647 unmodified lines | ||
| 1069 | 1106 | sequence: 1, |
| 1070 | 1107 | type: "welcome" as const, |
| 1071 | 1108 | payload: { |
| 1109 | + environment: testEnvironmentDescriptor, | |
| 1072 | 1110 | cwd: "/tmp/project", |
| 1073 | 1111 | projectName: "project", |
| 1074 | 1112 | }, |
| @@ -1078,7 +1116,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 3 unmodified lines | ||
| 1078 | 1116 | version: 1 as const, |
| 1079 | 1117 | sequence: 2, |
| 1080 | 1118 | type: "ready" as const, |
| 1081 | − payload: { at: new Date().toISOString() }, | |
| 1119 | + payload: { at: new Date().toISOString(), environment: testEnvironmentDescriptor }, | |
| 1082 | 1120 | }); |
| 1083 | 1121 | |
| 1084 | 1122 | yield* buildAppUnderTest({ |
| @@ -1996,6 +2034,73 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 911 unmodified lines | ||
| 1996 | 2034 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 1997 | 2035 | ); |
| 1998 | 2036 | |
| 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 | + | |
| 1999 | 2104 | it.effect("closes thread terminals after a successful archive command", () => |
| 2000 | 2105 | Effect.gen(function* () { |
| 2001 | 2106 | const threadId = ThreadId.makeUnsafe("thread-archive"); |
| @@ -2498,6 +2603,145 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 496 unmodified lines | ||
| 2498 | 2603 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 2499 | 2604 | ); |
| 2500 | 2605 | |
| 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 | + | |
| 2501 | 2745 | it.effect("routes websocket rpc orchestration.getSnapshot errors", () => |
| 2502 | 2746 | Effect.gen(function* () { |
| 2503 | 2747 | yield* buildAppUnderTest({ |
| @@ -44,11 +44,13 @@ import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor" · 43 unmodified lines | ||
| 44 | 44 | import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry"; |
| 45 | 45 | import { ServerSettingsLive } from "./serverSettings"; |
| 46 | 46 | import { ProjectFaviconResolverLive } from "./project/Layers/ProjectFaviconResolver"; |
| 47 | +import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver"; | |
| 47 | 48 | import { WorkspaceEntriesLive } from "./workspace/Layers/WorkspaceEntries"; |
| 48 | 49 | import { WorkspaceFileSystemLive } from "./workspace/Layers/WorkspaceFileSystem"; |
| 49 | 50 | import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths"; |
| 50 | 51 | import { ProjectSetupScriptRunnerLive } from "./project/Layers/ProjectSetupScriptRunner"; |
| 51 | 52 | import { ObservabilityLive } from "./observability/Layers/Observability"; |
| 53 | +import { ServerEnvironmentLive } from "./environment/Layers/ServerEnvironment"; | |
| 52 | 54 | |
| 53 | 55 | const PtyAdapterLive = Layer.unwrap( |
| 54 | 56 | Effect.gen(function* () { |
| @@ -199,6 +201,8 @@ const RuntimeDependenciesLive = ReactorLayerLive.pipe( · 144 unmodified lines | ||
| 199 | 201 | Layer.provideMerge(ServerSettingsLive), |
| 200 | 202 | Layer.provideMerge(WorkspaceLayerLive), |
| 201 | 203 | Layer.provideMerge(ProjectFaviconResolverLive), |
| 204 | + Layer.provideMerge(RepositoryIdentityResolverLive), | |
| 205 | + Layer.provideMerge(ServerEnvironmentLive), | |
| 202 | 206 | |
| 203 | 207 | // Misc. |
| 204 | 208 | Layer.provideMerge(AnalyticsServiceLayerLive), |
| @@ -1,3 +1,4 @@ | ||
| 1 | +import { EnvironmentId } from "@t3tools/contracts"; | |
| 1 | 2 | import { assert, it } from "@effect/vitest"; |
| 2 | 3 | import { assertTrue } from "@effect/vitest/utils"; |
| 3 | 4 | import { Effect, Option } from "effect"; |
| @@ -9,12 +10,20 @@ it.effect( · 5 unmodified lines | ||
| 9 | 10 | () => |
| 10 | 11 | Effect.gen(function* () { |
| 11 | 12 | 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 | + }; | |
| 12 | 20 | |
| 13 | 21 | const welcome = yield* lifecycleEvents |
| 14 | 22 | .publish({ |
| 15 | 23 | version: 1, |
| 16 | 24 | type: "welcome", |
| 17 | 25 | payload: { |
| 26 | + environment, | |
| 18 | 27 | cwd: "/tmp/project", |
| 19 | 28 | projectName: "project", |
| 20 | 29 | }, |
| @@ -29,6 +38,7 @@ it.effect( · 8 unmodified lines | ||
| 29 | 38 | type: "ready", |
| 30 | 39 | payload: { |
| 31 | 40 | at: new Date().toISOString(), |
| 41 | + environment, | |
| 32 | 42 | }, |
| 33 | 43 | }) |
| 34 | 44 | .pipe(Effect.timeoutOption("50 millis")); |
| @@ -27,6 +27,7 @@ import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnap · 26 unmodified lines | ||
| 27 | 27 | import { OrchestrationReactor } from "./orchestration/Services/OrchestrationReactor"; |
| 28 | 28 | import { ServerLifecycleEvents } from "./serverLifecycleEvents"; |
| 29 | 29 | import { ServerSettingsService } from "./serverSettings"; |
| 30 | +import { ServerEnvironment } from "./environment/Services/ServerEnvironment"; | |
| 30 | 31 | import { AnalyticsService } from "./telemetry/Services/AnalyticsService"; |
| 31 | 32 | |
| 32 | 33 | const isWildcardHost = (host: string | undefined): boolean => |
| @@ -262,6 +263,7 @@ const makeServerRuntimeStartup = Effect.gen(function* () { · 229 unmodified lines | ||
| 262 | 263 | const orchestrationReactor = yield* OrchestrationReactor; |
| 263 | 264 | const lifecycleEvents = yield* ServerLifecycleEvents; |
| 264 | 265 | const serverSettings = yield* ServerSettingsService; |
| 266 | + const serverEnvironment = yield* ServerEnvironment; | |
| 265 | 267 | |
| 266 | 268 | const commandGate = yield* makeCommandGate; |
| 267 | 269 | const httpListening = yield* Deferred.make<void>(); |
| @@ -308,7 +310,9 @@ const makeServerRuntimeStartup = Effect.gen(function* () { · 40 unmodified lines | ||
| 308 | 310 | |
| 309 | 311 | yield* Effect.logDebug("startup phase: preparing welcome payload"); |
| 310 | 312 | const welcome = yield* runStartupPhase("welcome.prepare", autoBootstrapWelcome); |
| 313 | + const environment = yield* serverEnvironment.getDescriptor; | |
| 311 | 314 | yield* Effect.logDebug("startup phase: publishing welcome event", { |
| 315 | + environmentId: environment.environmentId, | |
| 312 | 316 | cwd: welcome.cwd, |
| 313 | 317 | projectName: welcome.projectName, |
| 314 | 318 | bootstrapProjectId: welcome.bootstrapProjectId, |
| @@ -319,7 +323,10 @@ const makeServerRuntimeStartup = Effect.gen(function* () { · 4 unmodified lines | ||
| 319 | 323 | lifecycleEvents.publish({ |
| 320 | 324 | version: 1, |
| 321 | 325 | type: "welcome", |
| 322 | − payload: welcome, | |
| 326 | + payload: { | |
| 327 | + environment, | |
| 328 | + ...welcome, | |
| 329 | + }, | |
| 323 | 330 | }), |
| 324 | 331 | ); |
| 325 | 332 | }).pipe( |
| @@ -354,7 +361,10 @@ const makeServerRuntimeStartup = Effect.gen(function* () { · 28 unmodified lines | ||
| 354 | 361 | lifecycleEvents.publish({ |
| 355 | 362 | version: 1, |
| 356 | 363 | type: "ready", |
| 357 | − payload: { at: new Date().toISOString() }, | |
| 364 | + payload: { | |
| 365 | + at: new Date().toISOString(), | |
| 366 | + environment: yield* serverEnvironment.getDescriptor, | |
| 367 | + }, | |
| 358 | 368 | }), |
| 359 | 369 | ); |
| 360 | 370 | |
| @@ -47,6 +47,8 @@ import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries"; · 46 unmodified lines | ||
| 47 | 47 | import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem"; |
| 48 | 48 | import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths"; |
| 49 | 49 | import { ProjectSetupScriptRunner } from "./project/Services/ProjectSetupScriptRunner"; |
| 50 | +import { RepositoryIdentityResolver } from "./project/Services/RepositoryIdentityResolver"; | |
| 51 | +import { ServerEnvironment } from "./environment/Services/ServerEnvironment"; | |
| 50 | 52 | |
| 51 | 53 | const WsRpcLayer = WsRpcGroup.toLayer( |
| 52 | 54 | Effect.gen(function* () { |
| @@ -67,6 +69,8 @@ const WsRpcLayer = WsRpcGroup.toLayer( · 14 unmodified lines | ||
| 67 | 69 | const workspaceEntries = yield* WorkspaceEntries; |
| 68 | 70 | const workspaceFileSystem = yield* WorkspaceFileSystem; |
| 69 | 71 | const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; |
| 72 | + const repositoryIdentityResolver = yield* RepositoryIdentityResolver; | |
| 73 | + const serverEnvironment = yield* ServerEnvironment; | |
| 70 | 74 | const serverCommandId = (tag: string) => |
| 71 | 75 | CommandId.makeUnsafe(`server:${tag}:${crypto.randomUUID()}`); |
| 72 | 76 | |
| @@ -113,6 +117,49 @@ const WsRpcLayer = WsRpcGroup.toLayer( · 40 unmodified lines | ||
| 113 | 117 | }); |
| 114 | 118 | }; |
| 115 | 119 | |
| 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 | + | |
| 116 | 163 | const dispatchBootstrapTurnStart = ( |
| 117 | 164 | command: Extract<OrchestrationCommand, { type: "thread.turn.start" }>, |
| 118 | 165 | ): Effect.Effect<{ readonly sequence: number }, OrchestrationDispatchCommandError> => |
| @@ -329,8 +376,10 @@ const WsRpcLayer = WsRpcGroup.toLayer( · 210 unmodified lines | ||
| 329 | 376 | const keybindingsConfig = yield* keybindings.loadConfigState; |
| 330 | 377 | const providers = yield* providerRegistry.getProviders; |
| 331 | 378 | const settings = yield* serverSettings.getSettings; |
| 379 | + const environment = yield* serverEnvironment.getDescriptor; | |
| 332 | 380 | |
| 333 | 381 | return { |
| 382 | + environment, | |
| 334 | 383 | cwd: config.cwd, |
| 335 | 384 | keybindingsConfigPath: config.keybindingsConfigPath, |
| 336 | 385 | keybindings: keybindingsConfig.keybindings, |
| @@ -435,6 +484,7 @@ const WsRpcLayer = WsRpcGroup.toLayer( · 98 unmodified lines | ||
| 435 | 484 | ), |
| 436 | 485 | ).pipe( |
| 437 | 486 | Effect.map((events) => Array.from(events)), |
| 487 | + Effect.flatMap(enrichOrchestrationEvents), | |
| 438 | 488 | Effect.mapError( |
| 439 | 489 | (cause) => |
| 440 | 490 | new OrchestrationReplayEventsError({ |
| @@ -455,10 +505,14 @@ const WsRpcLayer = WsRpcGroup.toLayer( · 14 unmodified lines | ||
| 455 | 505 | orchestrationEngine.readEvents(fromSequenceExclusive), |
| 456 | 506 | ).pipe( |
| 457 | 507 | Effect.map((events) => Array.from(events)), |
| 508 | + Effect.flatMap(enrichOrchestrationEvents), | |
| 458 | 509 | Effect.catch(() => Effect.succeed([] as Array<OrchestrationEvent>)), |
| 459 | 510 | ); |
| 460 | 511 | 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); | |
| 462 | 516 | type SequenceState = { |
| 463 | 517 | readonly nextSequence: number; |
| 464 | 518 | readonly pendingBySequence: Map<number, OrchestrationEvent>; |