diff --git a/apps/server/package.json b/apps/server/package.json index 58f67485a7f..e3979cceab2 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -21,6 +21,7 @@ "build:preflight": "tsdown --config tsdown.preflight.config.ts", "start": "node dist/bin.mjs start --port 8080", "typecheck": "tsc --noEmit", + "typecheck:tsgo": "tsgo --noEmit", "test": "vitest run --coverage", "test:fast": "vitest run" }, diff --git a/apps/server/src/auth/Layers/BootstrapCredentialService.ts b/apps/server/src/auth/Layers/BootstrapCredentialService.ts index 77d865eca68..61481532ba8 100644 --- a/apps/server/src/auth/Layers/BootstrapCredentialService.ts +++ b/apps/server/src/auth/Layers/BootstrapCredentialService.ts @@ -1,4 +1,5 @@ import type { AuthPairingLink } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -46,6 +47,7 @@ const generatePairingToken = (): string => { }; export const makeBootstrapCredentialService = Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; const config = yield* ServerConfig; const pairingLinks = yield* AuthPairingLinkRepository; const seededGrantsRef = yield* Ref.make(new Map()); @@ -141,7 +143,7 @@ export const makeBootstrapCredentialService = Effect.gen(function* () { const issueOneTimeToken: BootstrapCredentialServiceShape["issueOneTimeToken"] = (input) => Effect.gen(function* () { - const id = crypto.randomUUID(); + const id = yield* crypto.randomUUIDv4.pipe(Effect.orDie); const credential = generatePairingToken(); const ttl = input?.ttl ?? DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES; const now = yield* DateTime.now; diff --git a/apps/server/src/auth/Layers/ServerAuth.ts b/apps/server/src/auth/Layers/ServerAuth.ts index 13ee8869613..1895d6ba6b6 100644 --- a/apps/server/src/auth/Layers/ServerAuth.ts +++ b/apps/server/src/auth/Layers/ServerAuth.ts @@ -182,7 +182,7 @@ export const makeServerAuth = Effect.gen(function* () { if (credential) { const verified = yield* authenticateToken(credential).pipe( Effect.map((session): Option.Option => Option.some(session)), - Effect.catch(() => Effect.succeed(Option.none())), + Effect.orElseSucceed(() => Option.none()), ); if (Option.isSome(verified)) { const session = verified.value; @@ -202,7 +202,7 @@ export const makeServerAuth = Effect.gen(function* () { if (loopbackBypassEnabled && isLoopbackRequest(request)) { const issued = yield* issueLoopbackSession(request).pipe( Effect.map((session): Option.Option => Option.some(session)), - Effect.catch(() => Effect.succeed(Option.none())), + Effect.orElseSucceed(() => Option.none()), ); if (Option.isSome(issued)) { const session = issued.value; diff --git a/apps/server/src/auth/Layers/ServerSecretStore.ts b/apps/server/src/auth/Layers/ServerSecretStore.ts index e65d25d1b6d..6e9609df2b8 100644 --- a/apps/server/src/auth/Layers/ServerSecretStore.ts +++ b/apps/server/src/auth/Layers/ServerSecretStore.ts @@ -167,7 +167,7 @@ export const makeServerSecretStore = Effect.gen(function* () { Effect.gen(function* () { const entries = yield* fileSystem .readDirectory(serverConfig.secretsDir) - .pipe(Effect.catch(() => Effect.succeed>([]))); + .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); for (const entry of entries) { if (!entry.endsWith(".bin")) { continue; diff --git a/apps/server/src/auth/Layers/SessionCredentialService.ts b/apps/server/src/auth/Layers/SessionCredentialService.ts index 31e2b13240a..67828a0ffa9 100644 --- a/apps/server/src/auth/Layers/SessionCredentialService.ts +++ b/apps/server/src/auth/Layers/SessionCredentialService.ts @@ -1,5 +1,6 @@ import { AuthSessionId, type AuthClientMetadata, type AuthClientSession } from "@t3tools/contracts"; import * as Clock from "effect/Clock"; +import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -90,6 +91,7 @@ function toAuthClientSession(input: Omit): AuthCli } export const makeSessionCredentialService = Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; const serverConfig = yield* ServerConfig; const secretStore = yield* ServerSecretStore; const authSessions = yield* AuthSessionRepository; @@ -203,7 +205,7 @@ export const makeSessionCredentialService = Effect.gen(function* () { const encodeClaims = Schema.encodeEffect(Schema.fromJsonString(SessionClaims)); const issue: SessionCredentialServiceShape["issue"] = (input) => Effect.gen(function* () { - const sessionId = AuthSessionId.make(crypto.randomUUID()); + const sessionId = AuthSessionId.make(yield* crypto.randomUUIDv4.pipe(Effect.orDie)); const issuedAt = yield* DateTime.now; const expiresAt = DateTime.add(issuedAt, { milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_SESSION_TTL), diff --git a/apps/server/src/checkpointing/Layers/CheckpointStore.ts b/apps/server/src/checkpointing/Layers/CheckpointStore.ts index bf4b7c13fef..91e8db3a5b4 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointStore.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointStore.ts @@ -105,7 +105,7 @@ const makeCheckpointStore = Effect.gen(function* () { }) .pipe( Effect.map((result) => result.exitCode === 0 && result.stdout.trim() === "true"), - Effect.catch(() => Effect.succeed(false)), + Effect.orElseSucceed(() => false), ); const captureCheckpoint: CheckpointStoreShape["captureCheckpoint"] = Effect.fn( diff --git a/apps/server/src/cli/project.ts b/apps/server/src/cli/project.ts index 706f2fb505b..6b878645855 100644 --- a/apps/server/src/cli/project.ts +++ b/apps/server/src/cli/project.ts @@ -5,6 +5,7 @@ import { type ClientOrchestrationCommand, } from "@t3tools/contracts"; import * as Console from "effect/Console"; +import * as Crypto from "effect/Crypto"; import * as Data from "effect/Data"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; @@ -59,6 +60,8 @@ class ProjectCommandError extends Data.TaggedError("ProjectCommandError")<{ readonly message: string; }> {} +const projectCommandUuid = Crypto.Crypto.pipe(Effect.flatMap((crypto) => crypto.randomUUIDv4)); + const ProjectCliRuntimeLive = Layer.mergeAll( WorkspacePathsLive, OrchestrationLayerLive.pipe( @@ -103,7 +106,7 @@ const decodeOrchestrationReadModelResponse = (response: HttpClientResponse.HttpC const readErrorMessageFromResponse = (response: HttpClientResponse.HttpClientResponse) => HttpClientResponse.schemaBodyJson(OrchestrationHttpErrorResponse)(response).pipe( Effect.map((body) => body.error), - Effect.catch(() => Effect.succeed(null)), + Effect.orElseSucceed(() => null), Effect.map((body) => { if (typeof body === "string" && body.trim().length > 0) { return body; @@ -260,7 +263,7 @@ const runProjectMutation = Effect.fn("runProjectMutation")(function* ( }) => Effect.Effect< string, Error, - FileSystem.FileSystem | HttpClient.HttpClient | Path.Path | WorkspacePaths + Crypto.Crypto | FileSystem.FileSystem | HttpClient.HttpClient | Path.Path | WorkspacePaths >, ) { const logLevel = yield* GlobalFlag.LogLevel; @@ -343,10 +346,10 @@ const projectAddCommand = Command.make("add", { } const title = yield* resolveProjectTitle(workspaceRoot, Option.getOrUndefined(flags.title)); - const projectId = ProjectId.make(crypto.randomUUID()); + const projectId = ProjectId.make(yield* projectCommandUuid); yield* dispatch({ type: "project.create", - commandId: CommandId.make(crypto.randomUUID()), + commandId: CommandId.make(yield* projectCommandUuid), projectId, title, workspaceRoot, @@ -384,7 +387,7 @@ const projectRemoveCommand = Command.make("remove", { }); yield* dispatch({ type: "project.delete", - commandId: CommandId.make(crypto.randomUUID()), + commandId: CommandId.make(yield* projectCommandUuid), projectId: project.id, }); return `Removed project ${project.id} (${project.title}).`; @@ -424,7 +427,7 @@ const projectRenameCommand = Command.make("rename", { yield* dispatch({ type: "project.meta.update", - commandId: CommandId.make(crypto.randomUUID()), + commandId: CommandId.make(yield* projectCommandUuid), projectId: project.id, title: nextTitle, }); diff --git a/apps/server/src/daemonLauncher.ts b/apps/server/src/daemonLauncher.ts index 948144bf5bc..4db5732e25a 100644 --- a/apps/server/src/daemonLauncher.ts +++ b/apps/server/src/daemonLauncher.ts @@ -1,6 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off // @effect-diagnostics importFromBarrel:off // @effect-diagnostics globalTimers:off +// @effect-diagnostics globalTimersInEffect:off globalFetchInEffect:off globalFetch:off -- native fetch + AbortController timeout, wrapped in Effect.tryPromise (fire-once bootstrap/shutdown HTTP probes). globalFetch* = tsgo + language-service rule names for the same check. // @effect-diagnostics globalErrorInEffectFailure:off import { spawn } from "node:child_process"; import * as fs from "node:fs"; @@ -75,7 +76,7 @@ const probeHealth = (origin: string) => } }, catch: (cause) => new DaemonLauncherError({ message: "health probe failed", cause }), - }).pipe(Effect.catch(() => Effect.succeed(false))); + }).pipe(Effect.orElseSucceed(() => false)); const pollHealth = (statePath: string) => Effect.gen(function* () { @@ -229,7 +230,7 @@ const fetchPairingStartupUrl = (origin: string) => } }, catch: (cause) => new DaemonLauncherError({ message: "pairing-startup request failed", cause }), - }).pipe(Effect.catch(() => Effect.succeed(null))); + }).pipe(Effect.orElseSucceed(() => null)); const resolveDerivedPaths = (input: { readonly baseDirOverride: string | undefined; @@ -373,7 +374,7 @@ export const runStopCommand = (input: StopCommandInput) => } }, catch: (cause) => new DaemonLauncherError({ message: "shutdown request failed", cause }), - }).pipe(Effect.catch(() => Effect.succeed(false))); + }).pipe(Effect.orElseSucceed(() => false)); if (!sent) { yield* Console.log(""); @@ -400,7 +401,7 @@ export const runStopCommand = (input: StopCommandInput) => schedule: Schedule.spaced(Duration.millis(STOP_DRAIN_INTERVAL_MS)), times: STOP_DRAIN_RETRIES, }), - Effect.catch(() => Effect.succeed(false)), + Effect.orElseSucceed(() => false), ); yield* Console.log(""); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index f726f05f401..c3392539062 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -696,7 +696,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp"; const canonicalizeExistingPath = (value: string) => - fileSystem.realPath(value).pipe(Effect.catch(() => Effect.succeed(value))); + fileSystem.realPath(value).pipe(Effect.orElseSucceed(() => value)); const normalizeStatusCacheKey = canonicalizeExistingPath; const nonRepositoryStatusDetails = { isRepo: false, @@ -760,7 +760,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { if (details.isDefaultBranch && latest.state !== "open") return null; return toStatusPr(latest); }), - Effect.catch(() => Effect.succeed(null)), + Effect.orElseSucceed(() => null), ) : null; @@ -782,7 +782,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { ); const readConfigValueNullable = (cwd: string, key: string) => - gitCore.readConfigValue(cwd, key).pipe(Effect.catch(() => Effect.succeed(null))); + gitCore.readConfigValue(cwd, key).pipe(Effect.orElseSucceed(() => null)); const resolveHostingProvider = Effect.fn("resolveHostingProvider")(function* ( cwd: string, @@ -966,7 +966,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { ) { const terms = yield* sourceControlProvider(cwd).pipe( Effect.map((provider) => getChangeRequestTerminologyForKind(provider.kind)), - Effect.catch(() => Effect.succeed(getChangeRequestTerminologyForKind("unknown"))), + Effect.orElseSucceed(() => getChangeRequestTerminologyForKind("unknown")), ); const summary = summarizeGitActionResult(result, terms); let latestOpenPr: PullRequestInfo | null = null; @@ -1010,7 +1010,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { upstreamRef: finalBranchContext.upstreamRef, }).pipe( Effect.flatMap((headContext) => findOpenPr(cwd, headContext)), - Effect.catch(() => Effect.succeed(null)), + Effect.orElseSucceed(() => null), ); } @@ -1074,7 +1074,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const defaultFromProvider = yield* sourceControlProvider(cwd).pipe( Effect.flatMap((provider) => provider.getDefaultBranch({ cwd })), - Effect.catch(() => Effect.succeed(null)), + Effect.orElseSucceed(() => null), ); if (defaultFromProvider) { return defaultFromProvider; @@ -1680,7 +1680,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const changeRequestTerms = wantsPr ? yield* sourceControlProvider(input.cwd).pipe( Effect.map((provider) => getChangeRequestTerminologyForKind(provider.kind)), - Effect.catch(() => Effect.succeed(getChangeRequestTerminologyForKind("unknown"))), + Effect.orElseSucceed(() => getChangeRequestTerminologyForKind("unknown")), ) : null; diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index 87310d8171c..31b60e8a3c5 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -131,10 +131,11 @@ export const pairingStartupRouteLayer = prefixedRouteLayer( ); return HttpServerResponse.jsonUnsafe({ url }, { status: 200 }); }).pipe( - Effect.catchTag("LoopbackOnlyError", respondToLoopbackOnlyError), - Effect.catchTag("PairingStartupError", (error) => - Effect.succeed(HttpServerResponse.jsonUnsafe({ error: error.message }, { status: 500 })), - ), + Effect.catchTags({ + LoopbackOnlyError: respondToLoopbackOnlyError, + PairingStartupError: (error) => + Effect.succeed(HttpServerResponse.jsonUnsafe({ error: error.message }, { status: 500 })), + }), ), ); @@ -210,7 +211,7 @@ export const attachmentsRouteLayer = prefixedRouteLayer( const fileSystem = yield* FileSystem.FileSystem; const fileInfo = yield* fileSystem .stat(filePath) - .pipe(Effect.catch(() => Effect.succeed(null))); + .pipe(Effect.orElseSucceed(() => null)); if (!fileInfo || fileInfo.type !== "File") { return HttpServerResponse.text("Not Found", { status: 404 }); } @@ -221,8 +222,8 @@ export const attachmentsRouteLayer = prefixedRouteLayer( "Cache-Control": "public, max-age=31536000, immutable", }, }).pipe( - Effect.catch(() => - Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 })), + Effect.orElseSucceed(() => + HttpServerResponse.text("Internal Server Error", { status: 500 }), ), ); }).pipe(Effect.catchTag("AuthError", respondToAuthError)), @@ -262,8 +263,8 @@ export const projectFaviconRouteLayer = prefixedRouteLayer( "Cache-Control": PROJECT_FAVICON_CACHE_CONTROL, }, }).pipe( - Effect.catch(() => - Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 })), + Effect.orElseSucceed(() => + HttpServerResponse.text("Internal Server Error", { status: 500 }), ), ); }).pipe(Effect.catchTag("AuthError", respondToAuthError)), @@ -345,12 +346,12 @@ export const staticAndDevRouteLayer = HttpRouter.add( const fileInfo = yield* fileSystem .stat(filePath) - .pipe(Effect.catch(() => Effect.succeed(null))); + .pipe(Effect.orElseSucceed(() => null)); if (!fileInfo || fileInfo.type !== "File") { const indexPath = path.resolve(staticRoot, "index.html"); const indexData = yield* fileSystem .readFile(indexPath) - .pipe(Effect.catch(() => Effect.succeed(null))); + .pipe(Effect.orElseSucceed(() => null)); if (!indexData) { return HttpServerResponse.text("Not Found", { status: 404 }); } @@ -368,7 +369,7 @@ export const staticAndDevRouteLayer = HttpRouter.add( const contentType = Mime.getType(filePath) ?? "application/octet-stream"; const data = yield* fileSystem .readFile(filePath) - .pipe(Effect.catch(() => Effect.succeed(null))); + .pipe(Effect.orElseSucceed(() => null)); if (!data) { return HttpServerResponse.text("Internal Server Error", { status: 500 }); } diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index 63324b1687c..52da3703698 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -9,10 +9,12 @@ import { type ProviderRuntimeEvent, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; +import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import type * as PlatformError from "effect/PlatformError"; import * as Stream from "effect/Stream"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; @@ -69,10 +71,12 @@ function checkpointStatusFromRuntime(status: string | undefined): "ready" | "mis } } -const serverCommandId = (tag: string): CommandId => - CommandId.make(`server:${tag}:${crypto.randomUUID()}`); - const make = Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const randomUUID = crypto.randomUUIDv4; + const serverEventId = randomUUID.pipe(Effect.map(EventId.make)); + const serverCommandId = (tag: string) => + randomUUID.pipe(Effect.map((uuid) => CommandId.make(`server:${tag}:${uuid}`))); const orchestrationEngine = yield* OrchestrationEngineService; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; const providerService = yield* ProviderService; @@ -87,24 +91,31 @@ const make = Effect.gen(function* () { readonly detail: string; readonly createdAt: string; }) => - orchestrationEngine.dispatch({ - type: "thread.activity.append", + Effect.all({ commandId: serverCommandId("checkpoint-revert-failure"), - threadId: input.threadId, - activity: { - id: EventId.make(crypto.randomUUID()), - tone: "error", - kind: "checkpoint.revert.failed", - summary: "Checkpoint revert failed", - payload: { - turnCount: input.turnCount, - detail: input.detail, - }, - turnId: null, - createdAt: input.createdAt, - }, - createdAt: input.createdAt, - }); + activityId: serverEventId, + }).pipe( + Effect.flatMap(({ commandId, activityId }) => + orchestrationEngine.dispatch({ + type: "thread.activity.append", + commandId, + threadId: input.threadId, + activity: { + id: activityId, + tone: "error", + kind: "checkpoint.revert.failed", + summary: "Checkpoint revert failed", + payload: { + turnCount: input.turnCount, + detail: input.detail, + }, + turnId: null, + createdAt: input.createdAt, + }, + createdAt: input.createdAt, + }), + ), + ); const appendCaptureFailureActivity = (input: { readonly threadId: ThreadId; @@ -112,23 +123,30 @@ const make = Effect.gen(function* () { readonly detail: string; readonly createdAt: string; }) => - orchestrationEngine.dispatch({ - type: "thread.activity.append", + Effect.all({ commandId: serverCommandId("checkpoint-capture-failure"), - threadId: input.threadId, - activity: { - id: EventId.make(crypto.randomUUID()), - tone: "error", - kind: "checkpoint.capture.failed", - summary: "Checkpoint capture failed", - payload: { - detail: input.detail, - }, - turnId: input.turnId, - createdAt: input.createdAt, - }, - createdAt: input.createdAt, - }); + activityId: serverEventId, + }).pipe( + Effect.flatMap(({ commandId, activityId }) => + orchestrationEngine.dispatch({ + type: "thread.activity.append", + commandId, + threadId: input.threadId, + activity: { + id: activityId, + tone: "error", + kind: "checkpoint.capture.failed", + summary: "Checkpoint capture failed", + payload: { + detail: input.detail, + }, + turnId: input.turnId, + createdAt: input.createdAt, + }, + createdAt: input.createdAt, + }), + ), + ); const resolveSessionRuntimeForThread = Effect.fn("resolveSessionRuntimeForThread")(function* ( threadId: ThreadId, @@ -281,7 +299,7 @@ const make = Effect.gen(function* () { yield* orchestrationEngine.dispatch({ type: "thread.turn.diff.complete", - commandId: serverCommandId("checkpoint-turn-diff-complete"), + commandId: yield* serverCommandId("checkpoint-turn-diff-complete"), threadId: input.threadId, turnId: input.turnId, completedAt: input.createdAt, @@ -311,10 +329,10 @@ const make = Effect.gen(function* () { yield* orchestrationEngine.dispatch({ type: "thread.activity.append", - commandId: serverCommandId("checkpoint-captured-activity"), + commandId: yield* serverCommandId("checkpoint-captured-activity"), threadId: input.threadId, activity: { - id: EventId.make(crypto.randomUUID()), + id: EventId.make(yield* randomUUID), tone: "info", kind: "checkpoint.captured", summary: "Checkpoint captured", @@ -710,7 +728,7 @@ const make = Effect.gen(function* () { yield* orchestrationEngine .dispatch({ type: "thread.revert.complete", - commandId: serverCommandId("checkpoint-revert-complete"), + commandId: yield* serverCommandId("checkpoint-revert-complete"), threadId: event.payload.threadId, turnCount: event.payload.turnCount, createdAt: now, @@ -795,7 +813,11 @@ const make = Effect.gen(function* () { const processInput = ( input: ReactorInput, - ): Effect.Effect => + ): Effect.Effect< + void, + CheckpointStoreError | OrchestrationDispatchError | PlatformError.PlatformError, + never + > => input.source === "domain" ? processDomainEvent(input.event) : processRuntimeEvent(input.event); const processInputSafely = (input: ReactorInput) => diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index b85397b396d..138a46f4f4b 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -8,6 +8,7 @@ import type { import { MCP_CATALOG_AGGREGATE_ID, OrchestrationCommand } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; +import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; @@ -102,6 +103,7 @@ const makeOrchestrationEngine = Effect.gen(function* () { const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; // ru-fork: the decider splits MCP draft secrets into the secret store. const serverSecretStore = yield* ServerSecretStore; + const crypto = yield* Crypto.Crypto; const nowIso = Effect.map(DateTime.now, DateTime.formatIso); let commandReadModel = createEmptyReadModel(yield* nowIso); @@ -172,7 +174,10 @@ const makeOrchestrationEngine = Effect.gen(function* () { const eventBase = yield* decideOrchestrationCommand({ command: envelope.command, readModel: commandReadModel, - }).pipe(Effect.provideService(ServerSecretStore, serverSecretStore)); + }).pipe( + Effect.provideService(ServerSecretStore, serverSecretStore), + Effect.provideService(Crypto.Crypto, crypto), + ); const eventBases = Array.isArray(eventBase) ? eventBase : [eventBase]; const committedCommand = yield* sql .withTransaction( diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index fe4a3d6b05a..483d84c4bd0 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -399,7 +399,7 @@ const runAttachmentSideEffects = Effect.fn("runAttachmentSideEffects")(function* const attachmentsRootDir = serverConfig.attachmentsDir; const readAttachmentRootEntries = fileSystem .readDirectory(attachmentsRootDir, { recursive: false }) - .pipe(Effect.catch(() => Effect.succeed([] as Array))); + .pipe(Effect.orElseSucceed(() => [] as Array)); const removeDeletedThreadAttachmentEntry = Effect.fn("removeDeletedThreadAttachmentEntry")( function* (threadSegment: string, entry: string) { @@ -463,7 +463,7 @@ const runAttachmentSideEffects = Effect.fn("runAttachmentSideEffects")(function* const absolutePath = path.join(attachmentsRootDir, relativePath); const fileInfo = yield* fileSystem .stat(absolutePath) - .pipe(Effect.catch(() => Effect.succeed(null))); + .pipe(Effect.orElseSucceed(() => null)); if (!fileInfo || fileInfo.type !== "File") { return; } diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 38d628da95e..ee7de9c229a 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -14,6 +14,7 @@ import { import { isTemporaryWorktreeBranch, WORKTREE_BRANCH_PREFIX } from "@t3tools/shared/git"; import * as Cache from "effect/Cache"; import * as Cause from "effect/Cause"; +import * as Crypto from "effect/Crypto"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -86,9 +87,6 @@ function mapProviderSessionStatusToOrchestrationStatus( const turnStartKeyForEvent = (event: ProviderIntentEvent): string => event.commandId !== null ? `command:${event.commandId}` : `event:${event.eventId}`; -const serverCommandId = (tag: string): CommandId => - CommandId.make(`server:${tag}:${crypto.randomUUID()}`); - const HANDLED_TURN_START_KEY_MAX = 10_000; const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30); const SESSION_OVERLAY_FINGERPRINT_MAX = 10_000; @@ -223,6 +221,10 @@ function buildGeneratedWorktreeBranchName(raw: string): string { } const make = Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const serverCommandId = (tag: string) => + crypto.randomUUIDv4.pipe(Effect.map((uuid) => CommandId.make(`server:${tag}:${uuid}`))); + const serverEventId = () => crypto.randomUUIDv4.pipe(Effect.map(EventId.make)); const orchestrationEngine = yield* OrchestrationEngineService; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; const providerService = yield* ProviderService; @@ -269,24 +271,31 @@ const make = Effect.gen(function* () { readonly createdAt: string; readonly requestId?: string; }) => - orchestrationEngine.dispatch({ - type: "thread.activity.append", + Effect.all({ commandId: serverCommandId("provider-failure-activity"), - threadId: input.threadId, - activity: { - id: EventId.make(crypto.randomUUID()), - tone: "error", - kind: input.kind, - summary: input.summary, - payload: { - detail: input.detail, - ...(input.requestId ? { requestId: input.requestId } : {}), - }, - turnId: input.turnId, - createdAt: input.createdAt, - }, - createdAt: input.createdAt, - }); + eventId: serverEventId(), + }).pipe( + Effect.flatMap(({ commandId, eventId }) => + orchestrationEngine.dispatch({ + type: "thread.activity.append", + commandId, + threadId: input.threadId, + activity: { + id: eventId, + tone: "error", + kind: input.kind, + summary: input.summary, + payload: { + detail: input.detail, + ...(input.requestId ? { requestId: input.requestId } : {}), + }, + turnId: input.turnId, + createdAt: input.createdAt, + }, + createdAt: input.createdAt, + }), + ), + ); // ru-fork: `formatFailureDetail` deleted. Routing moved to the // classifier in `apps/server/src/ru-fork/cli-errors-handling/`. @@ -301,13 +310,17 @@ const make = Effect.gen(function* () { readonly session: OrchestrationSession; readonly createdAt: string; }) => - orchestrationEngine.dispatch({ - type: "thread.session.set", - commandId: serverCommandId("provider-session-set"), - threadId: input.threadId, - session: input.session, - createdAt: input.createdAt, - }); + serverCommandId("provider-session-set").pipe( + Effect.flatMap((commandId) => + orchestrationEngine.dispatch({ + type: "thread.session.set", + commandId, + threadId: input.threadId, + session: input.session, + createdAt: input.createdAt, + }), + ), + ); /** * Ru-fork: clear orphan turn + session state after the failed @@ -359,7 +372,7 @@ const make = Effect.gen(function* () { // semantics ever change. yield* orchestrationEngine.dispatch({ type: "thread.session.stop", - commandId: serverCommandId("provider-orphan-turn-stop"), + commandId: yield* serverCommandId("provider-orphan-turn-stop"), threadId: input.threadId, createdAt: input.createdAt, }); @@ -785,7 +798,7 @@ const make = Effect.gen(function* () { const renamed = yield* gitWorkflow.renameBranch({ cwd, oldBranch, newBranch: targetBranch }); yield* orchestrationEngine.dispatch({ type: "thread.meta.update", - commandId: serverCommandId("worktree-branch-rename"), + commandId: yield* serverCommandId("worktree-branch-rename"), threadId: input.threadId, branch: renamed.branch, worktreePath: cwd, @@ -847,7 +860,7 @@ const make = Effect.gen(function* () { yield* orchestrationEngine.dispatch({ type: "thread.meta.update", - commandId: serverCommandId("thread-title-rename"), + commandId: yield* serverCommandId("thread-title-rename"), threadId: input.threadId, title: generated.title, }); @@ -1020,7 +1033,7 @@ const make = Effect.gen(function* () { // "Send-while-parked". const isParked = yield* providerService .hasParkedRequests(event.payload.threadId) - .pipe(Effect.catch(() => Effect.succeed(false))); + .pipe(Effect.orElseSucceed(() => false)); if (isParked) { yield* Effect.logInfo( "provider command reactor auto-interrupting parked session before turn start", diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 089360f9f35..b3497892ce8 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -19,6 +19,7 @@ import { } from "@t3tools/contracts"; import * as Cache from "effect/Cache"; import * as Cause from "effect/Cause"; +import * as Crypto from "effect/Crypto"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -41,8 +42,6 @@ import { import { ServerSettingsService } from "../../serverSettings.ts"; const providerTurnKey = (threadId: ThreadId, turnId: TurnId) => `${threadId}:${turnId}`; -const providerCommandId = (event: ProviderRuntimeEvent, tag: string): CommandId => - CommandId.make(`provider:${event.eventId}:${tag}:${crypto.randomUUID()}`); interface AssistantSegmentState { baseKey: string; @@ -615,11 +614,16 @@ function runtimeEventToActivities( } const make = Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; const orchestrationEngine = yield* OrchestrationEngineService; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; const providerService = yield* ProviderService; const projectionTurnRepository = yield* ProjectionTurnRepository; const serverSettingsService = yield* ServerSettingsService; + const providerCommandId = (event: ProviderRuntimeEvent, tag: string) => + crypto.randomUUIDv4.pipe( + Effect.map((uuid) => CommandId.make(`provider:${event.eventId}:${tag}:${uuid}`)), + ); const turnMessageIdsByTurnKey = yield* Cache.make>({ capacity: TURN_MESSAGE_IDS_BY_TURN_CACHE_CAPACITY, @@ -856,7 +860,7 @@ const make = Effect.gen(function* () { yield* orchestrationEngine.dispatch({ type: "thread.message.assistant.delta", - commandId: providerCommandId(input.event, input.commandTag), + commandId: yield* providerCommandId(input.event, input.commandTag), threadId: input.threadId, messageId: input.messageId, delta: bufferedText, @@ -923,7 +927,7 @@ const make = Effect.gen(function* () { if (hasRenderableText) { yield* orchestrationEngine.dispatch({ type: "thread.message.assistant.delta", - commandId: providerCommandId(input.event, input.finalDeltaCommandTag), + commandId: yield* providerCommandId(input.event, input.finalDeltaCommandTag), threadId: input.threadId, messageId: input.messageId, delta: text, @@ -935,7 +939,7 @@ const make = Effect.gen(function* () { if (input.hasProjectedMessage || hasRenderableText) { yield* orchestrationEngine.dispatch({ type: "thread.message.assistant.complete", - commandId: providerCommandId(input.event, input.commandTag), + commandId: yield* providerCommandId(input.event, input.commandTag), threadId: input.threadId, messageId: input.messageId, ...(input.turnId ? { turnId: input.turnId } : {}), @@ -1011,7 +1015,7 @@ const make = Effect.gen(function* () { const existingPlan = findProposedPlanById(input.threadProposedPlans, input.planId); yield* orchestrationEngine.dispatch({ type: "thread.proposed-plan.upsert", - commandId: providerCommandId(input.event, "proposed-plan-upsert"), + commandId: yield* providerCommandId(input.event, "proposed-plan-upsert"), threadId: input.threadId, proposedPlan: { id: input.planId, @@ -1167,10 +1171,11 @@ const make = Effect.gen(function* () { return; } + const commandUuid = yield* crypto.randomUUIDv4; yield* orchestrationEngine.dispatch({ type: "thread.proposed-plan.upsert", commandId: CommandId.make( - `provider:source-proposed-plan-implemented:${implementationThreadId}:${crypto.randomUUID()}`, + `provider:source-proposed-plan-implemented:${implementationThreadId}:${commandUuid}`, ), threadId: sourceThread.id, proposedPlan: { @@ -1314,7 +1319,7 @@ const make = Effect.gen(function* () { yield* orchestrationEngine.dispatch({ type: "thread.session.set", - commandId: providerCommandId(event, "thread-session-set"), + commandId: yield* providerCommandId(event, "thread-session-set"), threadId: thread.id, session: { threadId: thread.id, @@ -1349,7 +1354,7 @@ const make = Effect.gen(function* () { // (1) timeline row, bound to the real turn id yield* orchestrationEngine.dispatch({ type: "thread.activity.append", - commandId: providerCommandId(event, "turn-failed-activity"), + commandId: yield* providerCommandId(event, "turn-failed-activity"), threadId: thread.id, activity: { id: event.eventId, @@ -1367,7 +1372,7 @@ const make = Effect.gen(function* () { // clear activeTurnId so the "Работаю" timer stops. yield* orchestrationEngine.dispatch({ type: "thread.session.set", - commandId: providerCommandId(event, "turn-failed-session"), + commandId: yield* providerCommandId(event, "turn-failed-session"), threadId: thread.id, session: { threadId: thread.id, @@ -1413,7 +1418,7 @@ const make = Effect.gen(function* () { if (spillChunk.length > 0) { yield* orchestrationEngine.dispatch({ type: "thread.message.assistant.delta", - commandId: providerCommandId(event, "assistant-delta-buffer-spill"), + commandId: yield* providerCommandId(event, "assistant-delta-buffer-spill"), threadId: thread.id, messageId: assistantMessageId, delta: spillChunk, @@ -1424,7 +1429,7 @@ const make = Effect.gen(function* () { } else { yield* orchestrationEngine.dispatch({ type: "thread.message.assistant.delta", - commandId: providerCommandId(event, "assistant-delta"), + commandId: yield* providerCommandId(event, "assistant-delta"), threadId: thread.id, messageId: assistantMessageId, delta: assistantDelta, @@ -1647,7 +1652,7 @@ const make = Effect.gen(function* () { if (shouldApplyRuntimeError) { yield* orchestrationEngine.dispatch({ type: "thread.session.set", - commandId: providerCommandId(event, "runtime-error-session-set"), + commandId: yield* providerCommandId(event, "runtime-error-session-set"), threadId: thread.id, session: { threadId: thread.id, @@ -1669,7 +1674,7 @@ const make = Effect.gen(function* () { if (event.type === "thread.metadata.updated" && event.payload.name) { yield* orchestrationEngine.dispatch({ type: "thread.meta.update", - commandId: providerCommandId(event, "thread-meta-update"), + commandId: yield* providerCommandId(event, "thread-meta-update"), threadId: thread.id, title: event.payload.name, }); @@ -1697,7 +1702,7 @@ const make = Effect.gen(function* () { ); yield* orchestrationEngine.dispatch({ type: "thread.turn.diff.complete", - commandId: providerCommandId(event, "thread-turn-diff-complete"), + commandId: yield* providerCommandId(event, "thread-turn-diff-complete"), threadId: thread.id, turnId, completedAt: now, @@ -1714,13 +1719,17 @@ const make = Effect.gen(function* () { const activities = runtimeEventToActivities(event); yield* Effect.forEach(activities, (activity) => - orchestrationEngine.dispatch({ - type: "thread.activity.append", - commandId: providerCommandId(event, "thread-activity-append"), - threadId: thread.id, - activity, - createdAt: activity.createdAt, - }), + providerCommandId(event, "thread-activity-append").pipe( + Effect.flatMap((commandId) => + orchestrationEngine.dispatch({ + type: "thread.activity.append", + commandId, + threadId: thread.id, + activity, + createdAt: activity.createdAt, + }), + ), + ), ).pipe(Effect.asVoid); }); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index c4b36a9171d..14099ae7835 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -4,8 +4,10 @@ import type { OrchestrationReadModel, } from "@t3tools/contracts"; import { MCP_CATALOG_AGGREGATE_ID } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import type * as PlatformError from "effect/PlatformError"; import { type SecretStoreError, ServerSecretStore } from "../auth/Services/ServerSecretStore.ts"; import { @@ -46,17 +48,27 @@ function withEventBase( readonly occurredAt: string; readonly metadata?: OrchestrationEvent["metadata"]; }, -): Omit { - return { - eventId: crypto.randomUUID() as OrchestrationEvent["eventId"], - aggregateKind: input.aggregateKind, - aggregateId: input.aggregateId, - occurredAt: input.occurredAt, - commandId: input.commandId, - causationEventId: null, - correlationId: input.commandId, - metadata: input.metadata ?? {}, - }; +): Effect.Effect< + Omit, + PlatformError.PlatformError, + Crypto.Crypto +> { + return Crypto.Crypto.pipe( + Effect.flatMap((crypto) => + crypto.randomUUIDv4.pipe( + Effect.map((eventId) => ({ + eventId: eventId as OrchestrationEvent["eventId"], + aggregateKind: input.aggregateKind, + aggregateId: input.aggregateId, + occurredAt: input.occurredAt, + commandId: input.commandId, + causationEventId: null, + correlationId: input.commandId, + metadata: input.metadata ?? {}, + })), + ), + ), + ); } type PlannedOrchestrationEvent = Omit; @@ -73,8 +85,8 @@ const decideCommandSequence = Effect.fn("decideCommandSequence")(function* ({ readonly readModel: OrchestrationReadModel; }): Effect.fn.Return< ReadonlyArray, - OrchestrationCommandInvariantError | SecretStoreError, - ServerSecretStore + OrchestrationCommandInvariantError | SecretStoreError | PlatformError.PlatformError, + ServerSecretStore | Crypto.Crypto > { let nextReadModel = readModel; let nextSequence = readModel.snapshotSequence; @@ -107,8 +119,8 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" readonly readModel: OrchestrationReadModel; }): Effect.fn.Return< DecideOrchestrationCommandResult, - OrchestrationCommandInvariantError | SecretStoreError, - ServerSecretStore + OrchestrationCommandInvariantError | SecretStoreError | PlatformError.PlatformError, + ServerSecretStore | Crypto.Crypto > { switch (command.type) { case "project.create": { @@ -119,12 +131,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }); return { - ...withEventBase({ + ...(yield* withEventBase({ aggregateKind: "project", aggregateId: command.projectId, occurredAt: command.createdAt, commandId: command.commandId, - }), + })), type: "project.created", payload: { projectId: command.projectId, @@ -146,12 +158,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }); const occurredAt = yield* nowIso; return { - ...withEventBase({ + ...(yield* withEventBase({ aggregateKind: "project", aggregateId: command.projectId, occurredAt, commandId: command.commandId, - }), + })), type: "project.meta-updated", payload: { projectId: command.projectId, @@ -203,12 +215,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" const occurredAt = yield* nowIso; return { - ...withEventBase({ + ...(yield* withEventBase({ aggregateKind: "project", aggregateId: command.projectId, occurredAt, commandId: command.commandId, - }), + })), type: "project.deleted" as const, payload: { projectId: command.projectId, @@ -229,12 +241,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" threadId: command.threadId, }); return { - ...withEventBase({ + ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt: command.createdAt, commandId: command.commandId, - }), + })), type: "thread.created", payload: { threadId: command.threadId, @@ -259,12 +271,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }); const occurredAt = yield* nowIso; return { - ...withEventBase({ + ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt, commandId: command.commandId, - }), + })), type: "thread.deleted", payload: { threadId: command.threadId, @@ -281,12 +293,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }); const occurredAt = yield* nowIso; return { - ...withEventBase({ + ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt, commandId: command.commandId, - }), + })), type: "thread.archived", payload: { threadId: command.threadId, @@ -304,12 +316,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }); const occurredAt = yield* nowIso; return { - ...withEventBase({ + ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt, commandId: command.commandId, - }), + })), type: "thread.unarchived", payload: { threadId: command.threadId, @@ -326,12 +338,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }); const occurredAt = yield* nowIso; return { - ...withEventBase({ + ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt, commandId: command.commandId, - }), + })), type: "thread.meta-updated", payload: { threadId: command.threadId, @@ -354,12 +366,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }); const occurredAt = yield* nowIso; return { - ...withEventBase({ + ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt, commandId: command.commandId, - }), + })), type: "thread.runtime-mode-set", payload: { threadId: command.threadId, @@ -377,12 +389,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }); const occurredAt = yield* nowIso; return { - ...withEventBase({ + ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt, commandId: command.commandId, - }), + })), type: "thread.interaction-mode-set", payload: { threadId: command.threadId, @@ -423,12 +435,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }); } const userMessageEvent: Omit = { - ...withEventBase({ + ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt: command.createdAt, commandId: command.commandId, - }), + })), type: "thread.message-sent", payload: { threadId: command.threadId, @@ -443,12 +455,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }, }; const turnStartRequestedEvent: Omit = { - ...withEventBase({ + ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt: command.createdAt, commandId: command.commandId, - }), + })), causationEventId: userMessageEvent.eventId, type: "thread.turn-start-requested", payload: { @@ -474,12 +486,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" threadId: command.threadId, }); return { - ...withEventBase({ + ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt: command.createdAt, commandId: command.commandId, - }), + })), type: "thread.turn-interrupt-requested", payload: { threadId: command.threadId, @@ -496,7 +508,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" threadId: command.threadId, }); return { - ...withEventBase({ + ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt: command.createdAt, @@ -504,7 +516,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" metadata: { requestId: command.requestId, }, - }), + })), type: "thread.approval-response-requested", payload: { threadId: command.threadId, @@ -522,7 +534,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" threadId: command.threadId, }); return { - ...withEventBase({ + ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt: command.createdAt, @@ -530,7 +542,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" metadata: { requestId: command.requestId, }, - }), + })), type: "thread.user-input-response-requested", payload: { threadId: command.threadId, @@ -548,12 +560,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" threadId: command.threadId, }); return { - ...withEventBase({ + ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt: command.createdAt, commandId: command.commandId, - }), + })), type: "thread.checkpoint-revert-requested", payload: { threadId: command.threadId, @@ -570,12 +582,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" threadId: command.threadId, }); return { - ...withEventBase({ + ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt: command.createdAt, commandId: command.commandId, - }), + })), type: "thread.session-stop-requested", payload: { threadId: command.threadId, @@ -591,13 +603,13 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" threadId: command.threadId, }); return { - ...withEventBase({ + ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt: command.createdAt, commandId: command.commandId, metadata: {}, - }), + })), type: "thread.session-set", payload: { threadId: command.threadId, @@ -613,12 +625,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" threadId: command.threadId, }); return { - ...withEventBase({ + ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt: command.createdAt, commandId: command.commandId, - }), + })), type: "thread.message-sent", payload: { threadId: command.threadId, @@ -640,12 +652,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" threadId: command.threadId, }); return { - ...withEventBase({ + ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt: command.createdAt, commandId: command.commandId, - }), + })), type: "thread.message-sent", payload: { threadId: command.threadId, @@ -667,12 +679,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" threadId: command.threadId, }); return { - ...withEventBase({ + ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt: command.createdAt, commandId: command.commandId, - }), + })), type: "thread.proposed-plan-upserted", payload: { threadId: command.threadId, @@ -688,12 +700,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" threadId: command.threadId, }); return { - ...withEventBase({ + ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt: command.createdAt, commandId: command.commandId, - }), + })), type: "thread.turn-diff-completed", payload: { threadId: command.threadId, @@ -715,12 +727,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" threadId: command.threadId, }); return { - ...withEventBase({ + ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt: command.createdAt, commandId: command.commandId, - }), + })), type: "thread.reverted", payload: { threadId: command.threadId, @@ -744,13 +756,13 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" .requestId as OrchestrationEvent["metadata"]["requestId"]) : undefined; return { - ...withEventBase({ + ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, occurredAt: command.createdAt, commandId: command.commandId, ...(requestId !== undefined ? { metadata: { requestId } } : {}), - }), + })), type: "thread.activity-appended", payload: { threadId: command.threadId, @@ -790,12 +802,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" } const vars = yield* splitServerVars(command.serverId, command.draft.vars, []); return { - ...withEventBase({ + ...(yield* withEventBase({ aggregateKind: "mcp-catalog", aggregateId: MCP_CATALOG_AGGREGATE_ID, occurredAt: command.createdAt, commandId: command.commandId, - }), + })), type: "mcp.server-added", payload: { server: buildAddedServer(command.serverId, command.draft, vars, command.createdAt) }, }; @@ -836,12 +848,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ? yield* mergeTemplateVars(command.serverId, existing, command.patch.vars) : existing.vars; return { - ...withEventBase({ + ...(yield* withEventBase({ aggregateKind: "mcp-catalog", aggregateId: MCP_CATALOG_AGGREGATE_ID, occurredAt, commandId: command.commandId, - }), + })), type: "mcp.server-updated", payload: { server: applyServerUpdate(existing, command.patch, vars, occurredAt) }, }; @@ -853,12 +865,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" const existing = findCatalogServerById(readModel, command.serverId); // undefined ⇒ add const occurredAt = yield* nowIso; return { - ...withEventBase({ + ...(yield* withEventBase({ aggregateKind: "mcp-catalog", aggregateId: MCP_CATALOG_AGGREGATE_ID, occurredAt, commandId: command.commandId, - }), + })), type: existing ? "mcp.server-updated" : "mcp.server-added", payload: { server: buildSyncedBuiltin({ @@ -882,12 +894,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" yield* requireCatalogServer({ readModel, command, serverId: command.serverId }); const occurredAt = yield* nowIso; return { - ...withEventBase({ + ...(yield* withEventBase({ aggregateKind: "mcp-catalog", aggregateId: MCP_CATALOG_AGGREGATE_ID, occurredAt, commandId: command.commandId, - }), + })), type: "mcp.server-removed", payload: { serverId: command.serverId, removedAt: occurredAt }, }; @@ -923,12 +935,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" serverId: command.serverId, }); return { - ...withEventBase({ + ...(yield* withEventBase({ aggregateKind: "project", aggregateId: command.projectId, occurredAt, commandId: command.commandId, - }), + })), type: "mcp.binding-set", payload: { binding: buildBinding({ @@ -947,12 +959,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" yield* requireProject({ readModel, command, projectId: command.projectId }); const occurredAt = yield* nowIso; return { - ...withEventBase({ + ...(yield* withEventBase({ aggregateKind: "project", aggregateId: command.projectId, occurredAt, commandId: command.commandId, - }), + })), type: "mcp.binding-removed", payload: { projectId: command.projectId, diff --git a/apps/server/src/orchestration/http.ts b/apps/server/src/orchestration/http.ts index c7c3eb9c4f9..dd2476a9564 100644 --- a/apps/server/src/orchestration/http.ts +++ b/apps/server/src/orchestration/http.ts @@ -60,8 +60,10 @@ export const orchestrationSnapshotRouteLayer = prefixedRouteLayer( status: 200, }); }).pipe( - Effect.catchTag("OrchestrationDispatchCommandError", respondToOrchestrationHttpError), - Effect.catchTag("OrchestrationGetSnapshotError", respondToOrchestrationHttpError), + Effect.catchTags({ + OrchestrationDispatchCommandError: respondToOrchestrationHttpError, + OrchestrationGetSnapshotError: respondToOrchestrationHttpError, + }), ), ); diff --git a/apps/server/src/persistence/Layers/AuthSessions.ts b/apps/server/src/persistence/Layers/AuthSessions.ts index 64b8146f927..6774e79c81a 100644 --- a/apps/server/src/persistence/Layers/AuthSessions.ts +++ b/apps/server/src/persistence/Layers/AuthSessions.ts @@ -40,7 +40,7 @@ const AuthSessionDbRow = Schema.Struct({ revokedAt: Schema.NullOr(Schema.DateTimeUtcFromString), }); -function toAuthSessionRecord(row: typeof AuthSessionDbRow.Type): typeof AuthSessionRecord.Type { +function toAuthSessionRecord(row: typeof AuthSessionDbRow.Type): AuthSessionRecord { return { sessionId: row.sessionId, subject: row.subject, diff --git a/apps/server/src/persistence/Layers/ProjectionStatsFileCache.ts b/apps/server/src/persistence/Layers/ProjectionStatsFileCache.ts index bead158bfdc..e235ed18456 100644 --- a/apps/server/src/persistence/Layers/ProjectionStatsFileCache.ts +++ b/apps/server/src/persistence/Layers/ProjectionStatsFileCache.ts @@ -55,7 +55,7 @@ const makeStatsFileCacheRepository = Effect.gen(function* () { const decoded = yield* Effect.forEach(rawRows, (rawRow) => decodeRow(rawRow).pipe( Effect.map((row) => Option.some(rowToCacheRow(row))), - Effect.catch(() => Effect.succeed(Option.none())), + Effect.orElseSucceed(() => Option.none()), ), ); const rows = decoded.filter(Option.isSome).map((entry) => entry.value); diff --git a/apps/server/src/persistence/Services/StatsFileCache.ts b/apps/server/src/persistence/Services/StatsFileCache.ts index 03ca7c60f8d..d66002d42c0 100644 --- a/apps/server/src/persistence/Services/StatsFileCache.ts +++ b/apps/server/src/persistence/Services/StatsFileCache.ts @@ -42,4 +42,4 @@ export interface StatsFileCacheRepositoryShape { export class StatsFileCacheRepository extends Context.Service< StatsFileCacheRepository, StatsFileCacheRepositoryShape ->()("ru-fork/persistence/Services/StatsFileCache/StatsFileCacheRepository") {} +>()("@ru-code/ru-code/persistence/Services/StatsFileCache/StatsFileCacheRepository") {} diff --git a/apps/server/src/project/Layers/ProjectFaviconResolver.ts b/apps/server/src/project/Layers/ProjectFaviconResolver.ts index ed5412bf138..947a268d651 100644 --- a/apps/server/src/project/Layers/ProjectFaviconResolver.ts +++ b/apps/server/src/project/Layers/ProjectFaviconResolver.ts @@ -82,7 +82,7 @@ export const makeProjectFaviconResolver = Effect.gen(function* () { } const stats = yield* fileSystem .stat(candidate) - .pipe(Effect.catch(() => Effect.succeed(null))); + .pipe(Effect.orElseSucceed(() => null)); if (stats?.type === "File") { return candidate; } @@ -105,7 +105,7 @@ export const makeProjectFaviconResolver = Effect.gen(function* () { const sourcePath = path.join(cwd, sourceFile); const source = yield* fileSystem .readFileString(sourcePath) - .pipe(Effect.catch(() => Effect.succeed(null))); + .pipe(Effect.orElseSucceed(() => null)); if (!source) { continue; } diff --git a/apps/server/src/provider/Layers/CliAcpSupport.ts b/apps/server/src/provider/Layers/CliAcpSupport.ts index a0878c0e27d..8cc910daa83 100644 --- a/apps/server/src/provider/Layers/CliAcpSupport.ts +++ b/apps/server/src/provider/Layers/CliAcpSupport.ts @@ -8,6 +8,7 @@ * @module CliAcpSupport */ import { type CliSettings } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Scope from "effect/Scope"; @@ -103,7 +104,7 @@ export function buildCliAcpSpawnInput( export const makeCliAcpRuntime = ( input: CliAcpRuntimeInput, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { const acpContext = yield* Layer.build( AcpSessionRuntime.layer({ diff --git a/apps/server/src/provider/Layers/CliAdapter.ts b/apps/server/src/provider/Layers/CliAdapter.ts index 9a4efaf5d87..1a2727900fb 100644 --- a/apps/server/src/provider/Layers/CliAdapter.ts +++ b/apps/server/src/provider/Layers/CliAdapter.ts @@ -108,7 +108,7 @@ const PROVIDER = ProviderDriverKind.make(CLI_NAME); export interface CliAdapterLiveOptions { readonly environment?: NodeJS.ProcessEnv; readonly nativeEventLogger?: EventNdjsonLogger; - readonly instanceId?: typeof ProviderInstanceId.Type; + readonly instanceId?: ProviderInstanceId; /** * ru-fork: override the ACP start-handshake timeout. Production omits it ⇒ * ACP_SESSION_START_TIMEOUT_MS. Tests pass a tiny value so a scripted hang at @@ -738,6 +738,7 @@ export function makeCliAdapter(cliSettings: CliSettings, options?: CliAdapterLiv : Effect.void, }).pipe( Effect.provideService(Scope.Scope, sessionScope), + Effect.provideService(Crypto.Crypto, crypto), // ru-fork: capture the original stream-side cause at the // boundary — `mapAcpToAdapterError` below wraps it in a // `ProviderAdapterProcessError` whose `.cause` is preserved diff --git a/apps/server/src/provider/acp/AcpSessionRuntime.ts b/apps/server/src/provider/acp/AcpSessionRuntime.ts index c389e2a0c1f..791cfd2552b 100644 --- a/apps/server/src/provider/acp/AcpSessionRuntime.ts +++ b/apps/server/src/provider/acp/AcpSessionRuntime.ts @@ -1,4 +1,5 @@ import * as Cause from "effect/Cause"; +import * as Crypto from "effect/Crypto"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -160,7 +161,7 @@ export class AcpSessionRuntime extends Context.Service { return Layer.effect(AcpSessionRuntime, makeAcpSessionRuntime(options)); } @@ -171,7 +172,7 @@ const makeAcpSessionRuntime = ( ): Effect.Effect< AcpSessionRuntimeShape, EffectAcpErrors.AcpError, - ChildProcessSpawner.ChildProcessSpawner | Scope.Scope + ChildProcessSpawner.ChildProcessSpawner | Scope.Scope | Crypto.Crypto > => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; @@ -187,10 +188,11 @@ const makeAcpSessionRuntime = ( // window. T3 already has its own thread history, so the replay is dropped // to avoid double-rendering historical messages into the live chat. const suppressUpdatesRef = yield* Ref.make(false); + const crypto = yield* Crypto.Crypto; // Per-runtime-instance nonce. Used in assistant message item IDs so that a // resumed session (same acpSessionId, fresh in-memory segment counter at 0) // never collides with assistant message IDs persisted by the prior runtime. - const runtimeInstanceId = crypto.randomUUID().slice(0, 8); + const runtimeInstanceId = (yield* crypto.randomUUIDv4.pipe(Effect.orDie)).slice(0, 8); const logRequest = (event: AcpSessionRequestLogEvent) => options.requestLogger ? options.requestLogger(event) : Effect.void; diff --git a/apps/server/src/provider/providerMaintenance.ts b/apps/server/src/provider/providerMaintenance.ts index bfa34fe4f16..dc39a4fb3e9 100644 --- a/apps/server/src/provider/providerMaintenance.ts +++ b/apps/server/src/provider/providerMaintenance.ts @@ -348,7 +348,7 @@ export const resolveProviderMaintenanceCapabilitiesEffect = Effect.fn( const fileSystem = yield* FileSystem.FileSystem; const realCommandPath = yield* fileSystem .realPath(resolvedCommandPath) - .pipe(Effect.catch(() => Effect.succeed(resolvedCommandPath))); + .pipe(Effect.orElseSucceed(() => resolvedCommandPath)); return resolver.resolve({ ...options, realCommandPath, @@ -407,7 +407,7 @@ const fetchNpmLatestVersion = Effect.fn("fetchNpmLatestVersion")(function* (pack ).pipe(HttpClientRequest.setHeader("accept", "application/json")); const response = yield* client.execute(request).pipe( Effect.timeoutOption(PROVIDER_LATEST_VERSION_TIMEOUT_MS), - Effect.catch(() => Effect.succeed(Option.none())), + Effect.orElseSucceed(() => Option.none()), ); if (Option.isNone(response)) { return null; @@ -418,7 +418,7 @@ const fetchNpmLatestVersion = Effect.fn("fetchNpmLatestVersion")(function* (pack } const payload = yield* httpResponse.json.pipe( Effect.flatMap(Schema.decodeUnknownEffect(NpmLatestVersionResponse)), - Effect.catch(() => Effect.succeed(null)), + Effect.orElseSucceed(() => null), ); return payload ? nonEmptyString(payload.version) : null; }); diff --git a/apps/server/src/ru-fork/mcp/McpProjectionQuery.ts b/apps/server/src/ru-fork/mcp/McpProjectionQuery.ts index e866946117d..2135bd132e8 100644 --- a/apps/server/src/ru-fork/mcp/McpProjectionQuery.ts +++ b/apps/server/src/ru-fork/mcp/McpProjectionQuery.ts @@ -60,7 +60,7 @@ const makeMcpProjectionQuery = Effect.gen(function* () { Effect.map((snapshot): McpProjectionStreamEvent | null => ({ type: "snapshot", snapshot })), // A transient snapshot read failure drops THIS update instead of ending the subscription // (matches McpRuntime's resilience); the next event re-reads fresh. - Effect.catch(() => Effect.succeed(null)), + Effect.orElseSucceed(() => null), ), ), Stream.filter((event): event is McpProjectionStreamEvent => event !== null), diff --git a/apps/server/src/ru-fork/mcp/McpReactor.ts b/apps/server/src/ru-fork/mcp/McpReactor.ts index edfb79a5e10..1632d4b7525 100644 --- a/apps/server/src/ru-fork/mcp/McpReactor.ts +++ b/apps/server/src/ru-fork/mcp/McpReactor.ts @@ -25,6 +25,7 @@ import { } from "@ru-fork/mcp-core"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -60,7 +61,10 @@ import { type DesiredInstance, McpSupervisor } from "./McpSupervisor.ts"; // uuid here is correct — and matches CheckpointReactor / ProviderCommandReactor. (autobind is the // deliberate exception below: its stable id IS its "bind a builtin to a project once ever" guard.) const mkReconcileCommandId = (tag: string) => - CommandId.make(`server:${tag}:${crypto.randomUUID()}`); + Crypto.Crypto.pipe( + Effect.flatMap((crypto) => crypto.randomUUIDv4.pipe(Effect.orDie)), + Effect.map((uuid) => CommandId.make(`server:${tag}:${uuid}`)), + ); export interface McpReactorShape { /** Start reacting to authored MCP/project changes. Run inside the reactor scope. */ @@ -144,7 +148,7 @@ export const reconcileBuiltinsWith = ( // ru-fork: unique per dispatch (see mkReconcileCommandId). Identity is `serverId` (stable), and // whether to dispatch at all is decided by the hash GATE above (`installed.builtinHash === hash` // ⇒ skip), so a no-op restart mints nothing and a changed definition / a re-add always re-syncs. - commandId: mkReconcileCommandId(`mcp-builtin-sync:${definition.builtinId}`), + commandId: yield* mkReconcileCommandId(`mcp-builtin-sync:${definition.builtinId}`), serverId: McpServerId.make(serverId), builtinId: definition.builtinId, builtinHash: hash, @@ -170,7 +174,7 @@ export const reconcileBuiltinsWith = ( yield* engine .dispatch({ type: "mcp.server-remove", - commandId: mkReconcileCommandId(`mcp-builtin-remove:${installed.builtinId}`), + commandId: yield* mkReconcileCommandId(`mcp-builtin-remove:${installed.builtinId}`), serverId: installed.id, }) .pipe( @@ -229,7 +233,7 @@ export const backfillServerMetadataEffect = Effect.gen(function* () { yield* engine .dispatch({ type: "mcp.server-update", - commandId: mkReconcileCommandId( + commandId: yield* mkReconcileCommandId( `mcp-meta-backfill:${server.id}:${Object.keys(patch).toSorted().join("-")}`, ), serverId: server.id, @@ -274,7 +278,7 @@ export const pruneOrphanedVarValuesEffect = Effect.gen(function* () { yield* engine .dispatch({ type: "mcp.binding-set", - commandId: mkReconcileCommandId(`mcp-prune-vars:${binding.projectId}:${binding.serverId}`), + commandId: yield* mkReconcileCommandId(`mcp-prune-vars:${binding.projectId}:${binding.serverId}`), projectId: binding.projectId, serverId: binding.serverId, patch: { varValues: {}, keepVarValues: keep }, @@ -432,7 +436,7 @@ export const autobindBuiltinsForProjectWith = (projectId: ProjectId) => const serverSettings = yield* ServerSettingsService; const catalogRepository = yield* McpCatalogRepository; const engine = yield* OrchestrationEngineService; - const settings = yield* serverSettings.getSettings.pipe(Effect.catch(() => Effect.succeed(null))); + const settings = yield* serverSettings.getSettings.pipe(Effect.orElseSucceed(() => null)); if (settings === null || !settings.mcp.autobindDefaults) { return; } @@ -470,6 +474,7 @@ export const autobindBuiltinsForProjectWith = (projectId: ProjectId) => ); const make = Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; const catalogRepository = yield* McpCatalogRepository; const bindingRepository = yield* McpBindingRepository; const probeCache = yield* McpProbeCacheRepository; @@ -499,6 +504,7 @@ const make = Effect.gen(function* () { // default-config cache row and dispatches a metadata-only `mcp.server-update`. Replay-idempotent // (deterministic, field-scoped commandId); converges (once filled, the next pass produces no patch). const backfillServerMetadata = backfillServerMetadataEffect.pipe( + Effect.provideService(Crypto.Crypto, crypto), Effect.provideService(OrchestrationEngineService, engine), Effect.provideService(McpCatalogRepository, catalogRepository), Effect.provideService(McpProbeCacheRepository, probeCache), @@ -549,6 +555,7 @@ const make = Effect.gen(function* () { // content-hash change (3-way merge preserves user values/vars/extraArgs), remove deleted. Runs at // startup. NOT eager (no probing on load). Unsupported platform (no config variant) ⇒ skip. const reconcileBuiltins = reconcileBuiltinsWith(MCP_BUILTINS, process.platform).pipe( + Effect.provideService(Crypto.Crypto, crypto), Effect.provideService(OrchestrationEngineService, engine), Effect.provideService(McpCatalogRepository, catalogRepository), ); @@ -564,6 +571,7 @@ const make = Effect.gen(function* () { // ignores unknown names, so this is hygiene + makes the secret GC exact. Ref-preserving (no // plaintext needed) — `keepVarValues` carries the surviving names' existing refs through. const pruneOrphanedVarValues = pruneOrphanedVarValuesEffect.pipe( + Effect.provideService(Crypto.Crypto, crypto), Effect.provideService(OrchestrationEngineService, engine), Effect.provideService(McpCatalogRepository, catalogRepository), Effect.provideService(McpBindingRepository, bindingRepository), diff --git a/apps/server/src/ru-fork/mcp/McpSupervisor.ts b/apps/server/src/ru-fork/mcp/McpSupervisor.ts index 98e4ac4d7ba..08a3565c3c0 100644 --- a/apps/server/src/ru-fork/mcp/McpSupervisor.ts +++ b/apps/server/src/ru-fork/mcp/McpSupervisor.ts @@ -461,7 +461,7 @@ const makeMcpSupervisor = Effect.gen(function* () { const runSweep = Effect.gen(function* () { const settings = yield* serverSettings.getSettings.pipe( - Effect.catch(() => Effect.succeed(null)), + Effect.orElseSucceed(() => null), ); const instances = [...(yield* Ref.get(registryRef)).values()]; if (settings === null || instances.length === 0) { diff --git a/apps/server/src/ru-fork/skills/SkillScannerLive.ts b/apps/server/src/ru-fork/skills/SkillScannerLive.ts index 92e18ab17e8..9f1c154f5ca 100644 --- a/apps/server/src/ru-fork/skills/SkillScannerLive.ts +++ b/apps/server/src/ru-fork/skills/SkillScannerLive.ts @@ -12,7 +12,9 @@ import type { ServerProviderSkill } from "@t3tools/contracts"; import { ServerProviderSkill as ServerProviderSkillSchema } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; import { ServerConfig } from "../../config.ts"; import { makeCachedFsScanner, type ScanResult } from "../common/cachedFsScanner.ts"; @@ -38,6 +40,11 @@ const toResult = (r: ScanResult): SkillsForCwdResult => ({ const makeScanner = Effect.gen(function* () { const config = yield* ServerConfig; + // ru-fork: resolve FileSystem + Path at layer creation and provide them into each + // method below, so the scan dependencies stay internal instead of leaking onto the + // public service surface (every caller would otherwise have to provide them). + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; const core = yield* makeCachedFsScanner({ cachePath: config.skillsCachePath, @@ -58,8 +65,18 @@ const makeScanner = Effect.gen(function* () { }); return { - getSkillsForCwd: (cwd) => core.getForCwd(cwd).pipe(Effect.map(toResult)), - refreshSkillsForCwd: (cwd) => core.refreshForCwd(cwd).pipe(Effect.map(toResult)), + getSkillsForCwd: (cwd) => + core.getForCwd(cwd).pipe( + Effect.map(toResult), + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + ), + refreshSkillsForCwd: (cwd) => + core.refreshForCwd(cwd).pipe( + Effect.map(toResult), + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + ), } satisfies SkillScannerShape; }); diff --git a/apps/server/src/ru-fork/skills/SkillScannerService.ts b/apps/server/src/ru-fork/skills/SkillScannerService.ts index 26fbfe4ffc5..0b23417cc06 100644 --- a/apps/server/src/ru-fork/skills/SkillScannerService.ts +++ b/apps/server/src/ru-fork/skills/SkillScannerService.ts @@ -7,16 +7,12 @@ import type { ServerProviderSkill } from "@t3tools/contracts"; import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; -import type * as FileSystem from "effect/FileSystem"; -import type * as Path from "effect/Path"; export interface SkillsForCwdResult { readonly global: ReadonlyArray; readonly project: ReadonlyArray; } -export type SkillScannerEnv = FileSystem.FileSystem | Path.Path; - export interface SkillScannerShape { /** * Read the latest known skills for a cwd. Returns cached values when @@ -26,7 +22,7 @@ export interface SkillScannerShape { */ readonly getSkillsForCwd: ( cwd: string | null, - ) => Effect.Effect; + ) => Effect.Effect; /** * Force a re-scan of the global root and (when cwd is non-null) the @@ -34,7 +30,7 @@ export interface SkillScannerShape { */ readonly refreshSkillsForCwd: ( cwd: string | null, - ) => Effect.Effect; + ) => Effect.Effect; } export class SkillScanner extends Context.Service()( diff --git a/apps/server/src/ru-fork/stats/StatsScanner.ts b/apps/server/src/ru-fork/stats/StatsScanner.ts index 80d264bd807..8e1b1e65b0d 100644 --- a/apps/server/src/ru-fork/stats/StatsScanner.ts +++ b/apps/server/src/ru-fork/stats/StatsScanner.ts @@ -30,7 +30,7 @@ export interface StatsScannerShape { } export class StatsScanner extends Context.Service()( - "ru-fork/stats/StatsScanner", + "@ru-code/ru-code/ru-fork/stats/StatsScanner", ) {} interface DiskFile { diff --git a/apps/server/src/ru-fork/subagents/SubagentScannerLive.ts b/apps/server/src/ru-fork/subagents/SubagentScannerLive.ts index d20c5673cc9..a20e91ea33b 100644 --- a/apps/server/src/ru-fork/subagents/SubagentScannerLive.ts +++ b/apps/server/src/ru-fork/subagents/SubagentScannerLive.ts @@ -8,6 +8,7 @@ import type { ServerProviderSubagent } from "@t3tools/contracts"; import { ServerProviderSubagent as ServerProviderSubagentSchema } from "@t3tools/contracts"; import { homedir } from "node:os"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; @@ -34,6 +35,10 @@ const toResult = (r: ScanResult): SubagentsForCwdResult const makeScanner = Effect.gen(function* () { const config = yield* ServerConfig; const path = yield* Path.Path; + // ru-fork: resolve FileSystem at layer creation and provide it (with Path) into each + // method below, so the scan dependencies stay internal to the layer rather than leaking + // onto the public service surface. + const fs = yield* FileSystem.FileSystem; // Mirrors cli-code's SubagentManager: when `cwd === homedir`, project // scan would re-read the user agents dir and double-list every entry. @@ -62,8 +67,18 @@ const makeScanner = Effect.gen(function* () { }); return { - getSubagentsForCwd: (cwd) => core.getForCwd(cwd).pipe(Effect.map(toResult)), - refreshSubagentsForCwd: (cwd) => core.refreshForCwd(cwd).pipe(Effect.map(toResult)), + getSubagentsForCwd: (cwd) => + core.getForCwd(cwd).pipe( + Effect.map(toResult), + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + ), + refreshSubagentsForCwd: (cwd) => + core.refreshForCwd(cwd).pipe( + Effect.map(toResult), + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + ), } satisfies SubagentScannerShape; }); diff --git a/apps/server/src/ru-fork/subagents/SubagentScannerService.ts b/apps/server/src/ru-fork/subagents/SubagentScannerService.ts index c38b071b69d..30790d642a0 100644 --- a/apps/server/src/ru-fork/subagents/SubagentScannerService.ts +++ b/apps/server/src/ru-fork/subagents/SubagentScannerService.ts @@ -7,8 +7,6 @@ import type { ServerProviderSubagent } from "@t3tools/contracts"; import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; -import type * as FileSystem from "effect/FileSystem"; -import type * as Path from "effect/Path"; export interface SubagentsForCwdResult { readonly builtin: ReadonlyArray; @@ -16,8 +14,6 @@ export interface SubagentsForCwdResult { readonly project: ReadonlyArray; } -export type SubagentScannerEnv = FileSystem.FileSystem | Path.Path; - export interface SubagentScannerShape { /** * Read the latest known subagents for a cwd. Returns cached values @@ -27,7 +23,7 @@ export interface SubagentScannerShape { */ readonly getSubagentsForCwd: ( cwd: string | null, - ) => Effect.Effect; + ) => Effect.Effect; /** * Force a re-scan of the user-level root and (when cwd is non-null) @@ -35,7 +31,7 @@ export interface SubagentScannerShape { */ readonly refreshSubagentsForCwd: ( cwd: string | null, - ) => Effect.Effect; + ) => Effect.Effect; } export class SubagentScanner extends Context.Service()( diff --git a/apps/server/src/ru-fork/turnCompletedCheckpointDispatch.ts b/apps/server/src/ru-fork/turnCompletedCheckpointDispatch.ts index 11ccb549b2c..f3eafe6404c 100644 --- a/apps/server/src/ru-fork/turnCompletedCheckpointDispatch.ts +++ b/apps/server/src/ru-fork/turnCompletedCheckpointDispatch.ts @@ -21,6 +21,7 @@ * * @module ru-fork/turnCompletedCheckpointDispatch */ +import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; import { @@ -44,6 +45,7 @@ export const dispatchTurnCompletedCheckpointPlaceholder = (params: { readonly now: string; }) => Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; const orchestrationEngine = yield* OrchestrationEngineService; @@ -76,10 +78,11 @@ export const dispatchTurnCompletedCheckpointPlaceholder = (params: { const checkpointTurnCount = checkpointContext.checkpoints.reduce((max, c) => Math.max(max, c.checkpointTurnCount), 0) + 1; + const commandUuid = yield* crypto.randomUUIDv4; yield* orchestrationEngine.dispatch({ type: "thread.turn.diff.complete", commandId: CommandId.make( - `provider:${params.event.eventId}:thread-turn-diff-complete-from-completion:${crypto.randomUUID()}`, + `provider:${params.event.eventId}:thread-turn-diff-complete-from-completion:${commandUuid}`, ), threadId: params.threadId, turnId: params.turnId, diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 1d42605b913..5f9c2ef8420 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -8,6 +8,7 @@ import { ProviderInstanceId, ThreadId, } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; import * as Data from "effect/Data"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -142,6 +143,8 @@ export const resolveWelcomeBase = Effect.gen(function* () { }); export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { + const crypto = yield* Crypto.Crypto; + const randomUUID = crypto.randomUUIDv4; const serverConfig = yield* ServerConfig; const projectionReadModelQuery = yield* ProjectionSnapshotQuery; const orchestrationEngine = yield* OrchestrationEngineService; @@ -160,12 +163,12 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { if (Option.isNone(existingProject)) { const createdAt = DateTime.formatIso(yield* DateTime.now); - nextProjectId = ProjectId.make(crypto.randomUUID()); + nextProjectId = ProjectId.make(yield* randomUUID); const bootstrapProjectTitle = path.basename(serverConfig.cwd) || "project"; nextProjectDefaultModelSelection = getAutoBootstrapDefaultModelSelection(); yield* orchestrationEngine.dispatch({ type: "project.create", - commandId: CommandId.make(crypto.randomUUID()), + commandId: CommandId.make(yield* randomUUID), projectId: nextProjectId, title: bootstrapProjectTitle, workspaceRoot: serverConfig.cwd, @@ -182,10 +185,10 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { yield* projectionReadModelQuery.getFirstActiveThreadIdByProjectId(nextProjectId); if (Option.isNone(existingThreadId)) { const createdAt = DateTime.formatIso(yield* DateTime.now); - const createdThreadId = ThreadId.make(crypto.randomUUID()); + const createdThreadId = ThreadId.make(yield* randomUUID); yield* orchestrationEngine.dispatch({ type: "thread.create", - commandId: CommandId.make(crypto.randomUUID()), + commandId: CommandId.make(yield* randomUUID), threadId: createdThreadId, projectId: nextProjectId, title: "Новый диалог", @@ -245,6 +248,7 @@ export const makeServerRuntimeStartup = Effect.gen(function* () { const lifecycleEvents = yield* ServerLifecycleEvents; const serverSettings = yield* ServerSettingsService; const serverEnvironment = yield* ServerEnvironment; + const crypto = yield* Crypto.Crypto; const commandGate = yield* makeCommandGate; const httpListening = yield* Deferred.make(); @@ -328,7 +332,9 @@ export const makeServerRuntimeStartup = Effect.gen(function* () { runStartupPhase( "welcome.autobootstrap", Effect.gen(function* () { - const bootstrapTargets = yield* resolveAutoBootstrapWelcomeTargets; + const bootstrapTargets = yield* resolveAutoBootstrapWelcomeTargets.pipe( + Effect.provideService(Crypto.Crypto, crypto), + ); if (!bootstrapTargets.bootstrapProjectId && !bootstrapTargets.bootstrapThreadId) { return; } diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts index 1909cd8181c..ada9760139a 100644 --- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts +++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts @@ -278,12 +278,12 @@ export const make = Effect.fn("makeSourceControlRepositoryService")(function* () }) .pipe( Effect.map(() => true), - Effect.catch(() => Effect.succeed(false)), + Effect.orElseSucceed(() => false), ); if (!hasCommits) { const details = yield* git .statusDetails(input.cwd) - .pipe(Effect.catch(() => Effect.succeed(null))); + .pipe(Effect.orElseSucceed(() => null)); return { repository: toRepositoryInfo(providerKind, urls), remoteName, diff --git a/apps/server/src/terminal/Layers/Manager.ts b/apps/server/src/terminal/Layers/Manager.ts index 2758d82d8f5..340ee643b40 100644 --- a/apps/server/src/terminal/Layers/Manager.ts +++ b/apps/server/src/terminal/Layers/Manager.ts @@ -1112,7 +1112,7 @@ export const makeTerminalManagerWithOptions = Effect.fn("makeTerminalManagerWith const threadPrefix = `${toSafeThreadId(threadId)}_`; const entries = yield* fileSystem .readDirectory(logsDir, { recursive: false }) - .pipe(Effect.catch(() => Effect.succeed([] as Array))); + .pipe(Effect.orElseSucceed(() => [] as Array)); yield* Effect.forEach( entries.filter( (name) => diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 8f6be65ddb1..2d9d423b6a7 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -395,7 +395,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* ( "GitVcsDriver.detectRepository.commonDir", cwd, ["rev-parse", "--git-common-dir"], - ).pipe(Effect.catch(() => Effect.succeed(null))); + ).pipe(Effect.orElseSucceed(() => null)); return { kind: "git" as const, diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index a65c12af57e..14610e03e5c 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -881,7 +881,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const remoteNames = yield* runGitStdout("GitVcsDriver.listRemoteNames", cwd, ["remote"]).pipe( Effect.map(parseRemoteNames), - Effect.catch(() => Effect.succeed>([])), + Effect.orElseSucceed((): ReadonlyArray => []), ); return ( parseUpstreamRefWithRemoteNames(upstreamRef, remoteNames) ?? @@ -940,7 +940,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* // through to the legacy behavior so a broken settings layer never silently // hides remote state. const settings = yield* serverSettings.getSettings.pipe( - Effect.catch(() => Effect.succeed(null)), + Effect.orElseSucceed(() => null), ); const intervalMs = settings === null ? null : Duration.toMillis(settings.automaticGitFetchInterval); @@ -1013,7 +1013,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* cwd: string, branchName: string, ) { - const remoteNames = yield* listRemoteNames(cwd).pipe(Effect.catch(() => Effect.succeed([]))); + const remoteNames = yield* listRemoteNames(cwd).pipe(Effect.orElseSucceed(() => [])); const parsedRemoteRef = parseRemoteRefWithRemoteNames(branchName, remoteNames); return parsedRemoteRef?.branchName ?? branchName; }); @@ -1059,7 +1059,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return pushDefaultRemote; } - return yield* resolvePrimaryRemoteName(cwd).pipe(Effect.catch(() => Effect.succeed(null))); + return yield* resolvePrimaryRemoteName(cwd).pipe(Effect.orElseSucceed(() => null)); }); const ensureRemote: GitVcsDriver.GitVcsDriverShape["ensureRemote"] = Effect.fn("ensureRemote")( @@ -1107,7 +1107,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ).pipe(Effect.map((stdout) => stdout.trim())); const primaryRemoteName = yield* resolvePrimaryRemoteName(cwd).pipe( - Effect.catch(() => Effect.succeed(null)), + Effect.orElseSucceed(() => null), ); const defaultBranch = primaryRemoteName === null ? null : yield* resolveDefaultBranchName(cwd, primaryRemoteName); @@ -1248,7 +1248,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* allowNonZeroExit: true, }, ), - originRemoteExists(cwd).pipe(Effect.catch(() => Effect.succeed(false))), + originRemoteExists(cwd).pipe(Effect.orElseSucceed(() => false)), ], { concurrency: "unbounded" }, ); @@ -1294,7 +1294,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const fallbackAheadCount = !upstreamRef && refName ? yield* computeAheadCountAgainstBase(cwd, refName).pipe( - Effect.catch(() => Effect.succeed(0)), + Effect.orElseSucceed(() => 0), ) : null; @@ -1312,7 +1312,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* fallbackAheadCount !== null ? fallbackAheadCount : yield* computeAheadCountAgainstBase(cwd, refName).pipe( - Effect.catch(() => Effect.succeed(0)), + Effect.orElseSucceed(() => 0), ); } @@ -1521,11 +1521,11 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } const comparableBaseBranch = yield* resolveBaseBranchForNoUpstream(cwd, branch).pipe( - Effect.catch(() => Effect.succeed(null)), + Effect.orElseSucceed(() => null), ); if (comparableBaseBranch) { const publishRemoteName = yield* resolvePushRemoteName(cwd, branch).pipe( - Effect.catch(() => Effect.succeed(null)), + Effect.orElseSucceed(() => null), ); if (!publishRemoteName) { return { @@ -1535,7 +1535,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } const hasRemoteBranch = yield* remoteBranchExists(cwd, publishRemoteName, branch).pipe( - Effect.catch(() => Effect.succeed(false)), + Effect.orElseSucceed(() => false), ); if (hasRemoteBranch) { return { @@ -1572,7 +1572,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } const currentUpstream = yield* resolveCurrentUpstream(cwd).pipe( - Effect.catch(() => Effect.succeed(null)), + Effect.orElseSucceed(() => null), ); if (currentUpstream) { yield* runGit("GitVcsDriver.pushCurrentBranch.pushUpstream", cwd, [ @@ -1696,7 +1696,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const listRefs: GitVcsDriver.GitVcsDriverShape["listRefs"] = Effect.fn("listRefs")( function* (input) { const branchRecencyPromise = readBranchRecency(input.cwd).pipe( - Effect.catch(() => Effect.succeed(new Map())), + Effect.orElseSucceed(() => new Map()), ); const localBranchResult = yield* executeGit( "GitVcsDriver.listRefs.branchNoColor", @@ -1839,7 +1839,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const candidatePath = line.slice("worktree ".length); const exists = yield* fileSystem.stat(candidatePath).pipe( Effect.map(() => true), - Effect.catch(() => Effect.succeed(false)), + Effect.orElseSucceed(() => false), ); currentPath = exists ? candidatePath : null; } else if (line.startsWith("branch refs/heads/") && currentPath) { diff --git a/apps/server/src/workspace/Layers/WorkspaceEntries.ts b/apps/server/src/workspace/Layers/WorkspaceEntries.ts index 0c0ab638207..ac26f013a44 100644 --- a/apps/server/src/workspace/Layers/WorkspaceEntries.ts +++ b/apps/server/src/workspace/Layers/WorkspaceEntries.ts @@ -187,7 +187,7 @@ export const makeWorkspaceEntries = Effect.gen(function* () { const isInsideVcsWorkTree = (cwd: string): Effect.Effect => vcsRegistry.detect({ cwd }).pipe( Effect.map((handle) => handle !== null), - Effect.catch(() => Effect.succeed(false)), + Effect.orElseSucceed(() => false), ); const filterVcsIgnoredPaths = ( @@ -199,23 +199,23 @@ export const makeWorkspaceEntries = Effect.gen(function* () { handle ? handle.driver.filterIgnoredPaths(cwd, relativePaths).pipe( Effect.map((paths) => [...paths]), - Effect.catch(() => Effect.succeed(relativePaths)), + Effect.orElseSucceed(() => relativePaths), ) : Effect.succeed(relativePaths), ), - Effect.catch(() => Effect.succeed(relativePaths)), + Effect.orElseSucceed(() => relativePaths), ); const buildWorkspaceIndexFromVcs = Effect.fn("WorkspaceEntries.buildWorkspaceIndexFromVcs")( function* (cwd: string) { - const vcs = yield* vcsRegistry.detect({ cwd }).pipe(Effect.catch(() => Effect.succeed(null))); + const vcs = yield* vcsRegistry.detect({ cwd }).pipe(Effect.orElseSucceed(() => null)); if (!vcs) { return null; } const listedFiles = yield* vcs.driver .listWorkspaceFiles(cwd) - .pipe(Effect.catch(() => Effect.succeed(null))); + .pipe(Effect.orElseSucceed(() => null)); if (!listedFiles) { return null; @@ -426,7 +426,7 @@ export const makeWorkspaceEntries = Effect.gen(function* () { const invalidate: WorkspaceEntriesShape["invalidate"] = Effect.fn("WorkspaceEntries.invalidate")( function* (cwd) { const normalizedCwd = yield* normalizeWorkspaceRoot(cwd).pipe( - Effect.catch(() => Effect.succeed(cwd)), + Effect.orElseSucceed(() => cwd), ); yield* Cache.invalidate(workspaceIndexCache, cwd); if (normalizedCwd !== cwd) { diff --git a/apps/server/src/workspace/Layers/WorkspacePaths.ts b/apps/server/src/workspace/Layers/WorkspacePaths.ts index 9dd33aaac7d..f994aa875ef 100644 --- a/apps/server/src/workspace/Layers/WorkspacePaths.ts +++ b/apps/server/src/workspace/Layers/WorkspacePaths.ts @@ -37,7 +37,7 @@ export const makeWorkspacePaths = Effect.gen(function* () { const normalizedWorkspaceRoot = path.resolve(expandHomePath(workspaceRoot.trim(), path)); let workspaceStat = yield* fileSystem .stat(normalizedWorkspaceRoot) - .pipe(Effect.catch(() => Effect.succeed(null))); + .pipe(Effect.orElseSucceed(() => null)); if (!workspaceStat && options?.createIfMissing) { yield* fileSystem.makeDirectory(normalizedWorkspaceRoot, { recursive: true }).pipe( Effect.mapError( @@ -50,7 +50,7 @@ export const makeWorkspacePaths = Effect.gen(function* () { ); workspaceStat = yield* fileSystem .stat(normalizedWorkspaceRoot) - .pipe(Effect.catch(() => Effect.succeed(null))); + .pipe(Effect.orElseSucceed(() => null)); } if (!workspaceStat) { return yield* new WorkspaceRootNotExistsError({ diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index a6ac853036b..31f7d75ce35 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1,4 +1,5 @@ import * as Cause from "effect/Cause"; +import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -245,8 +246,11 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => const mcpSupervisor = yield* McpSupervisor; // ru-fork: stats (analytics) — incremental scanner over the projects root. const statsScanner = yield* StatsScanner; + const crypto = yield* Crypto.Crypto; + const randomUUID = crypto.randomUUIDv4.pipe(Effect.orDie); + const serverEventId = randomUUID.pipe(Effect.map(EventId.make)); const serverCommandId = (tag: string) => - CommandId.make(`server:${tag}:${crypto.randomUUID()}`); + randomUUID.pipe(Effect.map((uuid) => CommandId.make(`server:${tag}:${uuid}`))); const loadAuthAccessSnapshot = () => Effect.all({ @@ -262,21 +266,28 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => readonly payload: Record; readonly tone: "info" | "error"; }) => - orchestrationEngine.dispatch({ - type: "thread.activity.append", + Effect.all({ commandId: serverCommandId("setup-script-activity"), - threadId: input.threadId, - activity: { - id: EventId.make(crypto.randomUUID()), - tone: input.tone, - kind: input.kind, - summary: input.summary, - payload: input.payload, - turnId: null, - createdAt: input.createdAt, - }, - createdAt: input.createdAt, - }); + activityId: serverEventId, + }).pipe( + Effect.flatMap(({ commandId, activityId }) => + orchestrationEngine.dispatch({ + type: "thread.activity.append", + commandId, + threadId: input.threadId, + activity: { + id: activityId, + tone: input.tone, + kind: input.kind, + summary: input.summary, + payload: input.payload, + turnId: null, + createdAt: input.createdAt, + }, + createdAt: input.createdAt, + }), + ), + ); const toDispatchCommandError = (cause: unknown, fallbackMessage: string) => isOrchestrationDispatchCommandError(cause) @@ -335,7 +346,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => repositoryIdentity, }, } satisfies OrchestrationEvent; - }).pipe(Effect.catch(() => Effect.succeed(event))); + }).pipe(Effect.orElseSucceed(() => event)); default: return Effect.succeed(event); } @@ -407,7 +418,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => thread: nextThread, })), ), - Effect.catch(() => Effect.succeed(Option.none())), + Effect.orElseSucceed(() => Option.none()), ); default: if (event.aggregateKind !== "thread") { @@ -423,7 +434,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => thread: nextThread, })), ), - Effect.catch(() => Effect.succeed(Option.none())), + Effect.orElseSucceed(() => Option.none()), ); } }; @@ -441,13 +452,16 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => const cleanupCreatedThread = () => createdThread - ? orchestrationEngine - .dispatch({ - type: "thread.delete", - commandId: serverCommandId("bootstrap-thread-delete"), - threadId: command.threadId, - }) - .pipe(Effect.ignoreCause({ log: true })) + ? serverCommandId("bootstrap-thread-delete").pipe( + Effect.flatMap((commandId) => + orchestrationEngine.dispatch({ + type: "thread.delete", + commandId, + threadId: command.threadId, + }), + ), + Effect.ignoreCause({ log: true }), + ) : Effect.void; const recordSetupScriptLaunchFailure = (input: { @@ -570,7 +584,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => if (bootstrap?.createThread) { yield* orchestrationEngine.dispatch({ type: "thread.create", - commandId: serverCommandId("bootstrap-thread-create"), + commandId: yield* serverCommandId("bootstrap-thread-create"), threadId: command.threadId, projectId: bootstrap.createThread.projectId, title: bootstrap.createThread.title, @@ -594,7 +608,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => targetWorktreePath = worktree.worktree.path; yield* orchestrationEngine.dispatch({ type: "thread.meta.update", - commandId: serverCommandId("bootstrap-thread-meta-update"), + commandId: yield* serverCommandId("bootstrap-thread-meta-update"), threadId: command.threadId, branch: worktree.worktree.refName, worktreePath: targetWorktreePath, @@ -687,7 +701,7 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => thread.session !== null && thread.session.status !== "stopped", }), ), - Effect.catch(() => Effect.succeed(false)), + Effect.orElseSucceed(() => false), ) : false; const result = yield* dispatchNormalizedCommand(normalizedCommand); diff --git a/apps/server/tests/orchestration/decider.delete.test.ts b/apps/server/tests/orchestration/decider.delete.test.ts index d8783cdc72c..279a6e315d5 100644 --- a/apps/server/tests/orchestration/decider.delete.test.ts +++ b/apps/server/tests/orchestration/decider.delete.test.ts @@ -12,7 +12,16 @@ import { import * as Effect from "effect/Effect"; import { describe, expect, it } from "vitest"; -import { decideOrchestrationCommand } from "../../src/orchestration/decider.ts"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; + +import { decideOrchestrationCommand as decideOrchestrationCommandRaw } from "../../src/orchestration/decider.ts"; + +// ru-fork: the decider now resolves event ids through the Effect `Crypto` service +// (withEventBase). Provide the Node implementation so these unit tests can run the +// decide effect directly without standing up a full runtime. +const decideOrchestrationCommand = ( + input: Parameters[0], +) => decideOrchestrationCommandRaw(input).pipe(Effect.provide(NodeCrypto.layer)); import { createEmptyReadModel, projectEvent } from "../../src/orchestration/projector.ts"; const asCommandId = (value: string): CommandId => CommandId.make(value); diff --git a/apps/server/tests/orchestration/decider.projectScripts.test.ts b/apps/server/tests/orchestration/decider.projectScripts.test.ts index 97c397f8ea0..9ba1448ac2e 100644 --- a/apps/server/tests/orchestration/decider.projectScripts.test.ts +++ b/apps/server/tests/orchestration/decider.projectScripts.test.ts @@ -11,7 +11,16 @@ import { createModelSelection } from "@t3tools/shared/model"; import { describe, expect, it } from "vitest"; import * as Effect from "effect/Effect"; -import { decideOrchestrationCommand } from "../../src/orchestration/decider.ts"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; + +import { decideOrchestrationCommand as decideOrchestrationCommandRaw } from "../../src/orchestration/decider.ts"; + +// ru-fork: the decider now resolves event ids through the Effect `Crypto` service +// (withEventBase). Provide the Node implementation so these unit tests can run the +// decide effect directly without standing up a full runtime. +const decideOrchestrationCommand = ( + input: Parameters[0], +) => decideOrchestrationCommandRaw(input).pipe(Effect.provide(NodeCrypto.layer)); import { createEmptyReadModel, projectEvent } from "../../src/orchestration/projector.ts"; const asEventId = (value: string): EventId => EventId.make(value); diff --git a/apps/server/tests/ru-fork/mcp/reactorErrorArms.test.ts b/apps/server/tests/ru-fork/mcp/reactorErrorArms.test.ts index f6d04efb1ba..156a95788b7 100644 --- a/apps/server/tests/ru-fork/mcp/reactorErrorArms.test.ts +++ b/apps/server/tests/ru-fork/mcp/reactorErrorArms.test.ts @@ -12,6 +12,8 @@ import { type McpCatalogServer, type McpProbeRecord, } from "@t3tools/contracts"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import type * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; @@ -77,14 +79,18 @@ const fakeProbeCache = (record: McpProbeRecord) => const stdioConfig = (id: string) => ({ transport: "stdio" as const, command: "uvx", args: [id] }); -// Run `effect` (already fully provided except logging) and capture every emitted log entry. -async function runCapturingLogs(effect: Effect.Effect) { +// Run `effect` (already fully provided except logging + the Crypto service the reactor's +// commandId minting now needs) and capture every emitted log entry. +async function runCapturingLogs(effect: Effect.Effect) { const captured: Array<{ readonly level: string; readonly message: string }> = []; const logger = Logger.make(({ logLevel, message }) => { captured.push({ level: String(logLevel), message: String(message) }); }); await Effect.runPromise( - effect.pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))), + effect.pipe( + Effect.provide(NodeCrypto.layer), + Effect.provide(Logger.layer([logger], { mergeWithExisting: false })), + ), ); return captured; } diff --git a/apps/server/tests/ru-fork/skills/SkillScanner.test.ts b/apps/server/tests/ru-fork/skills/SkillScanner.test.ts new file mode 100644 index 00000000000..fee0974e0de --- /dev/null +++ b/apps/server/tests/ru-fork/skills/SkillScanner.test.ts @@ -0,0 +1,69 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; + +import { CLI_FOLDER, ServerConfig } from "../../../src/config.ts"; +import { SkillScannerLive } from "../../../src/ru-fork/skills/SkillScannerLive.ts"; +import { SkillScanner } from "../../../src/ru-fork/skills/SkillScannerService.ts"; + +// Characterisation test for the SkillScanner service: builds a real temp skills +// tree on disk and verifies getSkillsForCwd returns the global + project skills +// (and globals-only for cwd=null). Doubles as a regression guard for the +// "provide FileSystem|Path in make" leak fix — the method's env must stay +// internal without changing observable behaviour. +it.effect("scans global + project SKILL.md files, and globals-only for cwd=null", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const root = yield* fs.makeTempDirectoryScoped({ prefix: "skill-scanner-" }); + // ServerConfig.layerTest derives cliConfigDir = dirname(baseDir)/CLI_FOLDER, + // so global skills resolve under //skills. + const baseDir = path.join(root, "app"); + const projectCwd = path.join(root, "project"); + const globalSkillsRoot = path.join(root, CLI_FOLDER, "skills"); + const projectSkillsRoot = path.join(projectCwd, CLI_FOLDER, "skills"); + + const writeSkill = (skillsRoot: string, name: string, description: string) => + Effect.gen(function* () { + const dir = path.join(skillsRoot, name); + yield* fs.makeDirectory(dir, { recursive: true }); + yield* fs.writeFileString( + path.join(dir, "SKILL.md"), + `---\nname: ${name}\ndescription: ${description}\n---\n# ${name}\n`, + ); + }); + + yield* writeSkill(globalSkillsRoot, "global-skill", "A global skill"); + yield* writeSkill(projectSkillsRoot, "project-skill", "A project skill"); + + const context = yield* Layer.build( + SkillScannerLive.pipe(Layer.provide(ServerConfig.layerTest(projectCwd, baseDir))), + ); + const scanner = Context.get(context, SkillScanner); + + const forProject = yield* scanner.getSkillsForCwd(projectCwd); + assert.deepEqual( + forProject.global.map((skill) => skill.name), + ["global-skill"], + ); + assert.equal(forProject.global[0]?.description, "A global skill"); + assert.deepEqual( + forProject.project.map((skill) => skill.name), + ["project-skill"], + ); + + const globalsOnly = yield* scanner.getSkillsForCwd(null); + assert.deepEqual( + globalsOnly.global.map((skill) => skill.name), + ["global-skill"], + ); + assert.deepEqual(globalsOnly.project, []); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/tests/ru-fork/subagents/SubagentScanner.test.ts b/apps/server/tests/ru-fork/subagents/SubagentScanner.test.ts new file mode 100644 index 00000000000..69de546de22 --- /dev/null +++ b/apps/server/tests/ru-fork/subagents/SubagentScanner.test.ts @@ -0,0 +1,67 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, it } from "@effect/vitest"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; + +import { CLI_FOLDER, ServerConfig } from "../../../src/config.ts"; +import { BUILTIN_SUBAGENTS } from "../../../src/ru-fork/subagents/builtinSubagents.ts"; +import { SubagentScannerLive } from "../../../src/ru-fork/subagents/SubagentScannerLive.ts"; +import { SubagentScanner } from "../../../src/ru-fork/subagents/SubagentScannerService.ts"; + +// Characterisation test for the SubagentScanner service: subagents are flat +// `/.md` files. Verifies getSubagentsForCwd returns the always-on +// builtin bucket plus the user + project agents scanned from disk. Doubles as a +// regression guard for the "provide FileSystem|Path in make" leak fix. +it.effect("scans builtin + user + project agent files for a cwd", () => + Effect.scoped( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const root = yield* fs.makeTempDirectoryScoped({ prefix: "subagent-scanner-" }); + const baseDir = path.join(root, "app"); + const projectCwd = path.join(root, "project"); + const globalAgentsRoot = path.join(root, CLI_FOLDER, "agents"); + const projectAgentsRoot = path.join(projectCwd, CLI_FOLDER, "agents"); + + const writeAgent = (agentsRoot: string, name: string, description: string) => + Effect.gen(function* () { + yield* fs.makeDirectory(agentsRoot, { recursive: true }); + yield* fs.writeFileString( + path.join(agentsRoot, `${name}.md`), + `---\nname: ${name}\ndescription: ${description}\n---\n# ${name}\n`, + ); + }); + + yield* writeAgent(globalAgentsRoot, "global-agent", "A global agent"); + yield* writeAgent(projectAgentsRoot, "project-agent", "A project agent"); + + const context = yield* Layer.build( + SubagentScannerLive.pipe(Layer.provide(ServerConfig.layerTest(projectCwd, baseDir))), + ); + const scanner = Context.get(context, SubagentScanner); + + const forProject = yield* scanner.getSubagentsForCwd(projectCwd); + assert.deepEqual(forProject.builtin, BUILTIN_SUBAGENTS); + assert.deepEqual( + forProject.user.map((agent) => agent.name), + ["global-agent"], + ); + assert.equal(forProject.user[0]?.description, "A global agent"); + assert.deepEqual( + forProject.project.map((agent) => agent.name), + ["project-agent"], + ); + + const globalsOnly = yield* scanner.getSubagentsForCwd(null); + assert.deepEqual( + globalsOnly.user.map((agent) => agent.name), + ["global-agent"], + ); + assert.deepEqual(globalsOnly.project, []); + }), + ).pipe(Effect.provide(NodeServices.layer)), +); diff --git a/apps/web/package.json b/apps/web/package.json index 37974bcb445..ab4ef1621a5 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "vite build", "preview": "vite preview", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "typecheck:tsgo": "tsgo --noEmit" }, "dependencies": { "@base-ui/react": "1.4.1", diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index a67db12e202..17b9795cf19 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -15,22 +15,13 @@ "plugins": [ { "name": "@effect/language-service", - "namespaceImportPackages": ["@effect/platform-node", "effect"], + // Diagnostic severities are deep-merged with tsconfig.base.json; list only web-specific overrides here. "diagnosticSeverity": { - "importFromBarrel": "error", - "anyUnknownInErrorContext": "error", - "unsafeEffectTypeAssertion": "error", - "instanceOfSchema": "error", - "deterministicKeys": "error", - "strictEffectProvide": "off", - "missingEffectServiceDependency": "error", - "leakingRequirements": "error", - "globalErrorInEffectCatch": "error", - "globalErrorInEffectFailure": "error", - "unknownInEffectCatch": "error", - "strictBooleanExpressions": "off", - "preferSchemaOverJson": "error", - "schemaSyncInEffect": "error" + "globalDate": "off", + "globalConsole": "off", + "globalRandom": "off", + "globalTimers": "off", + "globalFetch": "off" } } ] diff --git a/apps/web/vite-branding-plugin.ts b/apps/web/vite-branding-plugin.ts index ae8703174ca..5dcaab93b44 100644 --- a/apps/web/vite-branding-plugin.ts +++ b/apps/web/vite-branding-plugin.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics nodeBuiltinImport:off -- vite build plugin runs in Node, not an Effect runtime import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import type { Plugin } from "vite"; diff --git a/oxlint-plugin-t3code/package.json b/oxlint-plugin-t3code/package.json index f738fb9a6d6..c74b74a4f7c 100644 --- a/oxlint-plugin-t3code/package.json +++ b/oxlint-plugin-t3code/package.json @@ -4,6 +4,7 @@ "type": "module", "scripts": { "typecheck": "tsc --noEmit", + "typecheck:tsgo": "tsgo --noEmit", "test": "vitest run" }, "dependencies": { diff --git a/package.json b/package.json index 53276665263..6738afb65c9 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ }, "type": "module", "scripts": { - "prepare": "effect-language-service patch", + "prepare": "effect-language-service patch && effect-tsgo patch", + "typecheck:tsgo": "turbo run typecheck:tsgo", "dev": "node scripts/dev-runner.ts dev", "dev:server": "node scripts/dev-runner.ts dev:server", "dev:web": "node scripts/dev-runner.ts dev:web", @@ -27,7 +28,9 @@ }, "devDependencies": { "@effect/language-service": "catalog:", + "@effect/tsgo": "catalog:", "@oxlint/plugins": "^1.63.0", + "@typescript/native-preview": "catalog:", "@types/node": "catalog:", "beachball": "2.51.0", "oxfmt": "0.40.0", diff --git a/packages/branding/package.json b/packages/branding/package.json index ba030d3fd72..c4cc5abe626 100644 --- a/packages/branding/package.json +++ b/packages/branding/package.json @@ -10,7 +10,8 @@ } }, "scripts": { - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "typecheck:tsgo": "tsgo --noEmit" }, "devDependencies": { "@types/node": "catalog:", diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index ff7a22f9a05..f59a6591dc8 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -10,7 +10,8 @@ } }, "scripts": { - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "typecheck:tsgo": "tsgo --noEmit" }, "dependencies": { "@t3tools/contracts": "workspace:*", diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 38773de431f..839fa035c43 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -24,7 +24,8 @@ "scripts": { "dev": "tsdown src/index.ts --format esm,cjs --dts --watch --clean", "build": "tsdown src/index.ts --format esm,cjs --dts --clean", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "typecheck:tsgo": "tsgo --noEmit" }, "dependencies": { "@ru-fork/branding": "workspace:*", diff --git a/packages/effect-acp/package.json b/packages/effect-acp/package.json index f4710bffdac..11c795f5cd9 100644 --- a/packages/effect-acp/package.json +++ b/packages/effect-acp/package.json @@ -36,6 +36,7 @@ "dev": "tsdown src/client.ts src/agent.ts src/_generated/schema.gen.ts src/rpc.ts src/protocol.ts src/terminal.ts --format esm,cjs --dts --watch --clean", "build": "tsdown src/client.ts src/agent.ts src/_generated/schema.gen.ts src/rpc.ts src/protocol.ts src/terminal.ts --format esm,cjs --dts --clean", "typecheck": "tsc --noEmit", + "typecheck:tsgo": "tsgo --noEmit", "generate": "node scripts/generate.ts" }, "dependencies": { diff --git a/packages/effect-acp/scripts/generate.ts b/packages/effect-acp/scripts/generate.ts index 374f636df1e..61873a1a811 100644 --- a/packages/effect-acp/scripts/generate.ts +++ b/packages/effect-acp/scripts/generate.ts @@ -146,7 +146,7 @@ function collectSchemaEntries( return entries; } -function normalizeNullableTypes(value: typeof Schema.Json.Type): typeof Schema.Json.Type { +function normalizeNullableTypes(value: Schema.Json): Schema.Json { if (Array.isArray(value)) { return value.map(normalizeNullableTypes); } @@ -160,7 +160,7 @@ function normalizeNullableTypes(value: typeof Schema.Json.Type): typeof Schema.J ]); const normalizedObject = Object.fromEntries(normalizedEntries) as Record< string, - typeof Schema.Json.Type + Schema.Json >; const typeValue = normalizedObject.type; @@ -179,7 +179,7 @@ function normalizeNullableTypes(value: typeof Schema.Json.Type): typeof Schema.J } const nonNullType = nonNullTypes[0]!; - const nextObject: Record = {}; + const nextObject: Record = {}; for (const [key, child] of Object.entries(normalizedObject)) { if (key !== "type") { nextObject[key] = child; diff --git a/packages/effect-acp/src/agent.ts b/packages/effect-acp/src/agent.ts index 0de2b49b843..67bcd0f4c6d 100644 --- a/packages/effect-acp/src/agent.ts +++ b/packages/effect-acp/src/agent.ts @@ -207,7 +207,9 @@ export interface AcpAgentShape { ) => Effect.Effect; } -export class AcpAgent extends Context.Service()("effect-acp/AcpAgent") {} +export class AcpAgent extends Context.Service()( + "effect-acp/agent/AcpAgent", +) {} interface AcpCoreAgentRequestHandlers { initialize?: ( diff --git a/packages/effect-acp/src/protocol.ts b/packages/effect-acp/src/protocol.ts index 8fd0b5eb4be..7ffe6cc0131 100644 --- a/packages/effect-acp/src/protocol.ts +++ b/packages/effect-acp/src/protocol.ts @@ -29,12 +29,12 @@ export type AcpIncomingNotification = | { readonly _tag: "SessionUpdate"; readonly method: typeof CLIENT_METHODS.session_update; - readonly params: typeof AcpSchema.SessionNotification.Type; + readonly params: AcpSchema.SessionNotification; } | { readonly _tag: "ElicitationComplete"; readonly method: typeof CLIENT_METHODS.session_elicitation_complete; - readonly params: typeof AcpSchema.ElicitationCompleteNotification.Type; + readonly params: AcpSchema.ElicitationCompleteNotification; } | { readonly _tag: "ExtNotification"; diff --git a/packages/effect-acp/tsconfig.json b/packages/effect-acp/tsconfig.json index 04f1d37800f..8891950e04e 100644 --- a/packages/effect-acp/tsconfig.json +++ b/packages/effect-acp/tsconfig.json @@ -1,5 +1,7 @@ { "extends": "../../tsconfig.base.json", - "compilerOptions": {}, + "compilerOptions": { + "types": ["node"] + }, "include": ["src", "scripts", "test"] } diff --git a/packages/mcp-core/package.json b/packages/mcp-core/package.json index 1f919105dc3..09f8880a37a 100644 --- a/packages/mcp-core/package.json +++ b/packages/mcp-core/package.json @@ -10,7 +10,8 @@ } }, "scripts": { - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "typecheck:tsgo": "tsgo --noEmit" }, "dependencies": { "@modelcontextprotocol/sdk": "catalog:", diff --git a/packages/mcp-core/tsconfig.json b/packages/mcp-core/tsconfig.json index 73a306f847a..374bac55202 100644 --- a/packages/mcp-core/tsconfig.json +++ b/packages/mcp-core/tsconfig.json @@ -1,5 +1,7 @@ { "extends": "../../tsconfig.base.json", - "compilerOptions": {}, + "compilerOptions": { + "types": ["node"] + }, "include": ["src"] } diff --git a/packages/shared/package.json b/packages/shared/package.json index 57ec2054c3a..660348e0cb7 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -86,7 +86,8 @@ } }, "scripts": { - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "typecheck:tsgo": "tsgo --noEmit" }, "dependencies": { "@ru-fork/branding": "workspace:*", diff --git a/pixso-move/contracts/package.json b/pixso-move/contracts/package.json index 768092d7a91..6142239d932 100644 --- a/pixso-move/contracts/package.json +++ b/pixso-move/contracts/package.json @@ -4,10 +4,14 @@ "private": true, "type": "module", "exports": { - ".": { "types": "./src/index.ts", "import": "./src/index.ts" } + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } }, "scripts": { "typecheck": "tsc --noEmit", + "typecheck:tsgo": "tsgo --noEmit", "test": "vitest run --coverage", "test:fast": "vitest run" }, diff --git a/pixso-move/plugin/package.json b/pixso-move/plugin/package.json index faf0d43c747..116a9554df1 100644 --- a/pixso-move/plugin/package.json +++ b/pixso-move/plugin/package.json @@ -11,6 +11,7 @@ "watch:code": "vite build --config vite.code.config.ts --watch", "watch:ui": "vite build --config vite.ui.config.ts --watch", "typecheck": "tsc --noEmit", + "typecheck:tsgo": "tsgo --noEmit", "test": "vitest run", "test:fast": "vitest run" }, diff --git a/pixso-move/plugin/tsconfig.json b/pixso-move/plugin/tsconfig.json index 5838861feb9..0100c773755 100644 --- a/pixso-move/plugin/tsconfig.json +++ b/pixso-move/plugin/tsconfig.json @@ -3,11 +3,25 @@ "compilerOptions": { "lib": ["ESNext", "DOM", "DOM.Iterable"], "jsx": "react-jsx", - "types": ["node"], - "plugins": [], + "types": ["node", "vite/client"], + "plugins": [ + { + "name": "@effect/language-service", + // ru-fork: this is a browser Figma/Pixso plugin UI — Date/Math.random/timers are the + // legitimate platform APIs here, not Effect anti-patterns. Severities deep-merge with the + // root tsconfig.base.json; list only plugin-specific overrides. + "diagnosticSeverity": { + "globalDate": "off", + "globalConsole": "off", + "globalRandom": "off", + "globalTimers": "off", + "globalFetch": "off" + } + } + ], "module": "ESNext", "moduleResolution": "Bundler", "paths": { "~/*": ["./src/ui/*"] } }, - "include": ["src", "tests", "vite.code.config.ts", "vite.ui.config.ts"] + "include": ["src", "tests", "vite.code.config.ts"] } diff --git a/pixso-move/processor/package.json b/pixso-move/processor/package.json index 71444d6a26c..a0f097b395a 100644 --- a/pixso-move/processor/package.json +++ b/pixso-move/processor/package.json @@ -4,10 +4,14 @@ "private": true, "type": "module", "exports": { - ".": { "types": "./src/index.ts", "import": "./src/index.ts" } + ".": { + "types": "./src/index.ts", + "import": "./src/index.ts" + } }, "scripts": { "typecheck": "tsc --noEmit", + "typecheck:tsgo": "tsgo --noEmit", "test": "vitest run --coverage", "test:fast": "vitest run" }, diff --git a/pixso-move/server/package.json b/pixso-move/server/package.json index baf09fddd99..170b44073aa 100644 --- a/pixso-move/server/package.json +++ b/pixso-move/server/package.json @@ -3,12 +3,15 @@ "version": "0.0.0", "private": true, "type": "module", - "bin": { "pixso-move-server": "./dist/bin.mjs" }, + "bin": { + "pixso-move-server": "./dist/bin.mjs" + }, "scripts": { "dev": "node --watch src/bin.ts start", "build": "tsdown", "start": "node dist/bin.mjs start", "typecheck": "tsc --noEmit", + "typecheck:tsgo": "tsgo --noEmit", "test": "vitest run --coverage", "test:fast": "vitest run" }, diff --git a/pixso-move/server/src/services/nodeStoreLive.ts b/pixso-move/server/src/services/nodeStoreLive.ts index 4bad0bcb7a2..01a8125702a 100644 --- a/pixso-move/server/src/services/nodeStoreLive.ts +++ b/pixso-move/server/src/services/nodeStoreLive.ts @@ -1,5 +1,7 @@ import type { DesignerId, NodeRecord, NodeSummary } from "@pixso-move/contracts"; import { NodeId } from "@pixso-move/contracts"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; @@ -29,10 +31,11 @@ export const NodeStoreLive = Layer.effect( NodeStore, Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; + const crypto = yield* Crypto.Crypto; return { insert: (input: NodeInsert) => Effect.gen(function* () { - const nodeId = NodeId.make(crypto.randomUUID()); + const nodeId = NodeId.make(yield* crypto.randomUUIDv4.pipe(Effect.orDie)); const addedAt = yield* nowIso; yield* sql` INSERT INTO nodes (id, designer_id, root_name, nodes_json, preview, added_at) @@ -91,4 +94,4 @@ export const NodeStoreLive = Layer.effect( ), }; }), -); +).pipe(Layer.provide(NodeCrypto.layer)); diff --git a/pixso-move/server/src/services/resultStoreLive.ts b/pixso-move/server/src/services/resultStoreLive.ts index 784159d8496..2281d0997d3 100644 --- a/pixso-move/server/src/services/resultStoreLive.ts +++ b/pixso-move/server/src/services/resultStoreLive.ts @@ -1,5 +1,7 @@ import type { ProcessingResult, ProcessingStatus } from "@pixso-move/contracts"; import { DesignerId, NodeId, ResultTag } from "@pixso-move/contracts"; +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; +import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; @@ -35,16 +37,18 @@ export const ResultStoreLive = Layer.effect( ResultStore, Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; + const crypto = yield* Crypto.Crypto; return { reconcile: (rows: ReadonlyArray) => Effect.gen(function* () { let inserted = 0; for (const row of rows) { const createdAt = yield* nowIso; + const resultId = yield* crypto.randomUUIDv4.pipe(Effect.orDie); const out = yield* sql<{ readonly id: string }>` INSERT INTO processing_results (id, designer_id, node_id, result_tag, status, attempts, created_at) - VALUES (${crypto.randomUUID()}, ${row.designerId}, ${row.nodeId}, ${row.resultTag}, 'pending', 0, ${createdAt}) + VALUES (${resultId}, ${row.designerId}, ${row.nodeId}, ${row.resultTag}, 'pending', 0, ${createdAt}) ON CONFLICT (node_id, result_tag) DO NOTHING RETURNING id `; @@ -126,4 +130,4 @@ export const ResultStoreLive = Layer.effect( ), }; }), -); +).pipe(Layer.provide(NodeCrypto.layer)); diff --git a/pixso-move/server/tests/persistence.test.ts b/pixso-move/server/tests/persistence.test.ts index 1d98eca9677..bd89a739d5f 100644 --- a/pixso-move/server/tests/persistence.test.ts +++ b/pixso-move/server/tests/persistence.test.ts @@ -1,5 +1,7 @@ +import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; +import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as SqlClient from "effect/unstable/sql/SqlClient"; @@ -7,7 +9,18 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import { resolveServerConfig, ServerConfig } from "../src/config.ts"; import { persistenceLive } from "../src/persistence/sqlite.ts"; -const tmpDb = `./.data/test-${crypto.randomUUID()}.sqlite`; +// Build persistence over a fresh file path under a not-yet-existing parent dir, so the test +// exercises directory creation. The unique suffix comes from the Effect Crypto service. +const persistenceWithFreshDb = Layer.unwrap( + Effect.gen(function* () { + const uuid = yield* Crypto.Crypto.pipe(Effect.flatMap((crypto) => crypto.randomUUIDv4)); + const tmpDb = `./.data/test-${uuid}.sqlite`; + return persistenceLive.pipe( + Layer.provide(ServerConfig.layer(resolveServerConfig({ dbPath: tmpDb }))), + Layer.provide(NodeServices.layer), + ); + }).pipe(Effect.provide(NodeCrypto.layer)), +); it.effect("file-backed persistence creates the parent dir, runs migrations, persists", () => Effect.gen(function* () { @@ -18,12 +31,5 @@ it.effect("file-backed persistence creates the parent dir, runs migrations, pers const names = tables.map((t) => t.name); assert.include(names, "nodes"); assert.include(names, "processing_results"); - }).pipe( - Effect.provide( - persistenceLive.pipe( - Layer.provide(ServerConfig.layer(resolveServerConfig({ dbPath: tmpDb }))), - Layer.provide(NodeServices.layer), - ), - ), - ), + }).pipe(Effect.provide(persistenceWithFreshDb)), ); diff --git a/pixso-move/server/tests/server.test.ts b/pixso-move/server/tests/server.test.ts index 5c9e5fdef73..2cd66da1a89 100644 --- a/pixso-move/server/tests/server.test.ts +++ b/pixso-move/server/tests/server.test.ts @@ -1,6 +1,11 @@ +import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import { resolveServerConfig, ServerConfig } from "../src/config.ts"; import { makeServerLayer } from "../src/server.ts"; @@ -11,33 +16,31 @@ const key = "dz_alice"; const body = { designerId: key, rootName: "Card", nodesJson: '{"id":"1"}', preview: "iVBOR" }; // Integration: builds the real server layer (HTTP listener + logger + stores + -// persistence), binds a port, and round-trips a request over a real socket. +// persistence), binds a port, and round-trips a request over a real socket via HttpClient. it.effect("real server ingests and lists over HTTP", () => Effect.scoped( Effect.gen(function* () { yield* Layer.build(makeServerLayer); const base = `http://127.0.0.1:${PORT}`; + const client = yield* HttpClient.HttpClient; - const ingest = yield* Effect.promise(() => - fetch(`${base}/ingest`, { - method: "POST", - headers: { "content-type": "application/json", "x-designer-id": key }, - body: JSON.stringify(body), - }), - ); + const ingestRequest = yield* HttpClientRequest.post(`${base}/ingest`, { + headers: { "x-designer-id": key }, + }).pipe(HttpClientRequest.bodyJson(body)); + const ingest = yield* client.execute(ingestRequest); assert.equal(ingest.status, 200); - const list = yield* Effect.promise(() => - fetch(`${base}/nodes`, { headers: { "x-designer-id": key } }), - ); + const list = yield* client.get(`${base}/nodes`, { headers: { "x-designer-id": key } }); assert.equal(list.status, 200); - assert.equal(((yield* Effect.promise(() => list.json())) as unknown[]).length, 1); + const nodes = yield* HttpClientResponse.schemaBodyJson(Schema.Array(Schema.Unknown))(list); + assert.equal(nodes.length, 1); }), ).pipe( Effect.provide( Layer.mergeAll( ServerConfig.layer(resolveServerConfig({ port: PORT, dbPath: ":memory:" })), FakeAcpRunnerLive, + NodeHttpClient.layerUndici, ), ), ), diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 55711032e35..bb7c6d4f4c7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -16,7 +16,9 @@ catalog: "@effect/platform-node": 4.0.0-beta.78 "@effect/platform-node-shared": 4.0.0-beta.78 "@effect/language-service": 0.84.2 + "@effect/tsgo": 0.13.2 "@effect/vitest": 4.0.0-beta.78 + "@typescript/native-preview": 7.0.0-dev.20260604.1 "@types/node": 24.10.13 tsdown: 0.20.3 typescript: 5.9.3 diff --git a/scripts/package.json b/scripts/package.json index 02102e1fa34..e97a498ddaa 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -3,7 +3,8 @@ "private": true, "type": "module", "scripts": { - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "typecheck:tsgo": "tsgo --noEmit" }, "dependencies": { "@effect/platform-node": "catalog:", diff --git a/tsconfig.base.json b/tsconfig.base.json index 4f7edbaf759..a84f620ba86 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -35,6 +35,7 @@ "globalErrorInEffectFailure": "error", "unknownInEffectCatch": "error", "strictBooleanExpressions": "off", + "lazyEffect": "off", "preferSchemaOverJson": "error", "schemaSyncInEffect": "error", "cryptoRandomUUID": "error", @@ -47,7 +48,9 @@ "globalRandom": "error", "globalRandomInEffect": "error", "globalTimers": "error", - "globalTimersInEffect": "error" + "globalTimersInEffect": "error", + "globalFetch": "error", + "globalFetchInEffect": "error" } } ] diff --git a/tsgo-fixes.md b/tsgo-fixes.md new file mode 100644 index 00000000000..590147c7950 --- /dev/null +++ b/tsgo-fixes.md @@ -0,0 +1,140 @@ +# tsgo strict-rule fixes — decision plan + +State: clean install + `diagnosticSeverity` matched to t3 **100%** (base + `apps/web`). +Inventory (mcp-probe excluded): **47 errors + 73 suggestions**. + +t3 sources used for proven before/after: +- `e6330ead8` — crypto→service (compat APIs) +- `6b3050ee7` — crypto→service (tsgo migration) +- `25b02f4ba` — Date.now→Clock, setTimeout→timers, console→log, fetch→HttpClient, catch+succeed→orElseSucceed + +Decision buckets (every one of the 47 + 73 is in exactly one): + +| Bucket | Count | Action | +|---|---|---| +| §1 DO NOW — t3-proven | 21 | apply t3's before/after | +| §2 DO NOW — fork crypto, mechanical | 7 | apply Part-A pattern (no t3 file, but identical fix) | +| §3 CONFIG-SUPPRESS (like web) | 11 | tsconfig `global*`/`nodeBuiltinImport` off — they stop showing | +| §4 LEAVE FOR LATER | 8 | daemonLauncher (6) + 2 scanner leaks | +| §5 LEAVE FOR LATER — core/types | 2 | `generate.ts`, `main.tsx` | +| §6 Suggestions | 73 | optional; t3 keeps at "suggestion", not error | + +21 + 7 + 11 + 8 + 2 = **47 errors.** Doing §1+§2 = **28 fixed**, §3 removes **11** from view, +§4+§5 (**10**) deferred. + +--- + +## §1 — DO NOW: errors with a proven t3 before/after (21) + +### Crypto → Crypto service, SHARED files (t3 fixed the exact file) +Pattern (verbatim t3, `6b3050ee7`): acquire once, bind a local, yield at each site: +```ts +// AFTER +const crypto = yield* Crypto.Crypto; +const randomUUID = crypto.randomUUIDv4; // ws.ts: .pipe(Effect.orDie) +const serverCommandId = (tag: string) => + randomUUID.pipe(Effect.map((uuid) => CommandId.make(`server:${tag}:${uuid}`))); +// site: EventId.make(crypto.randomUUID()) → EventId.make(yield* randomUUID) +``` + +| Our file:line(s) | t3 commit | t3 after-shape | +|---|---|---| +| `apps/server/src/cli/project.ts:346,349,387,427` | `e6330ead8` | `projectCommandUuid` module helper (domain error) | +| `apps/server/src/orchestration/decider.ts:51` | `e6330ead8` | `withEventBase` pure-fn → `Effect` (`Crypto.Crypto.pipe(flatMap(c=>c.randomUUIDv4))`) | +| `apps/server/src/orchestration/Layers/ProviderCommandReactor.ts:90,277` | `e6330ead8` | in-`make` `serverCommandId`/`serverEventId` | +| `apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts:45,1173` | `e6330ead8` | in-`make` `providerCommandId` | +| `apps/server/src/orchestration/Layers/CheckpointReactor.ts:73,95,120,317` | `6b3050ee7` | `const randomUUID = crypto.randomUUIDv4` | +| `apps/server/src/serverRuntimeStartup.ts:163,168,185,188` | `6b3050ee7` | same | +| `apps/server/src/ws.ts:244,265` | `6b3050ee7` | `const randomUUID = crypto.randomUUIDv4.pipe(Effect.orDie)` | +| `apps/server/src/provider/acp/AcpSessionRuntime.ts:193` | later t3 | same service pattern | + +⚠️ Cascade: each `crypto.randomUUIDv4` adds `Crypto`+`PlatformError` to its `Effect`; +discharge `Crypto` at the layer root (already provided by `NodeServices.layer`); use +`Effect.orDie` to keep `never` where a fixed signature requires it. + +### deterministicKeys — SHARED (renamer missed it) +| File:line | before | after | source | +|---|---|---|---| +| `packages/effect-acp/src/agent.ts:210` | `…()("effect-acp/AcpAgent")` | `…()("effect-acp/agent/AcpAgent")` | t3 `6b3050ee7` key scheme | + +--- + +## §2 — DO NOW: fork crypto, mechanical (7) — pattern known, NO t3 file +Identical to §1 (acquire `Crypto.Crypto`, `yield* crypto.randomUUIDv4`); validated by the +shared-file fixes, just no t3 verbatim for these exact files. + +| File:line | rule | enclosing | risk | +|---|---|---|---| +| `apps/server/src/auth/Layers/BootstrapCredentialService.ts:144` | cryptoRandomUUIDInEffect | `make` gen | low | +| `apps/server/src/auth/Layers/SessionCredentialService.ts:206` | cryptoRandomUUIDInEffect | `make` gen | low | +| `apps/server/src/ru-fork/mcp/McpReactor.ts:63` | cryptoRandomUUID | `make` gen | low | +| `apps/server/src/ru-fork/turnCompletedCheckpointDispatch.ts:82` | cryptoRandomUUIDInEffect | **check fn sig** | med (may cascade) | +| `pixso-move/server/src/services/nodeStoreLive.ts:35` | cryptoRandomUUIDInEffect | `make` gen | low | +| `pixso-move/server/src/services/resultStoreLive.ts:47` | cryptoRandomUUIDInEffect | `make` gen | low | +| `apps/server/tests/persistence.test.ts:10` | cryptoRandomUUID | test | low (service+`NodeServices.layer`, or `Effect.sync(()=>globalThis.crypto.randomUUID())`) | + +--- + +## §3 — CONFIG-SUPPRESS like web (11) — turn the rule off, no code change +These are browser-ish UI / build-config files where globals/node-imports are legitimate +(same rationale t3 uses for `apps/web`). Add per-package `diagnosticSeverity` overrides. + +**pixso-move/plugin** (`src/ui/*` is a browser plugin UI) — add `global*: "off"`: +| File:line | rule | +|---|---| +| `src/ui/components/settings/settingsLayout.tsx:11,13` | globalDate ×2, globalTimers ×1 | +| `src/ui/key.ts:12` | globalRandom | + +**Build/config files** — `path` import is normal in vite configs; suppress `nodeBuiltinImport` +(per-file `// @effect-diagnostics nodeBuiltinImport:off` or tsconfig override): +| File:line | rule | +|---|---| +| `apps/web/vite-branding-plugin.ts:1` | nodeBuiltinImport | +| `pixso-move/plugin/vite.ui.config.ts:1` | nodeBuiltinImport | + +**pixso-move/server tests** — test code using global `fetch`; suppress `globalFetch` for tests: +| File:line | rule | +|---|---| +| `pixso-move/server/tests/server.test.ts:22,31` | globalFetchInEffect | +| `pixso-move/server/tests/server.test.ts:25` | preferSchemaOverJson (quick: `Schema.fromJsonString`, or suppress) | + +> Net: these 11 vanish from the report via config, exactly like the 134 web globals did. + +--- + +## §4 — LEAVE FOR LATER: fork code, real work (8) + +**daemonLauncher (fork, no t3 file) — 6:** the daemon health-poll loop. +| File:line | rule | why deferred | +|---|---|---| +| `apps/server/src/daemonLauncher.ts:66,217,364` | globalTimersInEffect | `setTimeout`→`Effect.sleep`/`NodeTimers` — touches poll timing | +| `apps/server/src/daemonLauncher.ts:68,219,366` | globalFetchInEffect | `fetch`→`HttpClient` — **real rewire** of the health probe | + +**Service requirement leaks (fork, no t3 file) — 2:** design choice. +| File:line | error | why deferred | +|---|---|---| +| `apps/server/src/ru-fork/skills/SkillScannerService.ts:40` | TS377041 leakingRequirements | methods leak `FileSystem\|Path`; provide-in-`make` redesign | +| `apps/server/src/ru-fork/subagents/SubagentScannerService.ts:41` | TS377041 | same | + +--- + +## §5 — LEAVE FOR LATER: core/type errors (2) +| File:line | error | note | +|---|---|---| +| `scripts/generate.ts:52` | TS2339 `ImportMeta.dirname` | tsgo-stricter; `import.meta.dirname` types or `fileURLToPath(import.meta.url)` | +| `pixso-move/plugin/src/ui/main.tsx:6` | TS2882 `./index.css` side-effect import | css ambient decl in pixso tsconfig | + +--- + +## §6 — Suggestions (73, do NOT fail build) — optional +t3 keeps these at "suggestion", not "error": +- `catchToOrElseSucceed` ×63 → `Effect.catch(()=>Effect.succeed(x))` → `Effect.orElseSucceed(()=>x)` (t3 `25b02f4ba`) +- `unnecessaryTypeofType` ×8, `multipleCatchTag` ×2 — cosmetic. + +--- + +## Order of operations +1. §1 (21) + §2 (7) → **28 errors fixed**, all crypto on the proven pattern. Re-run `pnpm typecheck:tsgo`. +2. §3 (11) → config overrides → **11 disappear**. +3. After 1+2+3, remaining errors = **§4 (8) + §5 (2) = 10**, all explicitly deferred. +4. §6 suggestions: optional cleanup, any time. diff --git a/turbo.json b/turbo.json index 44066f54dfe..12887ee8fd3 100644 --- a/turbo.json +++ b/turbo.json @@ -30,6 +30,11 @@ "outputs": [], "cache": false }, + "typecheck:tsgo": { + "dependsOn": ["^typecheck:tsgo"], + "outputs": [], + "cache": false + }, "test": { "dependsOn": ["^build"], "cache": false,