diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index a761de588c3..680390387fb 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -48,7 +48,9 @@ export const devUrlFlag = Flag.string("dev-url").pipe( Flag.optional, ); export const noBrowserFlag = Flag.boolean("no-browser").pipe( - Flag.withDescription("Disable automatic browser opening."), + Flag.withDescription( + "Disable automatic browser opening (defaults to true; pass --no-browser false to enable).", + ), Flag.optional, ); export const bootstrapFdFlag = Flag.integer("bootstrap-fd").pipe( @@ -310,7 +312,7 @@ export const resolveServerConfig = ( Option.fromUndefinedOr(env.noBrowser), Option.fromUndefinedOr(bootstrap?.noBrowser), ), - () => mode === "desktop", + () => true, ); const desktopBootstrapToken = bootstrap?.desktopBootstrapToken; const autoBootstrapProjectFromCwd = Option.getOrElse( diff --git a/apps/server/src/cli/server.ts b/apps/server/src/cli/server.ts index dba00a5236b..317a6a66749 100644 --- a/apps/server/src/cli/server.ts +++ b/apps/server/src/cli/server.ts @@ -25,7 +25,7 @@ export const startCommand = Command.make("start", { ...sharedServerCommandFlags export const serveCommand = Command.make("serve", { ...sharedServerCommandFlags }).pipe( Command.withDescription( - "Run the more Code server without opening a browser and print headless pairing details.", + "Run the more Code server in headless mode and print local and LAN pairing URLs.", ), Command.withHandler((flags) => runServerCommand(flags, { diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 3c7dc704a2f..cb24afb6c60 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -41,7 +41,7 @@ import { type ChangeRequestTerminology, } from "@t3tools/shared/sourceControl"; -import { GitManagerError } from "@t3tools/contracts"; +import { GitManagerError, GitPullRequestMaterializationError } from "@t3tools/contracts"; import { TextGeneration } from "../textGeneration/TextGeneration.ts"; import { ProjectSetupScriptRunner } from "../project/Services/ProjectSetupScriptRunner.ts"; import { extractBranchNameFromRemoteRef } from "./remoteRefs.ts"; @@ -629,9 +629,12 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { ) => configurePullRequestHeadUpstreamBase(cwd, pullRequest, localBranch).pipe( Effect.catch((error) => - Effect.logWarning( - `GitManager.configurePullRequestHeadUpstream: failed to configure upstream for ${localBranch} -> ${pullRequest.headBranch} in ${cwd}: ${error.message}`, - ).pipe(Effect.asVoid), + Effect.logWarning("GitManager.configurePullRequestHeadUpstream failed", { + cwd, + localBranch, + headBranch: pullRequest.headBranch, + cause: error, + }).pipe(Effect.asVoid), ), ); @@ -689,12 +692,30 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { localBranch = pullRequest.headBranch, ) => materializePullRequestHeadBranchBase(cwd, pullRequest, localBranch).pipe( - Effect.catch(() => - gitCore.fetchPullRequestBranch({ - cwd, - prNumber: pullRequest.number, - branch: localBranch, - }), + Effect.catch((primaryCause) => + gitCore + .fetchPullRequestBranch({ + cwd, + prNumber: pullRequest.number, + branch: localBranch, + }) + .pipe( + Effect.mapError( + (fallbackCause) => + new GitPullRequestMaterializationError({ + cwd, + pullRequestNumber: pullRequest.number, + headRepository: resolveHeadRepositoryNameWithOwner(pullRequest), + headBranch: pullRequest.headBranch, + localBranch, + cause: new AggregateError( + [primaryCause, fallbackCause], + `Repository-head and pull-request-ref fetches both failed for pull request #${pullRequest.number}.`, + { cause: primaryCause }, + ), + }), + ), + ), ), ); const fileSystem = yield* FileSystem.FileSystem; @@ -1411,9 +1432,11 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { }) .pipe( Effect.catch((error) => - Effect.logWarning( - `GitManager.preparePullRequestThread: failed to launch worktree setup script for thread ${input.threadId} in ${worktreePath}: ${error.message}`, - ).pipe(Effect.asVoid), + Effect.logWarning("GitManager.preparePullRequestThread setup script failed", { + threadId: input.threadId, + worktreePath, + cause: error, + }).pipe(Effect.asVoid), ), ); }; diff --git a/apps/server/src/persistence/Errors.ts b/apps/server/src/persistence/Errors.ts index 2a3d7aff189..1667f168508 100644 --- a/apps/server/src/persistence/Errors.ts +++ b/apps/server/src/persistence/Errors.ts @@ -1,20 +1,55 @@ import * as Schema from "effect/Schema"; import * as SchemaIssue from "effect/SchemaIssue"; +function formatSchemaIssuePath(path: ReadonlyArray): string { + return path + .map((segment) => (typeof segment === "number" ? `[${segment}]` : String(segment))) + .join("."); +} + +function summarizeSchemaIssue(issue: SchemaIssue.Issue): string { + switch (issue._tag) { + case "Filter": + case "Encoding": + return `${issue._tag}(${summarizeSchemaIssue(issue.issue)})`; + case "Pointer": { + const inner = summarizeSchemaIssue(issue.issue); + const path = formatSchemaIssuePath(issue.path); + return path.length > 0 ? `${path}:${inner}` : inner; + } + case "Composite": + case "AnyOf": + return `${issue._tag}(${issue.issues.map(summarizeSchemaIssue).join(",")})`; + default: + return issue._tag; + } +} + // =============================== // Core Persistence Errors // =============================== +export const PersistenceErrorCorrelation = Schema.Union([ + Schema.Struct({ sessionId: Schema.String }), + Schema.Struct({ currentSessionId: Schema.String }), + Schema.Struct({ pairingLinkId: Schema.String }), + Schema.Struct({ threadId: Schema.String }), +]); +export type PersistenceErrorCorrelation = typeof PersistenceErrorCorrelation.Type; + export class PersistenceSqlError extends Schema.TaggedErrorClass()( "PersistenceSqlError", { operation: Schema.String, - detail: Schema.String, + detail: Schema.optional(Schema.String), + correlation: Schema.optional(PersistenceErrorCorrelation), cause: Schema.optional(Schema.Defect()), }, ) { override get message(): string { - return `SQL error in ${this.operation}: ${this.detail}`; + return this.detail === undefined + ? `SQL error in ${this.operation}` + : `SQL error in ${this.operation}: ${this.detail}`; } } @@ -23,9 +58,23 @@ export class PersistenceDecodeError extends Schema.TaggedErrorClass - new PersistenceDecodeError({ - operation, - issue: SchemaIssue.makeFormatterDefault()(error.issue), - cause: error, - }); + return (cause: Schema.SchemaError): PersistenceDecodeError => + PersistenceDecodeError.fromSchemaError(operation, cause); } export function toPersistenceDecodeCauseError(operation: string) { diff --git a/apps/server/src/persistence/Layers/AuthPairingLinks.ts b/apps/server/src/persistence/Layers/AuthPairingLinks.ts index 9d2760d1449..3419ad09a78 100644 --- a/apps/server/src/persistence/Layers/AuthPairingLinks.ts +++ b/apps/server/src/persistence/Layers/AuthPairingLinks.ts @@ -2,11 +2,13 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { - toPersistenceDecodeError, - toPersistenceSqlError, + PersistenceDecodeError, + type PersistenceErrorCorrelation, + PersistenceSqlError, type AuthPairingLinkRepositoryError, } from "../Errors.ts"; import { @@ -20,11 +22,35 @@ import { RevokeAuthPairingLinkInput, } from "../Services/AuthPairingLinks.ts"; -function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { +const AuthPairingLinkRawDbRow = Schema.Struct({ + id: Schema.String, + credential: Schema.Unknown, + method: Schema.Unknown, + scopes: Schema.Unknown, + subject: Schema.Unknown, + label: Schema.Unknown, + proofKeyThumbprint: Schema.Unknown, + createdAt: Schema.Unknown, + expiresAt: Schema.Unknown, + consumedAt: Schema.Unknown, + revokedAt: Schema.Unknown, +}); + +const decodeAuthPairingLinkDbRow = Schema.decodeUnknownEffect(AuthPairingLinkRecord); + +function toPersistenceSqlOrDecodeError( + sqlOperation: string, + decodeOperation: string, + correlation?: PersistenceErrorCorrelation, +) { return (cause: unknown): AuthPairingLinkRepositoryError => Schema.isSchemaError(cause) - ? toPersistenceDecodeError(decodeOperation)(cause) - : toPersistenceSqlError(sqlOperation)(cause); + ? PersistenceDecodeError.fromSchemaError(decodeOperation, cause, correlation) + : new PersistenceSqlError({ + operation: sqlOperation, + ...(correlation === undefined ? {} : { correlation }), + cause, + }); } const makeAuthPairingLinkRepository = Effect.gen(function* () { @@ -65,7 +91,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { const consumeAvailablePairingLinkRow = SqlSchema.findOneOption({ Request: ConsumeAuthPairingLinkInput, - Result: AuthPairingLinkRecord, + Result: AuthPairingLinkRawDbRow, execute: ({ credential, proofKeyThumbprint, consumedAt, now }) => sql` UPDATE auth_pairing_links @@ -95,7 +121,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { const listActivePairingLinkRows = SqlSchema.findAll({ Request: ListActiveAuthPairingLinksInput, - Result: AuthPairingLinkRecord, + Result: AuthPairingLinkRawDbRow, execute: ({ now }) => sql` SELECT @@ -134,7 +160,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { const getPairingLinkRowByCredential = SqlSchema.findOneOption({ Request: GetAuthPairingLinkByCredentialInput, - Result: AuthPairingLinkRecord, + Result: AuthPairingLinkRawDbRow, execute: ({ credential }) => sql` SELECT @@ -160,6 +186,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { toPersistenceSqlOrDecodeError( "AuthPairingLinkRepository.create:query", "AuthPairingLinkRepository.create:encodeRequest", + { pairingLinkId: input.id }, ), ), ); @@ -172,6 +199,22 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { "AuthPairingLinkRepository.consumeAvailable:decodeRow", ), ), + Effect.flatMap((rowOption) => + Option.match(rowOption, { + onNone: () => Effect.succeed(Option.none()), + onSome: (row) => + decodeAuthPairingLinkDbRow(row).pipe( + Effect.mapError((cause) => + PersistenceDecodeError.fromSchemaError( + "AuthPairingLinkRepository.consumeAvailable:decodeRow", + cause, + { pairingLinkId: row.id }, + ), + ), + Effect.map(Option.some), + ), + }), + ), ); const listActive: AuthPairingLinkRepositoryShape["listActive"] = (input) => @@ -182,6 +225,19 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { "AuthPairingLinkRepository.listActive:decodeRows", ), ), + Effect.flatMap((rows) => + Effect.forEach(rows, (row) => + decodeAuthPairingLinkDbRow(row).pipe( + Effect.mapError((cause) => + PersistenceDecodeError.fromSchemaError( + "AuthPairingLinkRepository.listActive:decodeRows", + cause, + { pairingLinkId: row.id }, + ), + ), + ), + ), + ), ); const revoke: AuthPairingLinkRepositoryShape["revoke"] = (input) => @@ -190,6 +246,7 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { toPersistenceSqlOrDecodeError( "AuthPairingLinkRepository.revoke:query", "AuthPairingLinkRepository.revoke:decodeRows", + { pairingLinkId: input.id }, ), ), Effect.map((rows) => rows.length > 0), @@ -203,6 +260,22 @@ const makeAuthPairingLinkRepository = Effect.gen(function* () { "AuthPairingLinkRepository.getByCredential:decodeRow", ), ), + Effect.flatMap((rowOption) => + Option.match(rowOption, { + onNone: () => Effect.succeed(Option.none()), + onSome: (row) => + decodeAuthPairingLinkDbRow(row).pipe( + Effect.mapError((cause) => + PersistenceDecodeError.fromSchemaError( + "AuthPairingLinkRepository.getByCredential:decodeRow", + cause, + { pairingLinkId: row.id }, + ), + ), + Effect.map(Option.some), + ), + }), + ), ); return { diff --git a/apps/server/src/persistence/Layers/AuthSessions.ts b/apps/server/src/persistence/Layers/AuthSessions.ts index ab84e3fa041..78b589ef1fe 100644 --- a/apps/server/src/persistence/Layers/AuthSessions.ts +++ b/apps/server/src/persistence/Layers/AuthSessions.ts @@ -7,8 +7,9 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { - toPersistenceDecodeError, - toPersistenceSqlError, + PersistenceDecodeError, + type PersistenceErrorCorrelation, + PersistenceSqlError, type AuthSessionRepositoryError, } from "../Errors.ts"; import { @@ -40,6 +41,25 @@ const AuthSessionDbRow = Schema.Struct({ revokedAt: Schema.NullOr(Schema.DateTimeUtcFromString), }); +const AuthSessionRawDbRow = Schema.Struct({ + sessionId: Schema.String, + subject: Schema.Unknown, + scopes: Schema.Unknown, + method: Schema.Unknown, + clientLabel: Schema.Unknown, + clientIpAddress: Schema.Unknown, + clientUserAgent: Schema.Unknown, + clientDeviceType: Schema.Unknown, + clientOs: Schema.Unknown, + clientBrowser: Schema.Unknown, + issuedAt: Schema.Unknown, + expiresAt: Schema.Unknown, + lastConnectedAt: Schema.Unknown, + revokedAt: Schema.Unknown, +}); + +const decodeAuthSessionDbRow = Schema.decodeUnknownEffect(AuthSessionDbRow); + function toAuthSessionRecord(row: typeof AuthSessionDbRow.Type): AuthSessionRecord { return { sessionId: row.sessionId, @@ -61,11 +81,19 @@ function toAuthSessionRecord(row: typeof AuthSessionDbRow.Type): AuthSessionReco }; } -function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { +function toPersistenceSqlOrDecodeError( + sqlOperation: string, + decodeOperation: string, + correlation?: PersistenceErrorCorrelation, +) { return (cause: unknown): AuthSessionRepositoryError => Schema.isSchemaError(cause) - ? toPersistenceDecodeError(decodeOperation)(cause) - : toPersistenceSqlError(sqlOperation)(cause); + ? PersistenceDecodeError.fromSchemaError(decodeOperation, cause, correlation) + : new PersistenceSqlError({ + operation: sqlOperation, + ...(correlation === undefined ? {} : { correlation }), + cause, + }); } const makeAuthSessionRepository = Effect.gen(function* () { @@ -110,7 +138,7 @@ const makeAuthSessionRepository = Effect.gen(function* () { const getSessionRowById = SqlSchema.findOneOption({ Request: GetAuthSessionByIdInput, - Result: AuthSessionDbRow, + Result: AuthSessionRawDbRow, execute: ({ sessionId }) => sql` SELECT @@ -135,7 +163,7 @@ const makeAuthSessionRepository = Effect.gen(function* () { const listActiveSessionRows = SqlSchema.findAll({ Request: ListActiveAuthSessionsInput, - Result: AuthSessionDbRow, + Result: AuthSessionRawDbRow, execute: ({ now }) => sql` SELECT @@ -203,6 +231,7 @@ const makeAuthSessionRepository = Effect.gen(function* () { toPersistenceSqlOrDecodeError( "AuthSessionRepository.create:query", "AuthSessionRepository.create:encodeRequest", + { sessionId: input.sessionId }, ), ), ); @@ -213,12 +242,23 @@ const makeAuthSessionRepository = Effect.gen(function* () { toPersistenceSqlOrDecodeError( "AuthSessionRepository.getById:query", "AuthSessionRepository.getById:decodeRow", + { sessionId: input.sessionId }, ), ), Effect.flatMap((rowOption) => Option.match(rowOption, { onNone: () => Effect.succeed(Option.none()), - onSome: (row) => Effect.succeed(Option.some(toAuthSessionRecord(row))), + onSome: (row) => + decodeAuthSessionDbRow(row).pipe( + Effect.mapError((cause) => + PersistenceDecodeError.fromSchemaError( + "AuthSessionRepository.getById:decodeRow", + cause, + { sessionId: input.sessionId }, + ), + ), + Effect.map((decodedRow) => Option.some(toAuthSessionRecord(decodedRow))), + ), }), ), ); @@ -231,7 +271,20 @@ const makeAuthSessionRepository = Effect.gen(function* () { "AuthSessionRepository.listActive:decodeRows", ), ), - Effect.flatMap((rows) => Effect.succeed(rows.map((row) => toAuthSessionRecord(row)))), + Effect.flatMap((rows) => + Effect.forEach(rows, (row) => + decodeAuthSessionDbRow(row).pipe( + Effect.mapError((cause) => + PersistenceDecodeError.fromSchemaError( + "AuthSessionRepository.listActive:decodeRows", + cause, + { sessionId: row.sessionId }, + ), + ), + Effect.map(toAuthSessionRecord), + ), + ), + ), ); const revoke: AuthSessionRepositoryShape["revoke"] = (input) => @@ -240,6 +293,7 @@ const makeAuthSessionRepository = Effect.gen(function* () { toPersistenceSqlOrDecodeError( "AuthSessionRepository.revoke:query", "AuthSessionRepository.revoke:decodeRows", + { sessionId: input.sessionId }, ), ), Effect.map((rows) => rows.length > 0), @@ -251,6 +305,7 @@ const makeAuthSessionRepository = Effect.gen(function* () { toPersistenceSqlOrDecodeError( "AuthSessionRepository.revokeAllExcept:query", "AuthSessionRepository.revokeAllExcept:decodeRows", + { currentSessionId: input.currentSessionId }, ), ), Effect.map((rows) => rows.map((row) => row.sessionId)), @@ -262,6 +317,7 @@ const makeAuthSessionRepository = Effect.gen(function* () { toPersistenceSqlOrDecodeError( "AuthSessionRepository.setLastConnectedAt:query", "AuthSessionRepository.setLastConnectedAt:encodeRequest", + { sessionId: input.sessionId }, ), ), ); diff --git a/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts b/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts index 9ee5c82bb53..c46496ab7e2 100644 --- a/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts +++ b/apps/server/src/persistence/Layers/ProviderSessionRuntime.ts @@ -8,8 +8,9 @@ import * as Schema from "effect/Schema"; import * as Struct from "effect/Struct"; import { - toPersistenceDecodeError, - toPersistenceSqlError, + PersistenceDecodeError, + type PersistenceErrorCorrelation, + PersistenceSqlError, type ProviderSessionRuntimeRepositoryError, } from "../Errors.ts"; import { @@ -25,7 +26,19 @@ const ProviderSessionRuntimeDbRowSchema = ProviderSessionRuntime.mapFields( }), ); -const decodeRuntime = Schema.decodeUnknownEffect(ProviderSessionRuntime); +const ProviderSessionRuntimeRawDbRowSchema = Schema.Struct({ + threadId: Schema.String, + providerName: Schema.Unknown, + providerInstanceId: Schema.Unknown, + adapterKey: Schema.Unknown, + runtimeMode: Schema.Unknown, + status: Schema.Unknown, + lastSeenAt: Schema.Unknown, + resumeCursor: Schema.Unknown, + runtimePayload: Schema.Unknown, +}); + +const decodeRuntimeRow = Schema.decodeUnknownEffect(ProviderSessionRuntimeDbRowSchema); const GetRuntimeRequestSchema = Schema.Struct({ threadId: ThreadId, @@ -33,11 +46,19 @@ const GetRuntimeRequestSchema = Schema.Struct({ const DeleteRuntimeRequestSchema = GetRuntimeRequestSchema; -function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { +function toPersistenceSqlOrDecodeError( + sqlOperation: string, + decodeOperation: string, + correlation?: PersistenceErrorCorrelation, +) { return (cause: unknown): ProviderSessionRuntimeRepositoryError => Schema.isSchemaError(cause) - ? toPersistenceDecodeError(decodeOperation)(cause) - : toPersistenceSqlError(sqlOperation)(cause); + ? PersistenceDecodeError.fromSchemaError(decodeOperation, cause, correlation) + : new PersistenceSqlError({ + operation: sqlOperation, + ...(correlation === undefined ? {} : { correlation }), + cause, + }); } const makeProviderSessionRuntimeRepository = Effect.gen(function* () { @@ -84,7 +105,7 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { const getRuntimeRowByThreadId = SqlSchema.findOneOption({ Request: GetRuntimeRequestSchema, - Result: ProviderSessionRuntimeDbRowSchema, + Result: ProviderSessionRuntimeRawDbRowSchema, execute: ({ threadId }) => sql` SELECT @@ -104,7 +125,7 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { const listRuntimeRows = SqlSchema.findAll({ Request: Schema.Void, - Result: ProviderSessionRuntimeDbRowSchema, + Result: ProviderSessionRuntimeRawDbRowSchema, execute: () => sql` SELECT @@ -137,6 +158,7 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { toPersistenceSqlOrDecodeError( "ProviderSessionRuntimeRepository.upsert:query", "ProviderSessionRuntimeRepository.upsert:encodeRequest", + { threadId: runtime.threadId }, ), ), ); @@ -147,16 +169,19 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { toPersistenceSqlOrDecodeError( "ProviderSessionRuntimeRepository.getByThreadId:query", "ProviderSessionRuntimeRepository.getByThreadId:decodeRow", + { threadId: input.threadId }, ), ), Effect.flatMap((runtimeRowOption) => Option.match(runtimeRowOption, { onNone: () => Effect.succeed(Option.none()), onSome: (row) => - decodeRuntime(row).pipe( - Effect.mapError( - toPersistenceDecodeError( - "ProviderSessionRuntimeRepository.getByThreadId:rowToRuntime", + decodeRuntimeRow(row).pipe( + Effect.mapError((cause) => + PersistenceDecodeError.fromSchemaError( + "ProviderSessionRuntimeRepository.getByThreadId:decodeRow", + cause, + { threadId: input.threadId }, ), ), Effect.map((runtime) => Option.some(runtime)), @@ -177,9 +202,13 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { Effect.forEach( rows, (row) => - decodeRuntime(row).pipe( - Effect.mapError( - toPersistenceDecodeError("ProviderSessionRuntimeRepository.list:rowToRuntime"), + decodeRuntimeRow(row).pipe( + Effect.mapError((cause) => + PersistenceDecodeError.fromSchemaError( + "ProviderSessionRuntimeRepository.list:decodeRows", + cause, + { threadId: row.threadId }, + ), ), ), { concurrency: "unbounded" }, @@ -190,7 +219,12 @@ const makeProviderSessionRuntimeRepository = Effect.gen(function* () { const deleteByThreadId: ProviderSessionRuntimeRepositoryShape["deleteByThreadId"] = (input) => deleteRuntimeByThreadId(input).pipe( Effect.mapError( - toPersistenceSqlError("ProviderSessionRuntimeRepository.deleteByThreadId:query"), + (cause) => + new PersistenceSqlError({ + operation: "ProviderSessionRuntimeRepository.deleteByThreadId:query", + correlation: { threadId: input.threadId }, + cause, + }), ), ); diff --git a/apps/server/src/persistence/RepositoryErrorCorrelation.test.ts b/apps/server/src/persistence/RepositoryErrorCorrelation.test.ts new file mode 100644 index 00000000000..d465be8a371 --- /dev/null +++ b/apps/server/src/persistence/RepositoryErrorCorrelation.test.ts @@ -0,0 +1,258 @@ +import { AuthSessionId, ThreadId, type AuthEnvironmentScope } from "@t3tools/contracts"; +import { assert, describe, it } from "@effect/vitest"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import * as PersistenceErrors from "./Errors.ts"; +import { AuthPairingLinkRepositoryLive } from "./Layers/AuthPairingLinks.ts"; +import { AuthSessionRepositoryLive } from "./Layers/AuthSessions.ts"; +import { ProviderSessionRuntimeRepositoryLive } from "./Layers/ProviderSessionRuntime.ts"; +import { SqlitePersistenceMemory } from "./Layers/Sqlite.ts"; +import { AuthPairingLinkRepository } from "./Services/AuthPairingLinks.ts"; +import { AuthSessionRepository } from "./Services/AuthSessions.ts"; +import { ProviderSessionRuntimeRepository } from "./Services/ProviderSessionRuntime.ts"; + +const issuedAt = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z"); +const expiresAt = DateTime.makeUnsafe("2027-06-20T00:00:00.000Z"); +const now = DateTime.makeUnsafe("2026-06-21T00:00:00.000Z"); +const scopes: ReadonlyArray = ["access:read"]; + +const authSessionLayer = AuthSessionRepositoryLive.pipe( + Layer.provideMerge(SqlitePersistenceMemory), +); +const authPairingLinkLayer = AuthPairingLinkRepositoryLive.pipe( + Layer.provideMerge(SqlitePersistenceMemory), +); +const providerSessionRuntimeLayer = ProviderSessionRuntimeRepositoryLive.pipe( + Layer.provideMerge(SqlitePersistenceMemory), +); + +describe("persistence error correlation", () => { + it.effect("correlates auth session SQL and row-decode failures without sensitive fields", () => + Effect.gen(function* () { + const sessions = yield* AuthSessionRepository; + const sql = yield* SqlClient.SqlClient; + const sessionId = AuthSessionId.make("session-correlation"); + const currentSessionId = AuthSessionId.make("current-session-correlation"); + const subject = "session-subject-secret-sentinel"; + + yield* sessions.create({ + sessionId, + subject, + scopes, + method: "browser-session-cookie", + client: { + label: null, + ipAddress: null, + userAgent: null, + deviceType: "desktop", + os: null, + browser: null, + }, + issuedAt, + expiresAt, + }); + yield* sql` + UPDATE auth_sessions + SET scopes = ${"session-scopes-secret-sentinel"} + WHERE session_id = ${sessionId} + `; + + const decodeError = yield* Effect.flip(sessions.listActive({ now })); + assert.instanceOf(decodeError, PersistenceErrors.PersistenceDecodeError); + assert.deepStrictEqual(decodeError.correlation, { sessionId }); + assert.equal( + decodeError.message, + `Decode error in AuthSessionRepository.listActive:decodeRows: ${decodeError.issue}`, + ); + assert.notInclude(decodeError.issue, subject); + assert.notInclude(decodeError.issue, "session-scopes-secret-sentinel"); + assert.notInclude(decodeError.message, subject); + + yield* sql`DROP TABLE auth_sessions`; + const createError = yield* Effect.flip( + sessions.create({ + sessionId, + subject, + scopes, + method: "browser-session-cookie", + client: { + label: null, + ipAddress: null, + userAgent: null, + deviceType: "desktop", + os: null, + browser: null, + }, + issuedAt, + expiresAt, + }), + ); + assert.instanceOf(createError, PersistenceErrors.PersistenceSqlError); + assert.deepStrictEqual(createError.correlation, { sessionId }); + assert.equal(createError.message, "SQL error in AuthSessionRepository.create:query"); + assert.notInclude(createError.message, subject); + assert.notInclude(createError.message, DateTime.formatIso(issuedAt)); + + const revokeOtherError = yield* Effect.flip( + sessions.revokeAllExcept({ currentSessionId, revokedAt: now }), + ); + assert.instanceOf(revokeOtherError, PersistenceErrors.PersistenceSqlError); + assert.deepStrictEqual(revokeOtherError.correlation, { currentSessionId }); + assert.equal( + revokeOtherError.message, + "SQL error in AuthSessionRepository.revokeAllExcept:query", + ); + assert.notInclude(revokeOtherError.message, DateTime.formatIso(now)); + }).pipe(Effect.provide(authSessionLayer)), + ); + + it.effect("correlates pairing-link create and revoke failures by id only", () => + Effect.gen(function* () { + const pairingLinks = yield* AuthPairingLinkRepository; + const sql = yield* SqlClient.SqlClient; + const id = "pairing-link-correlation"; + const credential = "pairing-credential-secret-sentinel"; + const subject = "pairing-subject-secret-sentinel"; + const scopesPayload = "pairing-scopes-secret-sentinel"; + + yield* sql` + INSERT INTO auth_pairing_links ( + id, + credential, + method, + scopes, + subject, + label, + proof_key_thumbprint, + created_at, + expires_at, + consumed_at, + revoked_at + ) + VALUES ( + ${id}, + ${credential}, + ${"one-time-token"}, + ${scopesPayload}, + ${subject}, + NULL, + NULL, + ${DateTime.formatIso(issuedAt)}, + ${DateTime.formatIso(expiresAt)}, + NULL, + NULL + ) + `; + + const decodeError = yield* Effect.flip(pairingLinks.getByCredential({ credential })); + assert.instanceOf(decodeError, PersistenceErrors.PersistenceDecodeError); + assert.deepStrictEqual(decodeError.correlation, { pairingLinkId: id }); + assert.equal( + decodeError.message, + `Decode error in AuthPairingLinkRepository.getByCredential:decodeRow: ${decodeError.issue}`, + ); + assert.notInclude(decodeError.issue, credential); + assert.notInclude(decodeError.issue, subject); + assert.notInclude(decodeError.issue, scopesPayload); + assert.notInclude(decodeError.message, DateTime.formatIso(issuedAt)); + + yield* sql`DROP TABLE auth_pairing_links`; + const createError = yield* Effect.flip( + pairingLinks.create({ + id, + credential, + method: "one-time-token", + scopes, + subject, + label: null, + proofKeyThumbprint: null, + createdAt: issuedAt, + expiresAt, + }), + ); + assert.instanceOf(createError, PersistenceErrors.PersistenceSqlError); + assert.deepStrictEqual(createError.correlation, { pairingLinkId: id }); + assert.notInclude(createError.message, credential); + assert.notInclude(createError.message, subject); + assert.notInclude(createError.message, DateTime.formatIso(issuedAt)); + + const revokeError = yield* Effect.flip(pairingLinks.revoke({ id, revokedAt: now })); + assert.instanceOf(revokeError, PersistenceErrors.PersistenceSqlError); + assert.deepStrictEqual(revokeError.correlation, { pairingLinkId: id }); + assert.notInclude(revokeError.message, credential); + assert.notInclude(revokeError.message, DateTime.formatIso(now)); + }).pipe(Effect.provide(authPairingLinkLayer)), + ); + + it.effect("correlates provider runtime SQL and per-row decode failures by thread", () => + Effect.gen(function* () { + const runtimes = yield* ProviderSessionRuntimeRepository; + const sql = yield* SqlClient.SqlClient; + const threadId = ThreadId.make("thread-correlation"); + const runtimePayload = "runtime-payload-secret-sentinel"; + const lastSeenAt = "2026-06-20T00:00:00.000Z"; + + yield* sql` + INSERT INTO provider_session_runtime ( + thread_id, + provider_name, + provider_instance_id, + adapter_key, + runtime_mode, + status, + last_seen_at, + resume_cursor_json, + runtime_payload_json + ) + VALUES ( + ${threadId}, + ${"codex"}, + NULL, + ${"codex"}, + ${"invalid-runtime-mode"}, + ${"running"}, + ${lastSeenAt}, + NULL, + ${`{"secret":"${runtimePayload}"}`} + ) + `; + + const decodeError = yield* Effect.flip(runtimes.list()); + assert.instanceOf(decodeError, PersistenceErrors.PersistenceDecodeError); + assert.deepStrictEqual(decodeError.correlation, { threadId }); + assert.equal( + decodeError.message, + `Decode error in ProviderSessionRuntimeRepository.list:decodeRows: ${decodeError.issue}`, + ); + assert.notInclude(decodeError.issue, runtimePayload); + assert.notInclude(decodeError.message, runtimePayload); + assert.notInclude(decodeError.message, lastSeenAt); + + yield* sql`DROP TABLE provider_session_runtime`; + const sqlFailure = yield* Effect.flip( + runtimes.upsert({ + threadId, + providerName: "codex", + providerInstanceId: null, + adapterKey: "codex", + runtimeMode: "full-access", + status: "running", + lastSeenAt, + resumeCursor: null, + runtimePayload: { secret: runtimePayload }, + }), + ); + assert.instanceOf(sqlFailure, PersistenceErrors.PersistenceSqlError); + assert.deepStrictEqual(sqlFailure.correlation, { threadId }); + assert.equal( + sqlFailure.message, + "SQL error in ProviderSessionRuntimeRepository.upsert:query", + ); + assert.notInclude(sqlFailure.message, runtimePayload); + assert.notInclude(sqlFailure.message, lastSeenAt); + }).pipe(Effect.provide(providerSessionRuntimeLayer)), + ); +}); diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index c07be0be6a8..54df8151562 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -35,10 +35,10 @@ import { AnalyticsService } from "./telemetry/Services/AnalyticsService.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { ProviderSessionReaper } from "./provider/Services/ProviderSessionReaper.ts"; import { - formatHeadlessServeOutput, formatHostForUrl, + formatStartupAccessOutput, isWildcardHost, - issueHeadlessServeAccessInfo, + issueStartupAccessInfo, } from "./startupAccess.ts"; export class ServerRuntimeStartupError extends Data.TaggedError("ServerRuntimeStartupError")<{ @@ -436,21 +436,16 @@ export const makeServerRuntimeStartup = Effect.gen(function* () { yield* Effect.logDebug("startup phase: recording startup heartbeat"); yield* launchStartupHeartbeat; - if (serverConfig.startupPresentation === "headless") { - yield* Effect.logDebug("startup phase: headless access info"); - const accessInfo = yield* issueHeadlessServeAccessInfo(); + if (serverConfig.startupPresentation === "headless" || serverConfig.mode === "web") { + yield* Effect.logDebug("startup phase: pairing access info"); + const accessInfo = yield* issueStartupAccessInfo(); yield* runStartupPhase( - "headless.output", - Console.log(formatHeadlessServeOutput(accessInfo)), + "startup.output", + Console.log(formatStartupAccessOutput(accessInfo)), ); - } else { + } else if (!serverConfig.noBrowser) { yield* Effect.logDebug("startup phase: browser open check"); const startupBrowserTarget = yield* resolveStartupBrowserTarget; - if (serverConfig.mode !== "desktop") { - yield* Effect.logInfo( - "Authentication required. Open more Code using the pairing URL.", - ).pipe(Effect.annotateLogs({ pairingUrl: startupBrowserTarget })); - } yield* runStartupPhase("browser.open", maybeOpenBrowser(startupBrowserTarget)); } yield* Effect.logDebug("startup phase: complete"); diff --git a/apps/server/src/startupAccess.test.ts b/apps/server/src/startupAccess.test.ts index 03c01170f15..a2089624993 100644 --- a/apps/server/src/startupAccess.test.ts +++ b/apps/server/src/startupAccess.test.ts @@ -3,10 +3,14 @@ import { assert, expect, it } from "@effect/vitest"; import { buildPairingUrl, formatHeadlessServeOutput, + formatStartupAccessOutput, renderTerminalQrCode, resolveHeadlessConnectionHost, resolveHeadlessConnectionString, + resolveLanConnectionHost, + resolveLanConnectionString, resolveListeningPort, + resolveLocalConnectionString, } from "./startupAccess.ts"; it("prefers localhost when no explicit host is configured", () => { @@ -65,15 +69,47 @@ it("renders terminal QR codes as a multi-line unicode block grid", () => { assert.isTrue(qrCode.split("\n").length > 10); }); -it("formats headless serve output with the connection string, token, pairing url, and qr code", () => { +it("resolves a LAN connection string from the first external IPv4 interface", () => { + const interfaces = { + en0: [ + { + address: "192.168.1.42", + netmask: "255.255.255.0", + family: "IPv4", + mac: "00:00:00:00:00:00", + internal: false, + cidr: "192.168.1.42/24", + }, + ], + }; + + expect(resolveLanConnectionHost(interfaces)).toBe("192.168.1.42"); + expect(resolveLanConnectionString(3773, interfaces)).toBe("http://192.168.1.42:3773"); + expect(resolveLocalConnectionString(3773)).toBe("http://localhost:3773"); +}); + +it("formats startup output with local and LAN pairing URLs", () => { + const output = formatStartupAccessOutput({ + token: "PAIRCODE", + localPairingUrl: "http://localhost:3773/pair#token=PAIRCODE", + lanPairingUrl: "http://192.168.1.42:3773/pair#token=PAIRCODE", + }); + + expect(output).toContain("Token: PAIRCODE"); + expect(output).toContain("Local pairing URL: http://localhost:3773/pair#token=PAIRCODE"); + expect(output).toContain("LAN pairing URL: http://192.168.1.42:3773/pair#token=PAIRCODE"); + assert.isTrue(output.includes("█") || output.includes("▀") || output.includes("▄")); +}); + +it("formats legacy headless serve output through the startup formatter", () => { const output = formatHeadlessServeOutput({ connectionString: "http://192.168.1.42:3773", token: "PAIRCODE", pairingUrl: "http://192.168.1.42:3773/pair#token=PAIRCODE", }); - expect(output).toContain("Connection string: http://192.168.1.42:3773"); expect(output).toContain("Token: PAIRCODE"); - expect(output).toContain("Pairing URL: http://192.168.1.42:3773/pair#token=PAIRCODE"); + expect(output).toContain("Local pairing URL: http://192.168.1.42:3773/pair#token=PAIRCODE"); + expect(output).not.toContain("LAN pairing URL:"); assert.isTrue(output.includes("█") || output.includes("▀") || output.includes("▄")); }); diff --git a/apps/server/src/startupAccess.ts b/apps/server/src/startupAccess.ts index 610d15a4b93..952a6bac738 100644 --- a/apps/server/src/startupAccess.ts +++ b/apps/server/src/startupAccess.ts @@ -7,6 +7,13 @@ import { HttpServer } from "effect/unstable/http"; import { ServerConfig } from "./config.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; +export interface StartupAccessInfo { + readonly token: string; + readonly localPairingUrl: string; + readonly lanPairingUrl: string | undefined; +} + +/** @deprecated Use {@link StartupAccessInfo} */ export interface HeadlessServeAccessInfo { readonly connectionString: string; readonly token: string; @@ -77,6 +84,27 @@ export const resolveHeadlessConnectionString = ( return `http://${formatHostForUrl(connectionHost)}:${port}`; }; +export const resolveLocalConnectionString = (port: number): string => + `http://localhost:${port}`; + +export const resolveLanConnectionHost = ( + interfaces: NetworkInterfacesMap = NodeOS.networkInterfaces(), +): string | undefined => { + const interfaceEntries = Object.values(interfaces).flatMap((entries) => entries ?? []); + const externalIpv4 = interfaceEntries.find( + (entry) => !entry.internal && isIpv4Family(entry.family), + ); + return externalIpv4?.address; +}; + +export const resolveLanConnectionString = ( + port: number, + interfaces: NetworkInterfacesMap = NodeOS.networkInterfaces(), +): string | undefined => { + const lanHost = resolveLanConnectionHost(interfaces); + return lanHost === undefined ? undefined : `http://${formatHostForUrl(lanHost)}:${port}`; +}; + export const resolveListeningPort = (address: unknown, fallbackPort: number): number => { if ( typeof address === "object" && @@ -119,30 +147,45 @@ export const renderTerminalQrCode = (value: string, margin = 2): string => { return rows.join("\n"); }; -export const formatHeadlessServeOutput = (accessInfo: HeadlessServeAccessInfo): string => - [ +export const formatStartupAccessOutput = (accessInfo: StartupAccessInfo): string => { + const lines = [ "more Code server is ready.", - `Connection string: ${accessInfo.connectionString}`, `Token: ${accessInfo.token}`, - `Pairing URL: ${accessInfo.pairingUrl}`, - "", - renderTerminalQrCode(accessInfo.pairingUrl), - "", - ].join("\n"); + `Local pairing URL: ${accessInfo.localPairingUrl}`, + ]; + if (accessInfo.lanPairingUrl !== undefined) { + lines.push(`LAN pairing URL: ${accessInfo.lanPairingUrl}`); + } + lines.push("", renderTerminalQrCode(accessInfo.localPairingUrl), ""); + return lines.join("\n"); +}; + +export const formatHeadlessServeOutput = (accessInfo: HeadlessServeAccessInfo): string => + formatStartupAccessOutput({ + token: accessInfo.token, + localPairingUrl: accessInfo.pairingUrl, + lanPairingUrl: undefined, + }); -export const issueHeadlessServeAccessInfo = Effect.fn("issueHeadlessServeAccessInfo")(function* () { +export const issueStartupAccessInfo = Effect.fn("issueStartupAccessInfo")(function* () { const serverConfig = yield* ServerConfig; const httpServer = yield* HttpServer.HttpServer; const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; - const connectionString = resolveHeadlessConnectionString( - serverConfig.host, - resolveListeningPort(httpServer.address, serverConfig.port), - ); + const port = resolveListeningPort(httpServer.address, serverConfig.port); + const localConnectionString = resolveLocalConnectionString(port); + const lanConnectionString = resolveLanConnectionString(port); const issued = yield* serverAuth.issueStartupPairingCredential(); + const localPairingUrl = buildPairingUrl(localConnectionString, issued.credential); + const lanPairingUrl = + lanConnectionString === undefined + ? undefined + : buildPairingUrl(lanConnectionString, issued.credential); return { - connectionString, token: issued.credential, - pairingUrl: buildPairingUrl(connectionString, issued.credential), - } satisfies HeadlessServeAccessInfo; + localPairingUrl, + lanPairingUrl: lanPairingUrl === localPairingUrl ? undefined : lanPairingUrl, + } satisfies StartupAccessInfo; }); + +export const issueHeadlessServeAccessInfo = issueStartupAccessInfo; diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts index ba1f3a0435c..347a7678d09 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.test.ts @@ -1,4 +1,4 @@ -import { OpenCodeSettings, ProviderInstanceId } from "@t3tools/contracts"; +import { OpenCodeSettings, ProviderInstanceId, TextGenerationError } from "@t3tools/contracts"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import * as Duration from "effect/Duration"; @@ -9,14 +9,10 @@ import * as TestClock from "effect/testing/TestClock"; import * as NetService from "@t3tools/shared/Net"; import { beforeEach, expect } from "vite-plus/test"; -import { ServerConfig } from "../config.ts"; -import { - OpenCodeRuntime, - OpenCodeRuntimeError, - type OpenCodeRuntimeShape, -} from "../provider/opencodeRuntime.ts"; -import { type TextGenerationShape } from "./TextGeneration.ts"; -import { makeOpenCodeTextGeneration } from "./OpenCodeTextGeneration.ts"; +import * as ServerConfig from "../config.ts"; +import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; +import * as OpenCodeTextGeneration from "./OpenCodeTextGeneration.ts"; +import * as TextGeneration from "./TextGeneration.ts"; const runtimeMock = { state: { @@ -24,8 +20,11 @@ const runtimeMock = { promptUrls: [] as string[], authHeaders: [] as Array, closeCalls: [] as string[], + sessionCreateError: undefined as unknown, + sessionResult: undefined as { data?: { id: string } } | undefined, + promptRequestError: undefined as unknown, promptResult: undefined as - | { data?: { info?: { error?: unknown }; parts?: Array<{ type: string; text?: string }> } } + | { data?: { info?: { error?: unknown }; parts?: Array } } | undefined, }, reset() { @@ -33,11 +32,14 @@ const runtimeMock = { this.state.promptUrls.length = 0; this.state.authHeaders.length = 0; this.state.closeCalls.length = 0; + this.state.sessionCreateError = undefined; + this.state.sessionResult = undefined; + this.state.promptRequestError = undefined; this.state.promptResult = undefined; }, }; -const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { +const OpenCodeRuntimeTestDouble: OpenCodeRuntime.OpenCodeRuntimeShape = { startOpenCodeServerProcess: ({ binaryPath }) => Effect.gen(function* () { const index = runtimeMock.state.startCalls.length + 1; @@ -65,12 +67,20 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { createOpenCodeSdkClient: ({ baseUrl, serverPassword }) => ({ session: { - create: async () => ({ data: { id: `${baseUrl}/session` } }), + create: async () => { + if (runtimeMock.state.sessionCreateError !== undefined) { + throw runtimeMock.state.sessionCreateError; + } + return runtimeMock.state.sessionResult ?? { data: { id: `${baseUrl}/session` } }; + }, prompt: async () => { runtimeMock.state.promptUrls.push(baseUrl); runtimeMock.state.authHeaders.push( serverPassword ? `Basic ${btoa(`opencode:${serverPassword}`)}` : null, ); + if (runtimeMock.state.promptRequestError !== undefined) { + throw runtimeMock.state.promptRequestError; + } return ( runtimeMock.state.promptResult ?? { data: { @@ -88,10 +98,10 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { ); }, }, - }) as unknown as ReturnType, + }) as unknown as ReturnType, loadOpenCodeInventory: () => Effect.fail( - new OpenCodeRuntimeError({ + new OpenCodeRuntime.OpenCodeRuntimeError({ operation: "loadOpenCodeInventory", detail: "OpenCodeRuntimeTestDouble.loadOpenCodeInventory not used in this test", cause: null, @@ -103,15 +113,22 @@ const DEFAULT_TEST_MODEL_SELECTION = { instanceId: ProviderInstanceId.make("opencode"), model: "openai/gpt-5", }; +const DEFAULT_COMMIT_MESSAGE_INPUT = { + cwd: process.cwd(), + branch: "feature/opencode-reuse", + stagedSummary: "M README.md", + stagedPatch: "diff --git a/README.md b/README.md", + modelSelection: DEFAULT_TEST_MODEL_SELECTION, +}; const OPENCODE_TEXT_GENERATION_IDLE_TTL_MS = 30_000; const OpenCodeTextGenerationTestLayer = Layer.succeed( - OpenCodeRuntime, + OpenCodeRuntime.OpenCodeRuntime, OpenCodeRuntimeTestDouble, ).pipe( Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { + ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3code-opencode-text-generation-test-", }), ), @@ -120,11 +137,11 @@ const OpenCodeTextGenerationTestLayer = Layer.succeed( ); const OpenCodeTextGenerationExistingServerTestLayer = Layer.succeed( - OpenCodeRuntime, + OpenCodeRuntime.OpenCodeRuntime, OpenCodeRuntimeTestDouble, ).pipe( Layer.provideMerge( - ServerConfig.layerTest(process.cwd(), { + ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3code-opencode-text-generation-existing-server-test-", }), ), @@ -143,10 +160,10 @@ const EXISTING_SERVER_OPENCODE_SETTINGS = Schema.decodeSync(OpenCodeSettings)({ function withOpenCodeTextGeneration( settings: OpenCodeSettings, - effectFn: (textGeneration: TextGenerationShape) => Effect.Effect, + effectFn: (textGeneration: TextGeneration.TextGeneration["Service"]) => Effect.Effect, ) { return Effect.gen(function* () { - const textGeneration = yield* makeOpenCodeTextGeneration(settings); + const textGeneration = yield* OpenCodeTextGeneration.makeOpenCodeTextGeneration(settings); return yield* effectFn(textGeneration); }).pipe(Effect.scoped); } @@ -225,22 +242,119 @@ it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGeneration", (it) => { ).pipe(Effect.provide(TestClock.layer())), ); - it.effect("returns a typed empty-output error when OpenCode returns no text parts", () => + it.effect("preserves the SDK cause when session creation fails", () => withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => Effect.gen(function* () { - runtimeMock.state.promptResult = { data: {} }; + const sdkCause = new Error("session endpoint unavailable"); + runtimeMock.state.sessionCreateError = sdkCause; const error = yield* textGeneration - .generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/opencode-reuse", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }) + .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(TextGenerationError); + expect(error.message).toContain("OpenCode session.create request failed."); + expect(error.cause).toMatchObject({ + _tag: "OpenCodeTextGenerationSessionRequestError", + operation: "generateCommitMessage", + cwd: process.cwd(), + cause: sdkCause, + }); + expect((error.cause as { cause: unknown }).cause).toBe(sdkCause); + }), + ), + ); + + it.effect("reports a missing session payload without manufacturing a cause", () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + runtimeMock.state.sessionResult = {}; + + const error = yield* textGeneration + .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) + .pipe(Effect.flip); + + expect(error.message).toContain("OpenCode session.create returned no session payload."); + expect(error.cause).toMatchObject({ + _tag: "OpenCodeTextGenerationSessionPayloadError", + operation: "generateCommitMessage", + cwd: process.cwd(), + }); + expect(error.cause).not.toHaveProperty("cause"); + }), + ), + ); + + it.effect("reports a missing session id without manufacturing a cause", () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + runtimeMock.state.sessionResult = { data: {} }; + + const error = yield* textGeneration + .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) + .pipe(Effect.flip); + + expect(error.message).toContain("OpenCode session.create returned no session payload."); + expect(error.cause).toMatchObject({ + _tag: "OpenCodeTextGenerationSessionPayloadError", + operation: "generateCommitMessage", + cwd: process.cwd(), + }); + expect(error.cause).not.toHaveProperty("cause"); + }), + ), + ); + + it.effect("preserves the SDK cause and request context when prompting fails", () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + const sdkCause = new Error("prompt endpoint unavailable"); + runtimeMock.state.promptRequestError = sdkCause; + + const error = yield* textGeneration + .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) + .pipe(Effect.flip); + + expect(error.message).toContain("OpenCode session.prompt request failed."); + expect(error.cause).toMatchObject({ + _tag: "OpenCodeTextGenerationPromptRequestError", + operation: "generateCommitMessage", + cwd: process.cwd(), + sessionId: "http://127.0.0.1:4301/session", + providerId: "openai", + modelId: "gpt-5", + cause: sdkCause, + }); + expect((error.cause as { cause: unknown }).cause).toBe(sdkCause); + }), + ), + ); + + it.effect("returns a typed empty-output error for malformed and blank response parts", () => + withOpenCodeTextGeneration(DEFAULT_OPENCODE_SETTINGS, (textGeneration) => + Effect.gen(function* () { + runtimeMock.state.promptResult = { + data: { + parts: [null, { type: "tool" }, { type: "text", text: " " }], + }, + }; + + const error = yield* textGeneration + .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) .pipe(Effect.flip); expect(error.message).toContain("OpenCode returned empty output."); + expect(error.cause).toMatchObject({ + _tag: "OpenCodeTextGenerationEmptyOutputError", + operation: "generateCommitMessage", + cwd: process.cwd(), + sessionId: "http://127.0.0.1:4301/session", + providerId: "openai", + modelId: "gpt-5", + responsePartCount: 3, + textPartCount: 1, + }); + expect(error.cause).not.toHaveProperty("cause"); }), ), ); @@ -293,16 +407,21 @@ it.layer(OpenCodeTextGenerationTestLayer)("OpenCodeTextGeneration", (it) => { }; const error = yield* textGeneration - .generateCommitMessage({ - cwd: process.cwd(), - branch: "feature/opencode-reuse", - stagedSummary: "M README.md", - stagedPatch: "diff --git a/README.md b/README.md", - modelSelection: DEFAULT_TEST_MODEL_SELECTION, - }) + .generateCommitMessage(DEFAULT_COMMIT_MESSAGE_INPUT) .pipe(Effect.flip); expect(error.message).toContain("Model did not produce structured output"); + expect(error.cause).toMatchObject({ + _tag: "OpenCodeTextGenerationPromptResponseError", + operation: "generateCommitMessage", + cwd: process.cwd(), + sessionId: "http://127.0.0.1:4301/session", + providerId: "openai", + modelId: "gpt-5", + providerErrorName: "StructuredOutputError", + providerMessage: "Model did not produce structured output", + }); + expect(error.cause).not.toHaveProperty("cause"); }), ), ); diff --git a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts index 815508ccc26..a0234b664c9 100644 --- a/apps/server/src/textGeneration/OpenCodeTextGeneration.ts +++ b/apps/server/src/textGeneration/OpenCodeTextGeneration.ts @@ -6,6 +6,7 @@ import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; import { + NonNegativeInt, TextGenerationError, type ChatAttachment, type ModelSelection, @@ -15,7 +16,7 @@ import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/shar import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { extractJsonObject } from "@t3tools/shared/schemaJson"; -import { ServerConfig } from "../config.ts"; +import * as ServerConfig from "../config.ts"; import { resolveAttachmentPath } from "../attachmentStore.ts"; import { buildBranchNamePrompt, @@ -23,28 +24,116 @@ import { buildPrContentPrompt, buildThreadTitlePrompt, } from "./TextGenerationPrompts.ts"; -import { type TextGenerationShape } from "./TextGeneration.ts"; +import * as TextGeneration from "./TextGeneration.ts"; import { sanitizeCommitSubject, sanitizePrTitle, sanitizeThreadTitle, } from "./TextGenerationUtils.ts"; -import { - OpenCodeRuntime, - type OpenCodeServerConnection, - type OpenCodeServerProcess, - openCodeRuntimeErrorDetail, - parseOpenCodeModelSlug, - toOpenCodeFileParts, -} from "../provider/opencodeRuntime.ts"; +import * as OpenCodeRuntime from "../provider/opencodeRuntime.ts"; const OPENCODE_TEXT_GENERATION_IDLE_TTL = "30 seconds"; -function getOpenCodePromptErrorMessage(error: unknown): string | null { +const OpenCodeTextGenerationOperation = Schema.Literals([ + "generateCommitMessage", + "generatePrContent", + "generateBranchName", + "generateThreadTitle", +]); + +type OpenCodeTextGenerationOperation = typeof OpenCodeTextGenerationOperation.Type; + +const openCodeTextGenerationErrorContext = { + operation: OpenCodeTextGenerationOperation, + cwd: Schema.String, +}; + +export class OpenCodeTextGenerationSessionRequestError extends Schema.TaggedErrorClass()( + "OpenCodeTextGenerationSessionRequestError", + { + ...openCodeTextGenerationErrorContext, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `OpenCode session creation request failed for ${this.operation} in ${this.cwd}.`; + } +} + +export class OpenCodeTextGenerationSessionPayloadError extends Schema.TaggedErrorClass()( + "OpenCodeTextGenerationSessionPayloadError", + openCodeTextGenerationErrorContext, +) { + override get message(): string { + return `OpenCode session.create returned no session payload for ${this.operation} in ${this.cwd}.`; + } +} + +const openCodePromptErrorContext = { + ...openCodeTextGenerationErrorContext, + sessionId: Schema.String, + providerId: Schema.String, + modelId: Schema.String, +}; + +export class OpenCodeTextGenerationPromptRequestError extends Schema.TaggedErrorClass()( + "OpenCodeTextGenerationPromptRequestError", + { + ...openCodePromptErrorContext, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `OpenCode prompt request failed for ${this.operation} in ${this.cwd} using ${this.providerId}/${this.modelId} (session ${this.sessionId}).`; + } +} + +export class OpenCodeTextGenerationPromptResponseError extends Schema.TaggedErrorClass()( + "OpenCodeTextGenerationPromptResponseError", + { + ...openCodePromptErrorContext, + providerErrorName: Schema.optional(Schema.String), + providerMessage: Schema.String, + }, +) { + override get message(): string { + const providerError = this.providerErrorName ? ` ${this.providerErrorName}` : ""; + return `OpenCode prompt${providerError} failed for ${this.operation} in ${this.cwd} using ${this.providerId}/${this.modelId} (session ${this.sessionId}): ${this.providerMessage}`; + } +} + +export class OpenCodeTextGenerationEmptyOutputError extends Schema.TaggedErrorClass()( + "OpenCodeTextGenerationEmptyOutputError", + { + ...openCodePromptErrorContext, + responsePartCount: NonNegativeInt, + textPartCount: NonNegativeInt, + }, +) { + override get message(): string { + return `OpenCode returned empty output for ${this.operation} in ${this.cwd} using ${this.providerId}/${this.modelId} (session ${this.sessionId}, ${this.responsePartCount} response parts, ${this.textPartCount} text parts).`; + } +} + +interface OpenCodePromptFailure { + readonly name?: string; + readonly message: string; +} + +interface OpenCodeTextPart { + readonly type: "text"; + readonly text: string; +} + +function getOpenCodePromptFailure(error: unknown): OpenCodePromptFailure | null { if (!error || typeof error !== "object") { return null; } + const name = + "name" in error && typeof error.name === "string" && error.name.trim().length > 0 + ? error.name.trim() + : undefined; const message = "data" in error && error.data && @@ -54,37 +143,40 @@ function getOpenCodePromptErrorMessage(error: unknown): string | null { ? error.data.message.trim() : ""; if (message.length > 0) { - return message; + return { + ...(name ? { name } : {}), + message, + }; } - if ("name" in error && typeof error.name === "string") { - const name = error.name.trim(); - return name.length > 0 ? name : null; + if (name) { + return { name, message: name }; } return null; } +function isOpenCodeTextPart(part: unknown): part is OpenCodeTextPart { + return ( + part !== null && + typeof part === "object" && + "type" in part && + part.type === "text" && + "text" in part && + typeof part.text === "string" + ); +} + function getOpenCodeTextResponse(parts: ReadonlyArray | undefined): string { return (parts ?? []) - .flatMap((part) => { - if (!part || typeof part !== "object") { - return []; - } - if (!("type" in part) || part.type !== "text") { - return []; - } - if (!("text" in part) || typeof part.text !== "string") { - return []; - } - return [part.text]; - }) + .filter(isOpenCodeTextPart) + .map((part) => part.text) .join("") .trim(); } interface SharedOpenCodeTextGenerationServerState { - server: OpenCodeServerProcess | null; + server: OpenCodeRuntime.OpenCodeServerProcess | null; /** * The scope that owns the shared server's lifetime. Closing this scope * terminates the OpenCode child process and interrupts any fibers the @@ -101,8 +193,8 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" openCodeSettings: OpenCodeSettings, environment?: NodeJS.ProcessEnv, ) { - const serverConfig = yield* ServerConfig; - const openCodeRuntime = yield* OpenCodeRuntime; + const serverConfig = yield* ServerConfig.ServerConfig; + const openCodeRuntime = yield* OpenCodeRuntime.OpenCodeRuntime; const resolvedEnvironment = environment ?? process.env; const idleFiberScope = yield* Effect.acquireRelease(Scope.make(), (scope) => Scope.close(scope, Exit.void), @@ -135,7 +227,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" }); const scheduleIdleClose = Effect.fn("scheduleIdleClose")(function* ( - server: OpenCodeServerProcess, + server: OpenCodeRuntime.OpenCodeServerProcess, ) { yield* cancelIdleCloseFiber(); const fiber = yield* Effect.sleep(OPENCODE_TEXT_GENERATION_IDLE_TTL).pipe( @@ -217,7 +309,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" (cause) => new TextGenerationError({ operation: input.operation, - detail: openCodeRuntimeErrorDetail(cause), + detail: OpenCodeRuntime.openCodeRuntimeErrorDetail(cause), cause, }), ), @@ -240,7 +332,7 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" }), ); - const releaseSharedServer = (server: OpenCodeServerProcess) => + const releaseSharedServer = (server: OpenCodeRuntime.OpenCodeServerProcess) => sharedServerMutex.withPermit( Effect.gen(function* () { if (sharedServerState.server !== server) { @@ -267,18 +359,14 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" ); const runOpenCodeJson = Effect.fn("runOpenCodeJson")(function* (input: { - readonly operation: - | "generateCommitMessage" - | "generatePrContent" - | "generateBranchName" - | "generateThreadTitle"; + readonly operation: OpenCodeTextGenerationOperation; readonly cwd: string; readonly prompt: string; readonly outputSchemaJson: S; readonly modelSelection: ModelSelection; readonly attachments?: ReadonlyArray | undefined; }) { - const parsedModel = parseOpenCodeModelSlug(input.modelSelection.model); + const parsedModel = OpenCodeRuntime.parseOpenCodeModelSlug(input.modelSelection.model); if (!parsedModel) { return yield* new TextGenerationError({ operation: input.operation, @@ -286,60 +374,131 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" }); } - const fileParts = toOpenCodeFileParts({ + const fileParts = OpenCodeRuntime.toOpenCodeFileParts({ attachments: input.attachments, resolveAttachmentPath: (attachment) => resolveAttachmentPath({ attachmentsDir: serverConfig.attachmentsDir, attachment }), }); - const runAgainstServer = (server: Pick) => - Effect.tryPromise({ - try: async () => { - const client = openCodeRuntime.createOpenCodeSdkClient({ - baseUrl: server.url, - directory: input.cwd, - ...(openCodeSettings.serverUrl.length > 0 && openCodeSettings.serverPassword - ? { serverPassword: openCodeSettings.serverPassword } - : {}), + const runAgainstServer = Effect.fn("runOpenCodeJson.runAgainstServer")( + function* (server: Pick) { + const client = openCodeRuntime.createOpenCodeSdkClient({ + baseUrl: server.url, + directory: input.cwd, + ...(openCodeSettings.serverUrl.length > 0 && openCodeSettings.serverPassword + ? { serverPassword: openCodeSettings.serverPassword } + : {}), + }); + const session = yield* Effect.tryPromise({ + try: () => + client.session.create({ + title: `T3 Code ${input.operation}`, + permission: [{ permission: "*", pattern: "*", action: "deny" }], + }), + catch: (cause) => + new OpenCodeTextGenerationSessionRequestError({ + operation: input.operation, + cwd: input.cwd, + cause, + }), + }); + if ( + !session.data || + typeof session.data.id !== "string" || + session.data.id.length === 0 + ) { + return yield* new OpenCodeTextGenerationSessionPayloadError({ + operation: input.operation, + cwd: input.cwd, }); - const session = await client.session.create({ - title: `more Code ${input.operation}`, - permission: [{ permission: "*", pattern: "*", action: "deny" }], + } + const selectedAgent = getModelSelectionStringOptionValue(input.modelSelection, "agent"); + const selectedVariant = getModelSelectionStringOptionValue(input.modelSelection, "variant"); + const promptContext = { + operation: input.operation, + cwd: input.cwd, + sessionId: session.data.id, + providerId: parsedModel.providerID, + modelId: parsedModel.modelID, + }; + + const result = yield* Effect.tryPromise({ + try: () => + client.session.prompt({ + sessionID: session.data.id, + model: parsedModel, + ...(selectedAgent ? { agent: selectedAgent } : {}), + ...(selectedVariant ? { variant: selectedVariant } : {}), + parts: [{ type: "text", text: input.prompt }, ...fileParts], + }), + catch: (cause) => + new OpenCodeTextGenerationPromptRequestError({ + ...promptContext, + cause, + }), + }); + const promptFailure = getOpenCodePromptFailure(result.data?.info?.error); + if (promptFailure) { + return yield* new OpenCodeTextGenerationPromptResponseError({ + ...promptContext, + ...(promptFailure.name ? { providerErrorName: promptFailure.name } : {}), + providerMessage: promptFailure.message, }); - if (!session.data) { - throw new Error("OpenCode session.create returned no session payload."); - } - const selectedAgent = getModelSelectionStringOptionValue(input.modelSelection, "agent"); - const selectedVariant = getModelSelectionStringOptionValue( - input.modelSelection, - "variant", - ); - - const result = await client.session.prompt({ - sessionID: session.data.id, - model: parsedModel, - ...(selectedAgent ? { agent: selectedAgent } : {}), - ...(selectedVariant ? { variant: selectedVariant } : {}), - parts: [{ type: "text", text: input.prompt }, ...fileParts], + } + const responseParts = result.data?.parts ?? []; + const rawText = getOpenCodeTextResponse(responseParts); + if (rawText.length === 0) { + return yield* new OpenCodeTextGenerationEmptyOutputError({ + ...promptContext, + responsePartCount: responseParts.length, + textPartCount: responseParts.filter(isOpenCodeTextPart).length, }); - const info = result.data?.info; - const errorMessage = getOpenCodePromptErrorMessage(info?.error); - if (errorMessage) { - throw new Error(errorMessage); - } - const rawText = getOpenCodeTextResponse(result.data?.parts); - if (rawText.length === 0) { - throw new Error("OpenCode returned empty output."); - } - return rawText; - }, - catch: (cause) => - new TextGenerationError({ - operation: input.operation, - detail: openCodeRuntimeErrorDetail(cause), - cause, - }), - }); + } + return rawText; + }, + Effect.catchTags({ + OpenCodeTextGenerationSessionRequestError: (cause) => + Effect.fail( + new TextGenerationError({ + operation: cause.operation, + detail: "OpenCode session.create request failed.", + cause, + }), + ), + OpenCodeTextGenerationSessionPayloadError: (cause) => + Effect.fail( + new TextGenerationError({ + operation: cause.operation, + detail: "OpenCode session.create returned no session payload.", + cause, + }), + ), + OpenCodeTextGenerationPromptRequestError: (cause) => + Effect.fail( + new TextGenerationError({ + operation: cause.operation, + detail: "OpenCode session.prompt request failed.", + cause, + }), + ), + OpenCodeTextGenerationPromptResponseError: (cause) => + Effect.fail( + new TextGenerationError({ + operation: cause.operation, + detail: cause.providerMessage, + cause, + }), + ), + OpenCodeTextGenerationEmptyOutputError: (cause) => + Effect.fail( + new TextGenerationError({ + operation: cause.operation, + detail: "OpenCode returned empty output.", + cause, + }), + ), + }), + ); const rawOutput = openCodeSettings.serverUrl.length > 0 @@ -355,114 +514,111 @@ export const makeOpenCodeTextGeneration = Effect.fn("makeOpenCodeTextGeneration" const decodeOutput = Schema.decodeEffect(Schema.fromJsonString(input.outputSchemaJson)); return yield* decodeOutput(extractJsonObject(rawOutput)).pipe( - Effect.catchTag("SchemaError", (cause) => - Effect.fail( - new TextGenerationError({ - operation: input.operation, - detail: "OpenCode returned invalid structured output.", - cause, - }), - ), - ), + Effect.catchTags({ + SchemaError: (cause) => + Effect.fail( + new TextGenerationError({ + operation: input.operation, + detail: "OpenCode returned invalid structured output.", + cause, + }), + ), + }), ); }); - const generateCommitMessage: TextGenerationShape["generateCommitMessage"] = Effect.fn( - "OpenCodeTextGeneration.generateCommitMessage", - )(function* (input) { - const { prompt, outputSchema } = buildCommitMessagePrompt({ - branch: input.branch, - stagedSummary: input.stagedSummary, - stagedPatch: input.stagedPatch, - includeBranch: input.includeBranch === true, - }); - const generated = yield* runOpenCodeJson({ - operation: "generateCommitMessage", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, + const generateCommitMessage: TextGeneration.TextGeneration["Service"]["generateCommitMessage"] = + Effect.fn("OpenCodeTextGeneration.generateCommitMessage")(function* (input) { + const { prompt, outputSchema } = buildCommitMessagePrompt({ + branch: input.branch, + stagedSummary: input.stagedSummary, + stagedPatch: input.stagedPatch, + includeBranch: input.includeBranch === true, + }); + const generated = yield* runOpenCodeJson({ + operation: "generateCommitMessage", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); + + return { + subject: sanitizeCommitSubject(generated.subject), + body: generated.body.trim(), + ...("branch" in generated && typeof generated.branch === "string" + ? { branch: sanitizeFeatureBranchName(generated.branch) } + : {}), + }; }); - return { - subject: sanitizeCommitSubject(generated.subject), - body: generated.body.trim(), - ...("branch" in generated && typeof generated.branch === "string" - ? { branch: sanitizeFeatureBranchName(generated.branch) } - : {}), - }; - }); + const generatePrContent: TextGeneration.TextGeneration["Service"]["generatePrContent"] = + Effect.fn("OpenCodeTextGeneration.generatePrContent")(function* (input) { + const { prompt, outputSchema } = buildPrContentPrompt({ + baseBranch: input.baseBranch, + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + diffPatch: input.diffPatch, + }); + const generated = yield* runOpenCodeJson({ + operation: "generatePrContent", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + }); - const generatePrContent: TextGenerationShape["generatePrContent"] = Effect.fn( - "OpenCodeTextGeneration.generatePrContent", - )(function* (input) { - const { prompt, outputSchema } = buildPrContentPrompt({ - baseBranch: input.baseBranch, - headBranch: input.headBranch, - commitSummary: input.commitSummary, - diffSummary: input.diffSummary, - diffPatch: input.diffPatch, - }); - const generated = yield* runOpenCodeJson({ - operation: "generatePrContent", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, + return { + title: sanitizePrTitle(generated.title), + body: generated.body.trim(), + }; }); - return { - title: sanitizePrTitle(generated.title), - body: generated.body.trim(), - }; - }); + const generateBranchName: TextGeneration.TextGeneration["Service"]["generateBranchName"] = + Effect.fn("OpenCodeTextGeneration.generateBranchName")(function* (input) { + const { prompt, outputSchema } = buildBranchNamePrompt({ + message: input.message, + attachments: input.attachments, + }); + const generated = yield* runOpenCodeJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + attachments: input.attachments, + }); - const generateBranchName: TextGenerationShape["generateBranchName"] = Effect.fn( - "OpenCodeTextGeneration.generateBranchName", - )(function* (input) { - const { prompt, outputSchema } = buildBranchNamePrompt({ - message: input.message, - attachments: input.attachments, - }); - const generated = yield* runOpenCodeJson({ - operation: "generateBranchName", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, - attachments: input.attachments, + return { + branch: sanitizeBranchFragment(generated.branch), + }; }); - return { - branch: sanitizeBranchFragment(generated.branch), - }; - }); + const generateThreadTitle: TextGeneration.TextGeneration["Service"]["generateThreadTitle"] = + Effect.fn("OpenCodeTextGeneration.generateThreadTitle")(function* (input) { + const { prompt, outputSchema } = buildThreadTitlePrompt({ + message: input.message, + attachments: input.attachments, + }); + const generated = yield* runOpenCodeJson({ + operation: "generateThreadTitle", + cwd: input.cwd, + prompt, + outputSchemaJson: outputSchema, + modelSelection: input.modelSelection, + attachments: input.attachments, + }); - const generateThreadTitle: TextGenerationShape["generateThreadTitle"] = Effect.fn( - "OpenCodeTextGeneration.generateThreadTitle", - )(function* (input) { - const { prompt, outputSchema } = buildThreadTitlePrompt({ - message: input.message, - attachments: input.attachments, + return { + title: sanitizeThreadTitle(generated.title), + }; }); - const generated = yield* runOpenCodeJson({ - operation: "generateThreadTitle", - cwd: input.cwd, - prompt, - outputSchemaJson: outputSchema, - modelSelection: input.modelSelection, - attachments: input.attachments, - }); - - return { - title: sanitizeThreadTitle(generated.title), - }; - }); return { generateCommitMessage, generatePrContent, generateBranchName, generateThreadTitle, - } satisfies TextGenerationShape; + } satisfies TextGeneration.TextGeneration["Service"]; }); diff --git a/apps/server/src/vcs/VcsProjectConfig.test.ts b/apps/server/src/vcs/VcsProjectConfig.test.ts index aac4beb7e32..04f7fcffcda 100644 --- a/apps/server/src/vcs/VcsProjectConfig.test.ts +++ b/apps/server/src/vcs/VcsProjectConfig.test.ts @@ -3,6 +3,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; import * as Path from "effect/Path"; import * as VcsProjectConfig from "./VcsProjectConfig.ts"; @@ -13,6 +14,22 @@ const TestLayer = VcsProjectConfig.layer.pipe( ); describe("VcsProjectConfig", () => { + it("keeps operation context and the original cause on config errors", () => { + const cause = new Error("permission denied"); + const error = new VcsProjectConfig.VcsProjectConfigError({ + operation: "read", + cwd: "/repo/packages/app", + configPath: "/repo/.t3code/vcs.json", + cause, + }); + + assert.equal(error.operation, "read"); + assert.equal(error.cwd, "/repo/packages/app"); + assert.equal(error.configPath, "/repo/.t3code/vcs.json"); + assert.strictEqual(error.cause, cause); + assert.equal(error.message, "Failed to read VCS project config at /repo/.t3code/vcs.json."); + }); + it.layer(TestLayer)("uses an explicit requested VCS kind before config", (it) => { it.effect("returns the requested kind", () => Effect.gen(function* () { @@ -53,6 +70,49 @@ describe("VcsProjectConfig", () => { ); }); + it.layer(TestLayer)("continues to parent configs after a candidate inspect failure", (it) => { + it.effect("logs the failed candidate and returns the parent config", () => { + const messages: unknown[] = []; + const logger = Logger.make(({ message }) => { + messages.push(message); + }); + + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-vcs-config-test-", + }); + const configDir = path.join(root, ".t3code"); + const cwd = path.join(root, "invalid\0child"); + yield* fileSystem.makeDirectory(configDir, { recursive: true }); + yield* fileSystem.writeFileString( + path.join(configDir, "vcs.json"), + // @effect-diagnostics-next-line preferSchemaOverJson:off + JSON.stringify({ vcs: { kind: "jj" } }), + ); + + const config = yield* VcsProjectConfig.VcsProjectConfig; + const kind = yield* config.resolveKind({ cwd }); + + assert.equal(kind, "jj"); + const failedCandidate = path.join(cwd, ".t3code", "vcs.json"); + const [error] = messages[0] as ReadonlyArray; + assert.instanceOf(error, VcsProjectConfig.VcsProjectConfigError); + assert.equal( + error.message, + "Failed to inspect VCS project config at " + failedCandidate + ".", + ); + assert.deepInclude(error, { + operation: "inspect", + cwd, + configPath: failedCandidate, + _tag: "VcsProjectConfigError", + }); + }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); + }); + }); + it.layer(TestLayer)("falls back to auto when no config exists", (it) => { it.effect("returns auto", () => Effect.gen(function* () { @@ -67,4 +127,99 @@ describe("VcsProjectConfig", () => { }), ); }); + + it.layer(TestLayer)("falls back to auto when config JSON is malformed", (it) => { + it.effect("returns auto and logs the failed operation and path", () => { + const messages: unknown[] = []; + const logger = Logger.make(({ message }) => { + messages.push(message); + }); + + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-vcs-config-test-", + }); + const configDir = path.join(root, ".t3code"); + yield* fileSystem.makeDirectory(configDir, { recursive: true }); + yield* fileSystem.writeFileString(path.join(configDir, "vcs.json"), "{not json"); + + const config = yield* VcsProjectConfig.VcsProjectConfig; + const kind = yield* config.resolveKind({ cwd: root }); + + assert.equal(kind, "auto"); + const [error] = messages[0] as ReadonlyArray; + assert.instanceOf(error, VcsProjectConfig.VcsProjectConfigError); + assert.equal( + error.message, + "Failed to decode VCS project config at " + path.join(configDir, "vcs.json") + ".", + ); + assert.deepInclude(error.cause, { _tag: "SchemaError" }); + assert.deepInclude(error, { + operation: "decode", + cwd: root, + configPath: path.join(configDir, "vcs.json"), + _tag: "VcsProjectConfigError", + }); + }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); + }); + }); + + it.layer(TestLayer)("falls back to auto when the config path cannot be read", (it) => { + it.effect("retains the read failure context", () => { + const messages: unknown[] = []; + const logger = Logger.make(({ message }) => { + messages.push(message); + }); + + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-vcs-config-test-", + }); + const configPath = path.join(root, ".t3code", "vcs.json"); + yield* fileSystem.makeDirectory(configPath, { recursive: true }); + + const config = yield* VcsProjectConfig.VcsProjectConfig; + const kind = yield* config.resolveKind({ cwd: root }); + + assert.equal(kind, "auto"); + const [error] = messages[0] as ReadonlyArray; + assert.instanceOf(error, VcsProjectConfig.VcsProjectConfigError); + assert.equal(error.message, "Failed to read VCS project config at " + configPath + "."); + assert.deepInclude(error.cause, { _tag: "PlatformError" }); + assert.deepInclude(error, { + operation: "read", + cwd: root, + configPath, + _tag: "VcsProjectConfigError", + }); + }).pipe(Effect.provide(Logger.layer([logger], { mergeWithExisting: false }))); + }); + }); + + it.layer(TestLayer)("falls back to auto when config kind is invalid", (it) => { + it.effect("returns auto", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-vcs-config-test-", + }); + const configDir = path.join(root, ".t3code"); + yield* fileSystem.makeDirectory(configDir, { recursive: true }); + yield* fileSystem.writeFileString( + path.join(configDir, "vcs.json"), + `{"vcs":{"kind":"svn"}}`, + ); + + const config = yield* VcsProjectConfig.VcsProjectConfig; + const kind = yield* config.resolveKind({ cwd: root }); + + assert.equal(kind, "auto"); + }), + ); + }); }); diff --git a/apps/server/src/vcs/VcsProjectConfig.ts b/apps/server/src/vcs/VcsProjectConfig.ts index 3e5ee2347ce..6abce9a3ef3 100644 --- a/apps/server/src/vcs/VcsProjectConfig.ts +++ b/apps/server/src/vcs/VcsProjectConfig.ts @@ -2,10 +2,12 @@ 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 Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import { VcsDriverKind, type VcsDriverKind as VcsDriverKindType } from "@t3tools/contracts"; +import { fromLenientJson } from "@t3tools/shared/schemaJson"; const ProjectVcsConfig = Schema.Struct({ vcs: Schema.optional( @@ -15,46 +17,54 @@ const ProjectVcsConfig = Schema.Struct({ ), vcsKind: Schema.optional(VcsDriverKind), }); -const isProjectVcsConfig = Schema.is(ProjectVcsConfig); +const ProjectVcsConfigJson = fromLenientJson(ProjectVcsConfig); +const decodeProjectVcsConfigJson = Schema.decodeUnknownEffect(ProjectVcsConfigJson); -interface ProjectVcsConfigFile { - readonly vcs?: - | { - readonly kind?: VcsDriverKindType | undefined; - } - | undefined; - readonly vcsKind?: VcsDriverKindType | undefined; -} +type ProjectVcsConfigFile = typeof ProjectVcsConfig.Type; export interface VcsProjectConfigResolveInput { readonly cwd: string; readonly requestedKind?: VcsDriverKindType | "auto"; } -export interface VcsProjectConfigShape { - readonly resolveKind: ( - input: VcsProjectConfigResolveInput, - ) => Effect.Effect; +export class VcsProjectConfigError extends Schema.TaggedErrorClass()( + "VcsProjectConfigError", + { + operation: Schema.Literals(["inspect", "read", "decode"]), + cwd: Schema.String, + configPath: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to ${this.operation} VCS project config at ${this.configPath}.`; + } } -export class VcsProjectConfig extends Context.Service()( - "t3/vcs/VcsProjectConfig", -) {} +export class VcsProjectConfig extends Context.Service< + VcsProjectConfig, + { + readonly resolveKind: ( + input: VcsProjectConfigResolveInput, + ) => Effect.Effect; + } +>()("t3/vcs/VcsProjectConfig") {} function configuredKind(config: ProjectVcsConfigFile): VcsDriverKindType | "auto" { return config.vcs?.kind ?? config.vcsKind ?? "auto"; } -function parseConfig(raw: string): ProjectVcsConfigFile | null { - try { - const parsed = JSON.parse(raw) as unknown; - return isProjectVcsConfig(parsed) ? parsed : null; - } catch { - return null; - } -} +const logVcsProjectConfigError = (error: VcsProjectConfigError) => + Effect.logWarning(error).pipe( + Effect.annotateLogs({ + operation: error.operation, + cwd: error.cwd, + configPath: error.configPath, + errorTag: error._tag, + }), + ); -export const make = Effect.fn("makeVcsProjectConfig")(function* () { +export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -62,57 +72,80 @@ export const make = Effect.fn("makeVcsProjectConfig")(function* () { let current = cwd; while (true) { const candidate = path.join(current, ".t3code", "vcs.json"); - if (yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false))) { - return candidate; + const exists = yield* fileSystem.exists(candidate).pipe( + Effect.mapError( + (cause) => + new VcsProjectConfigError({ + operation: "inspect", + cwd, + configPath: candidate, + cause, + }), + ), + Effect.catchTags({ + VcsProjectConfigError: (error) => logVcsProjectConfigError(error).pipe(Effect.as(false)), + }), + ); + if (exists) { + return Option.some(candidate); } const parent = path.dirname(current); if (parent === current) { - return null; + return Option.none(); } current = parent; } }); const readConfiguredKind = Effect.fn("VcsProjectConfig.readConfiguredKind")(function* ( + cwd: string, configPath: string, ) { const raw = yield* fileSystem.readFileString(configPath).pipe( - Effect.catch((error) => - Effect.logWarning("failed to read VCS project config", { - configPath, - error, - }).pipe(Effect.as(null)), + Effect.mapError( + (cause) => + new VcsProjectConfigError({ + operation: "read", + cwd, + configPath, + cause, + }), + ), + ); + const parsed = yield* decodeProjectVcsConfigJson(raw).pipe( + Effect.mapError( + (cause) => + new VcsProjectConfigError({ + operation: "decode", + cwd, + configPath, + cause, + }), ), ); - if (raw === null) { - return "auto" as const; - } - - const parsed = parseConfig(raw); - if (parsed === null) { - yield* Effect.logWarning("invalid VCS project config", { - configPath, - }); - return "auto" as const; - } - return configuredKind(parsed); }); - const resolveKind: VcsProjectConfigShape["resolveKind"] = Effect.fn( + const resolveKind: VcsProjectConfig["Service"]["resolveKind"] = Effect.fn( "VcsProjectConfig.resolveKind", )(function* (input) { if (input.requestedKind !== undefined && input.requestedKind !== "auto") { return input.requestedKind; } - const configPath = yield* findConfigPath(input.cwd); - if (configPath === null) { - return "auto"; - } - - return yield* readConfiguredKind(configPath); + return yield* findConfigPath(input.cwd).pipe( + Effect.flatMap( + Option.match({ + onNone: () => Effect.succeed("auto" as const), + onSome: (configPath) => readConfiguredKind(input.cwd, configPath), + }), + ), + Effect.catchTags({ + VcsProjectConfigError: (error) => + logVcsProjectConfigError(error).pipe(Effect.as("auto" as const)), + }), + ); }); return VcsProjectConfig.of({ @@ -120,4 +153,4 @@ export const make = Effect.fn("makeVcsProjectConfig")(function* () { }); }); -export const layer = Layer.effect(VcsProjectConfig, make()); +export const layer = Layer.effect(VcsProjectConfig, make); diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts index 2e30cd59dd9..da840cbe017 100644 --- a/apps/web/src/authBootstrap.test.ts +++ b/apps/web/src/authBootstrap.test.ts @@ -1,6 +1,6 @@ import { - AuthSessionState as AuthSessionStateSchema, EnvironmentAuthInvalidError, + EnvironmentOperationForbiddenError, type AuthBrowserSessionResult, type AuthCreatePairingCredentialInput, type AuthSessionState, @@ -8,10 +8,11 @@ import { } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; -import * as Schema from "effect/Schema"; +import { HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { installEnvironmentHttpTest } from "../test/environmentHttpTest"; +import { __setPrimaryHttpRunnerForTests, type PrimaryHttpEffectRunner } from "./lib/runtime"; type TestWindow = { location: URL; @@ -36,8 +37,6 @@ const DESKTOP_AUTH = { } as const; const SESSION_EXPIRES_AT = DateTime.makeUnsafe("2026-04-05T00:00:00.000Z"); -const encodeAuthSessionState = Schema.encodeSync(AuthSessionStateSchema); - const unauthenticatedSession = (auth: AuthSessionState["auth"]): AuthSessionState => ({ authenticated: false, auth, @@ -68,11 +67,23 @@ function installTestBrowser(url: string) { }; vi.stubGlobal("window", testWindow); - vi.stubGlobal("document", { title: "more Code" }); + vi.stubGlobal("document", { title: "T3 Code" }); return testWindow; } +function installDesktopBootstrap() { + const testWindow = installTestBrowser("http://localhost/"); + testWindow.desktopBridge = { + getLocalEnvironmentBootstrap: () => ({ + label: "Local environment", + httpBaseUrl: "http://localhost:3773", + wsBaseUrl: "ws://localhost:3773", + bootstrapToken: "desktop-bootstrap-token", + }), + } as DesktopBridge; +} + function sequence(...values: ReadonlyArray) { let index = 0; return () => values[Math.min(index++, values.length - 1)]!; @@ -117,6 +128,7 @@ describe("resolveInitialServerAuthGateState", () => { disposeHttpTest = undefined; const { __resetServerAuthBootstrapForTests } = await import("./environments/primary"); __resetServerAuthBootstrapForTests(); + __setPrimaryHttpRunnerForTests(); vi.unstubAllEnvs(); vi.useRealTimers(); vi.restoreAllMocks(); @@ -132,15 +144,7 @@ describe("resolveInitialServerAuthGateState", () => { browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])), }); - const testWindow = installTestBrowser("http://localhost/"); - testWindow.desktopBridge = { - getLocalEnvironmentBootstrap: () => ({ - label: "Local environment", - httpBaseUrl: "http://localhost:3773", - wsBaseUrl: "ws://localhost:3773", - bootstrapToken: "desktop-bootstrap-token", - }), - } as DesktopBridge; + installDesktopBootstrap(); const { resolveInitialServerAuthGateState } = await import("./environments/primary"); @@ -220,18 +224,22 @@ describe("resolveInitialServerAuthGateState", () => { it("retries transient auth session bootstrap failures after restart", async () => { vi.useFakeTimers(); - const fetchMock = vi - .fn() - .mockResolvedValueOnce(new Response("Bad Gateway", { status: 502 })) - .mockResolvedValueOnce(new Response("Bad Gateway", { status: 502 })) - .mockResolvedValueOnce(new Response("Bad Gateway", { status: 502 })) - .mockResolvedValueOnce( - new Response( - JSON.stringify(encodeAuthSessionState(unauthenticatedSession(LOOPBACK_AUTH))), - { status: 200, headers: { "content-type": "application/json" } }, - ), - ); - vi.stubGlobal("fetch", fetchMock); + let attempts = 0; + const request = HttpClientRequest.get("http://localhost/api/auth/session"); + const response = HttpClientResponse.fromWeb( + request, + new Response("Bad Gateway", { status: 502 }), + ); + const runner: PrimaryHttpEffectRunner = async () => { + attempts += 1; + if (attempts < 4) { + throw new HttpClientError.HttpClientError({ + reason: new HttpClientError.StatusCodeError({ request, response }), + }); + } + return unauthenticatedSession(LOOPBACK_AUTH) as A; + }; + __setPrimaryHttpRunnerForTests(runner); const { resolveInitialServerAuthGateState } = await import("./environments/primary"); @@ -242,7 +250,7 @@ describe("resolveInitialServerAuthGateState", () => { status: "requires-auth", auth: LOOPBACK_AUTH, }); - expect(fetchMock).toHaveBeenCalledTimes(4); + expect(attempts).toBe(4); }); it("takes a pairing token from the location hash and strips it immediately", async () => { @@ -286,24 +294,114 @@ describe("resolveInitialServerAuthGateState", () => { expect(testApi.calls.session).toBe(2); }); + it("rejects a blank pairing token with a structured validation error", async () => { + const { PrimaryEnvironmentPairingCredentialRequiredError, submitServerAuthCredential } = + await import("./environments/primary/auth"); + + const error = await submitServerAuthCredential(" ").then( + () => null, + (failure: unknown) => failure, + ); + + expect(error).toBeInstanceOf(PrimaryEnvironmentPairingCredentialRequiredError); + expect(error).toMatchObject({ + _tag: "PrimaryEnvironmentPairingCredentialRequiredError", + providedLength: 3, + message: "Enter a pairing token to continue.", + }); + }); + it("surfaces a friendly error message when an invalid pairing token is submitted", async () => { + const cause = new EnvironmentAuthInvalidError({ + code: "auth_invalid", + reason: "invalid_credential", + traceId: "trace-invalid-credential", + }); + const testApi = await installAuthApi({ + browserSession: () => Effect.fail(cause), + }); + + const { isPrimaryEnvironmentPairingCredentialRejectedError, submitServerAuthCredential } = + await import("./environments/primary"); + + const error = await submitServerAuthCredential("bad-token").then( + () => null, + (failure: unknown) => failure, + ); + expect(error).toMatchObject({ + _tag: "PrimaryEnvironmentPairingCredentialRejectedError", + providedLength: 9, + message: "Invalid pairing token. Check the token and try again.", + }); + expect(isPrimaryEnvironmentPairingCredentialRejectedError(error)).toBe(true); + if (!isPrimaryEnvironmentPairingCredentialRejectedError(error)) { + throw new Error("Expected a structured rejected pairing credential error."); + } + expect(error.cause).toMatchObject({ + _tag: "EnvironmentAuthInvalidError", + code: "auth_invalid", + reason: "invalid_credential", + traceId: "trace-invalid-credential", + }); + expect(testApi.calls.browserSession).toEqual([{ credential: "bad-token" }]); + }); + + it("derives primary request messages from structural request context", async () => { + const cause = new Error("private transport detail"); + const { PrimaryEnvironmentRequestError } = await import("./environments/primary"); + const error = PrimaryEnvironmentRequestError.fromCause({ + operation: "list-pairing-links", + cause, + }); + + expect(error.status).toBe(500); + expect(error.cause).toBe(cause); + expect(error.message).toBe( + "Primary environment request failed during list-pairing-links (HTTP 500).", + ); + expect(error.message).not.toContain(cause.message); + }); + + it("maps environment HTTP errors to user-facing request messages", async () => { + const cause = new EnvironmentOperationForbiddenError({ + code: "operation_forbidden", + reason: "current_session_revoke_not_allowed", + traceId: "trace-forbidden", + }); + const { PrimaryEnvironmentRequestError } = await import("./environments/primary"); + const error = PrimaryEnvironmentRequestError.fromCause({ + operation: "list-client-sessions", + cause, + }); + + expect(error.status).toBe(403); + expect(error.message).toBe("This operation is not allowed for the current session."); + expect(error.message).not.toContain(cause.traceId); + }); + + it("surfaces a friendly bootstrap error when desktop credentials are missing", async () => { const testApi = await installAuthApi({ + session: () => unauthenticatedSession(DESKTOP_AUTH), browserSession: () => Effect.fail( new EnvironmentAuthInvalidError({ code: "auth_invalid", - reason: "invalid_credential", - traceId: "trace-invalid-credential", + reason: "missing_credential", + traceId: "trace-missing-credential", }), ), }); - const { submitServerAuthCredential } = await import("./environments/primary"); + installDesktopBootstrap(); - await expect(submitServerAuthCredential("bad-token")).rejects.toThrow( - "Invalid pairing token. Check the token and try again.", - ); - expect(testApi.calls.browserSession).toEqual([{ credential: "bad-token" }]); + const { resolveInitialServerAuthGateState } = await import("./environments/primary"); + + await expect(resolveInitialServerAuthGateState()).resolves.toEqual({ + status: "requires-auth", + auth: DESKTOP_AUTH, + errorMessage: "Authentication required.", + }); + expect(testApi.calls.browserSession).toEqual([{ credential: "desktop-bootstrap-token" }]); }); it("waits for the authenticated session to become observable after silent desktop bootstrap", async () => { @@ -318,15 +416,7 @@ describe("resolveInitialServerAuthGateState", () => { browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])), }); - const testWindow = installTestBrowser("http://localhost/"); - testWindow.desktopBridge = { - getLocalEnvironmentBootstrap: () => ({ - label: "Local environment", - httpBaseUrl: "http://localhost:3773", - wsBaseUrl: "ws://localhost:3773", - bootstrapToken: "desktop-bootstrap-token", - }), - } as DesktopBridge; + installDesktopBootstrap(); const { resolveInitialServerAuthGateState } = await import("./environments/primary"); @@ -337,6 +427,28 @@ describe("resolveInitialServerAuthGateState", () => { expect(testApi.calls.session).toBe(3); }); + it("preserves the timeout message when a bootstrapped session never becomes observable", async () => { + vi.useFakeTimers(); + const testApi = await installAuthApi({ + session: () => unauthenticatedSession(DESKTOP_AUTH), + browserSession: () => Effect.succeed(browserSession(["orchestration:read", "access:write"])), + }); + + installDesktopBootstrap(); + + const { resolveInitialServerAuthGateState } = await import("./environments/primary"); + + const gateStatePromise = resolveInitialServerAuthGateState(); + await vi.advanceTimersByTimeAsync(2_000); + + await expect(gateStatePromise).resolves.toEqual({ + status: "requires-auth", + auth: DESKTOP_AUTH, + errorMessage: "Timed out waiting for authenticated session after bootstrap.", + }); + expect(testApi.calls.browserSession).toEqual([{ credential: "desktop-bootstrap-token" }]); + }); + it("memoizes the authenticated gate state after the first successful read", async () => { const testApi = await installAuthApi({ session: sequence(authenticatedSession(LOOPBACK_AUTH), unauthenticatedSession(LOOPBACK_AUTH)), diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 8c7356e2829..36773500554 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -79,6 +79,7 @@ import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; import { readEnvironmentApi } from "~/environmentApi"; import { readLocalApi } from "~/localApi"; import { getSourceControlPresentation } from "~/sourceControlPresentation"; +import { openPullRequestLink } from "~/lib/openPullRequestLink"; import { useStore } from "~/store"; import { createThreadSelectorByRef } from "~/storeSelectors"; @@ -1208,7 +1209,8 @@ export default function GitActionsControl({ }); return; } - void api.shell.openExternal(prUrl).catch((err: unknown) => { + void openPullRequestLink(api.shell, prUrl).catch((err: unknown) => { + console.error(err); toastManager.add( stackedThreadToast({ type: "error", diff --git a/apps/web/src/environments/primary/auth.ts b/apps/web/src/environments/primary/auth.ts index f6f07dbb303..1406997aec2 100644 --- a/apps/web/src/environments/primary/auth.ts +++ b/apps/web/src/environments/primary/auth.ts @@ -21,15 +21,103 @@ import { import { PrimaryEnvironmentHttpClient } from "./httpClient"; import { runPrimaryHttp } from "../../lib/runtime"; -import * as Data from "effect/Data"; -import * as Predicate from "effect/Predicate"; - -export class BootstrapHttpError extends Data.TaggedError("BootstrapHttpError")<{ - readonly message: string; - readonly status: number; -}> {} -const isBootstrapHttpError = (u: unknown): u is BootstrapHttpError => - Predicate.isTagged(u, "BootstrapHttpError"); + +const PrimaryEnvironmentRequestOperation = Schema.Literals([ + "fetch-session-state", + "exchange-bootstrap-credential", + "fetch-environment-descriptor", + "create-pairing-credential", + "list-pairing-links", + "revoke-pairing-link", + "list-client-sessions", + "revoke-client-session", + "revoke-other-client-sessions", +]); +type PrimaryEnvironmentRequestOperation = typeof PrimaryEnvironmentRequestOperation.Type; + +export class PrimaryEnvironmentRequestError extends Schema.TaggedErrorClass()( + "PrimaryEnvironmentRequestError", + { + operation: PrimaryEnvironmentRequestOperation, + status: Schema.Number, + pairingLinkId: Schema.optional(Schema.String), + sessionId: Schema.optional(Schema.String), + cause: Schema.Defect(), + }, +) { + static fromCause(input: { + readonly operation: PrimaryEnvironmentRequestOperation; + readonly cause: unknown; + readonly pairingLinkId?: string; + readonly sessionId?: string; + }): PrimaryEnvironmentRequestError { + const status = readHttpApiStatus(input.cause) ?? 500; + return new PrimaryEnvironmentRequestError({ + operation: input.operation, + status, + ...(input.pairingLinkId !== undefined ? { pairingLinkId: input.pairingLinkId } : {}), + ...(input.sessionId !== undefined ? { sessionId: input.sessionId } : {}), + cause: input.cause, + }); + } + + override get message(): string { + return readHttpApiErrorMessage( + this.cause, + `Primary environment request failed during ${this.operation} (HTTP ${this.status}).`, + ); + } +} + +export const isPrimaryEnvironmentRequestError = Schema.is(PrimaryEnvironmentRequestError); + +export class PrimaryEnvironmentPairingCredentialRejectedError extends Schema.TaggedErrorClass()( + "PrimaryEnvironmentPairingCredentialRejectedError", + { + providedLength: Schema.Number, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return "Invalid pairing token. Check the token and try again."; + } +} + +export const isPrimaryEnvironmentPairingCredentialRejectedError = Schema.is( + PrimaryEnvironmentPairingCredentialRejectedError, +); + +export class PrimaryEnvironmentAuthSessionTimeoutError extends Schema.TaggedErrorClass()( + "PrimaryEnvironmentAuthSessionTimeoutError", + { + timeoutMs: Schema.Number, + elapsedMs: Schema.Number, + }, +) { + override get message(): string { + return "Timed out waiting for authenticated session after bootstrap."; + } +} + +export const isPrimaryEnvironmentAuthSessionTimeoutError = Schema.is( + PrimaryEnvironmentAuthSessionTimeoutError, +); + +export class PrimaryEnvironmentPairingCredentialRequiredError extends Schema.TaggedErrorClass()( + "PrimaryEnvironmentPairingCredentialRequiredError", + { + providedLength: Schema.Number, + }, +) { + override get message(): string { + return "Enter a pairing token to continue."; + } +} + +export const isPrimaryEnvironmentPairingCredentialRequiredError = Schema.is( + PrimaryEnvironmentPairingCredentialRequiredError, +); + const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError); export interface ServerPairingLinkRecord { @@ -106,10 +194,9 @@ export async function fetchSessionState(): Promise { ), ); } catch (error) { - const status = readHttpApiStatus(error); - throw new BootstrapHttpError({ - message: `Failed to load server auth session state (${status ?? "unknown"}).`, - status: status ?? 500, + throw PrimaryEnvironmentRequestError.fromCause({ + operation: "fetch-session-state", + cause: error, }); } }); @@ -160,20 +247,6 @@ function readHttpApiErrorMessage(error: unknown, fallbackMessage: string): strin } } -const INVALID_BOOTSTRAP_CREDENTIAL_MESSAGES = new Set([ - "Invalid bootstrap credential.", - "Unknown bootstrap credential.", -]); - -function toFriendlyBootstrapErrorMessage(status: number, message: string): string { - const trimmedMessage = message.trim(); - if (status === 401 && INVALID_BOOTSTRAP_CREDENTIAL_MESSAGES.has(trimmedMessage)) { - return "Invalid pairing token. Check the token and try again."; - } - - return trimmedMessage; -} - async function exchangeBootstrapCredential(credential: string): Promise { return retryTransientBootstrap(async () => { try { @@ -183,11 +256,19 @@ async function exchangeBootstrapCredential(credential: string): Promise= AUTH_SESSION_ESTABLISH_TIMEOUT_MS) { - throw new Error("Timed out waiting for authenticated session after bootstrap."); + const elapsedMs = Date.now() - startedAt; + if (elapsedMs >= AUTH_SESSION_ESTABLISH_TIMEOUT_MS) { + throw new PrimaryEnvironmentAuthSessionTimeoutError({ + timeoutMs: AUTH_SESSION_ESTABLISH_TIMEOUT_MS, + elapsedMs, + }); } await waitForBootstrapRetry(AUTH_SESSION_ESTABLISH_STEP_MS); @@ -240,7 +325,7 @@ function waitForBootstrapRetry(delayMs: number): Promise { } function isTransientBootstrapError(error: unknown): boolean { - if (isBootstrapHttpError(error)) { + if (isPrimaryEnvironmentRequestError(error)) { return TRANSIENT_BOOTSTRAP_STATUS_CODES.has(error.status); } @@ -281,7 +366,9 @@ async function bootstrapServerAuth(): Promise { export async function submitServerAuthCredential(credential: string): Promise { const trimmedCredential = credential.trim(); if (!trimmedCredential) { - throw new Error("Enter a pairing token to continue."); + throw new PrimaryEnvironmentPairingCredentialRequiredError({ + providedLength: credential.length, + }); } resolvedAuthenticatedGateState = null; @@ -310,13 +397,10 @@ export async function createServerPairingCredential(input?: { ), ); } catch (error) { - throw new Error( - readHttpApiErrorMessage( - error, - `Failed to create pairing credential (${readHttpApiStatus(error) ?? "unknown"}).`, - ), - { cause: error }, - ); + throw PrimaryEnvironmentRequestError.fromCause({ + operation: "create-pairing-credential", + cause: error, + }); } } @@ -353,13 +437,10 @@ export async function listServerPairingLinks(): Promise { ), ); } catch (error) { - throw new Error( - readHttpApiErrorMessage( - error, - `Failed to revoke pairing link (${readHttpApiStatus(error) ?? "unknown"}).`, - ), - { cause: error }, - ); + throw PrimaryEnvironmentRequestError.fromCause({ + operation: "revoke-pairing-link", + pairingLinkId: id, + cause: error, + }); } } @@ -406,13 +485,10 @@ export async function listServerClientSessions(): Promise< current: clientSession.current, })); } catch (error) { - throw new Error( - readHttpApiErrorMessage( - error, - `Failed to load paired clients (${readHttpApiStatus(error) ?? "unknown"}).`, - ), - { cause: error }, - ); + throw PrimaryEnvironmentRequestError.fromCause({ + operation: "list-client-sessions", + cause: error, + }); } } @@ -426,13 +502,11 @@ export async function revokeServerClientSession(sessionId: AuthSessionId): Promi ), ); } catch (error) { - throw new Error( - readHttpApiErrorMessage( - error, - `Failed to revoke client session (${readHttpApiStatus(error) ?? "unknown"}).`, - ), - { cause: error }, - ); + throw PrimaryEnvironmentRequestError.fromCause({ + operation: "revoke-client-session", + sessionId, + cause: error, + }); } } @@ -445,13 +519,10 @@ export async function revokeOtherServerClientSessions(): Promise { ); return result.revokedCount; } catch (error) { - throw new Error( - readHttpApiErrorMessage( - error, - `Failed to revoke other client sessions (${readHttpApiStatus(error) ?? "unknown"}).`, - ), - { cause: error }, - ); + throw PrimaryEnvironmentRequestError.fromCause({ + operation: "revoke-other-client-sessions", + cause: error, + }); } } diff --git a/apps/web/src/environments/primary/context.ts b/apps/web/src/environments/primary/context.ts index db4406ecee0..3e62bc876e2 100644 --- a/apps/web/src/environments/primary/context.ts +++ b/apps/web/src/environments/primary/context.ts @@ -5,10 +5,9 @@ import { } from "@t3tools/client-runtime"; import type { EnvironmentId, ExecutionEnvironmentDescriptor } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; -import { HttpClientError } from "effect/unstable/http"; import { create } from "zustand"; -import { BootstrapHttpError, retryTransientBootstrap } from "./auth"; +import { PrimaryEnvironmentRequestError, retryTransientBootstrap } from "./auth"; import { PrimaryEnvironmentHttpClient } from "./httpClient"; import { runPrimaryHttp } from "../../lib/runtime"; @@ -56,13 +55,9 @@ async function fetchPrimaryEnvironmentDescriptor(): Promise client.metadata.descriptor())), ); } catch (error) { - const status = - HttpClientError.isHttpClientError(error) && error.response !== undefined - ? error.response.status - : 500; - throw new BootstrapHttpError({ - message: `Failed to load server environment descriptor (${status}).`, - status, + throw PrimaryEnvironmentRequestError.fromCause({ + operation: "fetch-environment-descriptor", + cause: error, }); } diff --git a/apps/web/src/environments/primary/index.ts b/apps/web/src/environments/primary/index.ts index 09576c34e42..8985179e222 100644 --- a/apps/web/src/environments/primary/index.ts +++ b/apps/web/src/environments/primary/index.ts @@ -17,9 +17,17 @@ export { export { createServerPairingCredential, fetchSessionState, + isPrimaryEnvironmentAuthSessionTimeoutError, + isPrimaryEnvironmentPairingCredentialRejectedError, + isPrimaryEnvironmentPairingCredentialRequiredError, + isPrimaryEnvironmentRequestError, listServerClientSessions, listServerPairingLinks, peekPairingTokenFromUrl, + PrimaryEnvironmentAuthSessionTimeoutError, + PrimaryEnvironmentPairingCredentialRejectedError, + PrimaryEnvironmentPairingCredentialRequiredError, + PrimaryEnvironmentRequestError, resolveInitialServerAuthGateState, revokeOtherServerClientSessions, revokeServerClientSession, diff --git a/apps/web/src/hooks/useTheme.test.ts b/apps/web/src/hooks/useTheme.test.ts new file mode 100644 index 00000000000..a028970aae0 --- /dev/null +++ b/apps/web/src/hooks/useTheme.test.ts @@ -0,0 +1,257 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +function createStorage(overrides: Partial = {}): Storage { + const store = new Map(); + return { + clear: () => store.clear(), + getItem: (key) => store.get(key) ?? null, + key: (index) => [...store.keys()][index] ?? null, + get length() { + return store.size; + }, + removeItem: (key) => { + store.delete(key); + }, + setItem: (key, value) => { + store.set(key, value); + }, + ...overrides, + }; +} + +afterEach(() => { + vi.doUnmock("react"); + vi.resetModules(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("theme failure handling", () => { + it("preserves exact storage causes and operation context", async () => { + const readCause = new Error("storage read blocked"); + const writeCause = new Error("storage quota exceeded"); + vi.stubGlobal("window", { + localStorage: createStorage({ + getItem: () => { + throw readCause; + }, + setItem: () => { + throw writeCause; + }, + }), + }); + + const { readThemePreference, ThemeStorageError, writeThemePreference } = + await import("./useTheme"); + + try { + readThemePreference(); + expect.unreachable("expected the theme read to fail"); + } catch (error) { + expect(error).toBeInstanceOf(ThemeStorageError); + expect(error).toMatchObject({ + operation: "read", + storageKey: "t3code:theme", + cause: readCause, + }); + } + + try { + writeThemePreference("dark"); + expect.unreachable("expected the theme write to fail"); + } catch (error) { + expect(error).toBeInstanceOf(ThemeStorageError); + expect(error).toMatchObject({ + operation: "write", + storageKey: "t3code:theme", + theme: "dark", + cause: writeCause, + }); + } + }); + + it("falls back during initial theme application and logs only safe attributes", async () => { + const cause = new Error("private browsing storage failure"); + const errorLog = vi.spyOn(console, "error").mockImplementation(() => {}); + vi.stubGlobal("window", { + localStorage: createStorage({ + getItem: () => { + throw cause; + }, + }), + matchMedia: () => ({ matches: false }), + }); + vi.stubGlobal("document", { + documentElement: { + classList: { toggle: vi.fn() }, + }, + }); + + await expect(import("./useTheme")).resolves.toBeDefined(); + + expect(errorLog).toHaveBeenCalledWith( + "Failed to read theme preference for t3code:theme.", + expect.objectContaining({ + operation: "read", + storageKey: "t3code:theme", + errorTag: "ThemeStorageError", + }), + ); + const attributes = errorLog.mock.calls[0]?.[1]; + expect(attributes).not.toHaveProperty("cause"); + expect(JSON.stringify(attributes)).not.toContain(cause.message); + }); + + it("retries a failed storage read only after a relevant storage event", async () => { + const cause = new Error("persistent storage failure"); + const getItem = vi.fn(() => { + throw cause; + }); + const errorLog = vi.spyOn(console, "error").mockImplementation(() => {}); + let readSnapshot: (() => unknown) | undefined; + let subscribeToTheme: ((listener: () => void) => () => void) | undefined; + let storageHandler: ((event: StorageEvent) => void) | undefined; + vi.doMock("react", () => ({ + useCallback: (callback: A) => callback, + useEffect: () => undefined, + useSyncExternalStore: ( + subscribe: (listener: () => void) => () => void, + getSnapshot: () => unknown, + ) => { + subscribeToTheme = subscribe; + readSnapshot = getSnapshot; + return getSnapshot(); + }, + })); + vi.stubGlobal("window", { + addEventListener: (type: string, listener: (event: StorageEvent) => void) => { + if (type === "storage") storageHandler = listener; + }, + localStorage: createStorage({ getItem }), + matchMedia: () => ({ + matches: false, + addEventListener: () => undefined, + removeEventListener: () => undefined, + }), + removeEventListener: () => undefined, + }); + + const { useTheme } = await import("./useTheme"); + useTheme(); + readSnapshot?.(); + readSnapshot?.(); + + expect(getItem).toHaveBeenCalledTimes(1); + expect(errorLog).toHaveBeenCalledTimes(1); + + const unsubscribe = subscribeToTheme?.(() => undefined); + storageHandler?.({ key: "t3code:theme" } as StorageEvent); + readSnapshot?.(); + + expect(getItem).toHaveBeenCalledTimes(2); + expect(errorLog).toHaveBeenCalledTimes(2); + unsubscribe?.(); + }); + + it("logs palette write failures without throwing from setPalette", async () => { + const writeCause = new Error("palette storage quota exceeded"); + const errorLog = vi.spyOn(console, "error").mockImplementation(() => {}); + const store = new Map(); + let readSnapshot: (() => unknown) | undefined; + vi.doMock("react", () => ({ + useCallback: (callback: A) => callback, + useEffect: () => undefined, + useSyncExternalStore: ( + _subscribe: (listener: () => void) => () => void, + getSnapshot: () => unknown, + ) => { + readSnapshot = getSnapshot; + return getSnapshot(); + }, + })); + vi.stubGlobal("window", { + addEventListener: () => undefined, + localStorage: createStorage({ + setItem: (key, value) => { + if (key === "t3code:theme-palette") { + throw writeCause; + } + store.set(key, value); + }, + getItem: (key) => store.get(key) ?? null, + }), + matchMedia: () => ({ + matches: false, + addEventListener: () => undefined, + removeEventListener: () => undefined, + }), + removeEventListener: () => undefined, + }); + vi.stubGlobal("document", { + documentElement: { + classList: { toggle: vi.fn() }, + dataset: {}, + offsetHeight: 0, + }, + body: { style: {} }, + head: { append: vi.fn() }, + querySelector: () => null, + }); + vi.stubGlobal("getComputedStyle", () => ({ backgroundColor: "rgb(0, 0, 0)" })); + + const { useTheme } = await import("./useTheme"); + const { setPalette } = useTheme(); + setPalette("catppuccin"); + readSnapshot?.(); + + expect(errorLog).toHaveBeenCalledWith( + "Failed to write theme preference for t3code:theme-palette.", + expect.objectContaining({ + operation: "write", + storageKey: "t3code:theme-palette", + errorTag: "ThemeStorageError", + }), + ); + const attributes = errorLog.mock.calls[0]?.[1]; + expect(attributes).not.toHaveProperty("cause"); + expect(JSON.stringify(attributes)).not.toContain(writeCause.message); + }); + + it("preserves desktop sync causes and retries after a failed cosmetic sync", async () => { + const cause = new Error("desktop IPC unavailable"); + const errorLog = vi.spyOn(console, "error").mockImplementation(() => {}); + const setTheme = vi.fn().mockRejectedValue(cause); + vi.stubGlobal("window", { desktopBridge: { setTheme } }); + + const { DesktopThemeSyncError, syncDesktopTheme, syncDesktopThemePreference } = + await import("./useTheme"); + + const error = await syncDesktopThemePreference({ setTheme }, "dark").then( + () => undefined, + (failure: unknown) => failure, + ); + expect(error).toBeInstanceOf(DesktopThemeSyncError); + expect(error).toMatchObject({ theme: "dark", cause }); + + setTheme.mockClear(); + syncDesktopTheme("dark"); + await Promise.resolve(); + await Promise.resolve(); + syncDesktopTheme("dark"); + await Promise.resolve(); + await Promise.resolve(); + + expect(setTheme).toHaveBeenCalledTimes(2); + expect(errorLog).toHaveBeenCalledWith( + "Failed to sync the dark theme to the desktop shell.", + expect.objectContaining({ + theme: "dark", + errorTag: "DesktopThemeSyncError", + }), + ); + for (const [, attributes] of errorLog.mock.calls) { + expect(attributes).not.toHaveProperty("cause"); + expect(JSON.stringify(attributes)).not.toContain(cause.message); + } + }); +}); diff --git a/apps/web/src/hooks/useTheme.ts b/apps/web/src/hooks/useTheme.ts index df028407a6d..46e250d7aaa 100644 --- a/apps/web/src/hooks/useTheme.ts +++ b/apps/web/src/hooks/useTheme.ts @@ -1,13 +1,18 @@ +import type { DesktopBridge } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; import { useCallback, useEffect, useSyncExternalStore } from "react"; import { DEFAULT_THEME_PALETTE, isThemePalette, type ThemePalette } from "../themePalettes"; -type Theme = "light" | "dark" | "system"; +const ThemePreference = Schema.Literals(["light", "dark", "system"]); +type Theme = typeof ThemePreference.Type; type ThemeSnapshot = { palette: ThemePalette; theme: Theme; systemDark: boolean; }; +type DesktopThemeBridge = Pick; + const STORAGE_KEY = "t3code:theme"; const PALETTE_STORAGE_KEY = "t3code:theme-palette"; const MEDIA_QUERY = "(prefers-color-scheme: dark)"; @@ -19,32 +24,147 @@ const DEFAULT_THEME_SNAPSHOT: ThemeSnapshot = { const THEME_COLOR_META_NAME = "theme-color"; const DYNAMIC_THEME_COLOR_SELECTOR = `meta[name="${THEME_COLOR_META_NAME}"][data-dynamic-theme-color="true"]`; +export class ThemeStorageError extends Schema.TaggedErrorClass()( + "ThemeStorageError", + { + operation: Schema.Literals(["read", "write"]), + storageKey: Schema.String, + theme: Schema.optional(ThemePreference), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to ${this.operation} theme preference for ${this.storageKey}.`; + } +} + +export const isThemeStorageError = Schema.is(ThemeStorageError); + +export class DesktopThemeSyncError extends Schema.TaggedErrorClass()( + "DesktopThemeSyncError", + { + theme: ThemePreference, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to sync the ${this.theme} theme to the desktop shell.`; + } +} + +export const isDesktopThemeSyncError = Schema.is(DesktopThemeSyncError); + let listeners: Array<() => void> = []; let lastSnapshot: ThemeSnapshot | null = null; let lastDesktopTheme: Theme | null = null; +let lastAppliedTheme: ThemeSnapshot | null = null; +let themeStorageReadFailure: ThemeStorageError | null = null; function emitChange() { for (const listener of listeners) listener(); } function hasThemeStorage() { - return typeof window !== "undefined" && typeof localStorage !== "undefined"; + return typeof window !== "undefined" && typeof window.localStorage !== "undefined"; } function getSystemDark() { - return typeof window !== "undefined" && window.matchMedia(MEDIA_QUERY).matches; + return ( + typeof window !== "undefined" && + typeof window.matchMedia === "function" && + window.matchMedia(MEDIA_QUERY).matches + ); } -function getStored(): Theme { - if (!hasThemeStorage()) return DEFAULT_THEME_SNAPSHOT.theme; - const raw = localStorage.getItem(STORAGE_KEY); +function themeErrorLogAttributes(error: ThemeStorageError | DesktopThemeSyncError) { + if (isThemeStorageError(error)) { + return { + errorTag: error._tag, + operation: error.operation, + storageKey: error.storageKey, + }; + } + return { + errorTag: error._tag, + theme: error.theme, + }; +} + +export function readThemePreference(): Theme { + if (typeof window === "undefined") return DEFAULT_THEME_SNAPSHOT.theme; + let raw: string | null; + try { + raw = window.localStorage.getItem(STORAGE_KEY); + } catch (cause) { + throw new ThemeStorageError({ + operation: "read", + storageKey: STORAGE_KEY, + cause, + }); + } if (raw === "light" || raw === "dark" || raw === "system") return raw; return DEFAULT_THEME_SNAPSHOT.theme; } +export function writeThemePreference(theme: Theme): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(STORAGE_KEY, theme); + themeStorageReadFailure = null; + } catch (cause) { + throw new ThemeStorageError({ + operation: "write", + storageKey: STORAGE_KEY, + theme, + cause, + }); + } +} + +function writeThemePalette(palette: ThemePalette): void { + if (typeof window === "undefined") return; + try { + window.localStorage.setItem(PALETTE_STORAGE_KEY, palette); + themeStorageReadFailure = null; + } catch (cause) { + throw new ThemeStorageError({ + operation: "write", + storageKey: PALETTE_STORAGE_KEY, + cause, + }); + } +} + +function getStored(): Theme { + if (!hasThemeStorage()) return DEFAULT_THEME_SNAPSHOT.theme; + if (themeStorageReadFailure !== null) { + return DEFAULT_THEME_SNAPSHOT.theme; + } + try { + return readThemePreference(); + } catch (cause) { + const error = isThemeStorageError(cause) + ? cause + : new ThemeStorageError({ + operation: "read", + storageKey: STORAGE_KEY, + cause, + }); + themeStorageReadFailure = error; + console.error(error.message, themeErrorLogAttributes(error)); + return DEFAULT_THEME_SNAPSHOT.theme; + } +} + function getStoredPalette(): ThemePalette { if (!hasThemeStorage()) return DEFAULT_THEME_SNAPSHOT.palette; - const raw = localStorage.getItem(PALETTE_STORAGE_KEY); + if (themeStorageReadFailure !== null) return DEFAULT_THEME_SNAPSHOT.palette; + let raw: string | null; + try { + raw = window.localStorage.getItem(PALETTE_STORAGE_KEY); + } catch { + return DEFAULT_THEME_SNAPSHOT.palette; + } return isThemePalette(raw) ? raw : DEFAULT_THEME_SNAPSHOT.palette; } @@ -102,12 +222,23 @@ function applyTheme(theme: Theme, palette: ThemePalette, suppressTransitions = f if (typeof document === "undefined" || typeof window === "undefined") return; const root = document.documentElement; if (!root || !root.dataset) return; + const systemDark = theme === "system" ? getSystemDark() : false; + if ( + lastAppliedTheme?.theme === theme && + lastAppliedTheme.palette === palette && + lastAppliedTheme.systemDark === systemDark + ) { + syncDesktopTheme(theme); + return; + } + if (suppressTransitions) { root.classList.add("no-transitions"); } - const isDark = theme === "dark" || (theme === "system" && getSystemDark()); + const isDark = theme === "dark" || (theme === "system" && systemDark); root.classList.toggle("dark", isDark); root.dataset.themePalette = palette; + lastAppliedTheme = { palette, theme, systemDark }; syncBrowserChromeTheme(); syncDesktopTheme(theme); if (suppressTransitions) { @@ -120,7 +251,20 @@ function applyTheme(theme: Theme, palette: ThemePalette, suppressTransitions = f } } -function syncDesktopTheme(theme: Theme) { +export async function syncDesktopThemePreference( + bridge: DesktopThemeBridge, + theme: Theme, +): Promise { + try { + await bridge.setTheme(theme); + lastDesktopTheme = theme; + } catch (cause) { + lastDesktopTheme = null; + throw new DesktopThemeSyncError({ theme, cause }); + } +} + +export function syncDesktopTheme(theme: Theme) { if (typeof window === "undefined") return; const bridge = window.desktopBridge; if (!bridge || typeof bridge.setTheme !== "function" || lastDesktopTheme === theme) { @@ -128,10 +272,14 @@ function syncDesktopTheme(theme: Theme) { } lastDesktopTheme = theme; - void bridge.setTheme(theme).catch(() => { + void syncDesktopThemePreference(bridge, theme).catch((error) => { if (lastDesktopTheme === theme) { lastDesktopTheme = null; } + const structuredError = isDesktopThemeSyncError(error) + ? error + : new DesktopThemeSyncError({ theme, cause: error }); + console.error(structuredError.message, themeErrorLogAttributes(structuredError)); }); } @@ -178,6 +326,9 @@ function subscribe(listener: () => void): () => void { // Listen for storage changes from other tabs const handleStorage = (e: StorageEvent) => { if (e.key === STORAGE_KEY || e.key === PALETTE_STORAGE_KEY) { + if (e.key === STORAGE_KEY) { + themeStorageReadFailure = null; + } applyTheme(getStored(), getStoredPalette(), true); emitChange(); } @@ -201,7 +352,14 @@ export function useTheme() { const setTheme = useCallback((next: Theme) => { if (!hasThemeStorage()) return; - localStorage.setItem(STORAGE_KEY, next); + try { + writeThemePreference(next); + } catch (error) { + if (isThemeStorageError(error)) { + console.error(error.message, themeErrorLogAttributes(error)); + } + return; + } applyTheme(next, getStoredPalette(), true); emitChange(); }, []); @@ -209,7 +367,14 @@ export function useTheme() { const setPalette = useCallback( (next: ThemePalette) => { if (!hasThemeStorage()) return; - localStorage.setItem(PALETTE_STORAGE_KEY, next); + try { + writeThemePalette(next); + } catch (error) { + if (isThemeStorageError(error)) { + console.error(error.message, themeErrorLogAttributes(error)); + } + return; + } applyTheme(theme, next, true); emitChange(); }, diff --git a/apps/web/src/hooks/useThreadActions.test.ts b/apps/web/src/hooks/useThreadActions.test.ts new file mode 100644 index 00000000000..c5385211591 --- /dev/null +++ b/apps/web/src/hooks/useThreadActions.test.ts @@ -0,0 +1,19 @@ +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { ThreadArchiveBlockedError } from "./useThreadActions"; + +describe("ThreadArchiveBlockedError", () => { + it("keeps the blocked thread context with the fixed message", () => { + const error = new ThreadArchiveBlockedError({ + environmentId: EnvironmentId.make("environment-1"), + threadId: ThreadId.make("thread-1"), + }); + + expect(error).toMatchObject({ + environmentId: "environment-1", + threadId: "thread-1", + }); + expect(error.message).toBe("Cannot archive a running thread."); + }); +}); diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 4b9cff0eba2..0fac96882aa 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -1,5 +1,6 @@ import { parseScopedThreadKey, scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime"; -import { type ScopedThreadRef, ThreadId } from "@t3tools/contracts"; +import { EnvironmentId, type ScopedThreadRef, ThreadId } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; import { useRouter } from "@tanstack/react-router"; import { useCallback, useRef } from "react"; @@ -24,6 +25,18 @@ import { stackedThreadToast, toastManager } from "../components/ui/toast"; import { useSettings } from "./useSettings"; import { isManagedSectionWorkspace } from "../sectionWorkspacePolicy"; +export class ThreadArchiveBlockedError extends Schema.TaggedErrorClass()( + "ThreadArchiveBlockedError", + { + environmentId: EnvironmentId, + threadId: ThreadId, + }, +) { + override get message(): string { + return "Cannot archive a running thread."; + } +} + export function useThreadActions() { const sidebarThreadSortOrder = useSettings((settings) => settings.sidebarThreadSortOrder); const confirmThreadDelete = useSettings((settings) => settings.confirmThreadDelete); @@ -65,7 +78,10 @@ export function useThreadActions() { if (!resolved) return; const { thread, threadRef } = resolved; if (thread.session?.status === "running" && thread.session.activeTurnId != null) { - throw new Error("Cannot archive a running thread."); + throw new ThreadArchiveBlockedError({ + environmentId: threadRef.environmentId, + threadId: threadRef.threadId, + }); } const currentRouteThreadRef = getCurrentRouteThreadRef(); diff --git a/apps/web/src/lib/openPullRequestLink.test.ts b/apps/web/src/lib/openPullRequestLink.test.ts new file mode 100644 index 00000000000..756e1ed6ad9 --- /dev/null +++ b/apps/web/src/lib/openPullRequestLink.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { openPullRequestLink, PullRequestLinkOpenError } from "./openPullRequestLink"; + +describe("openPullRequestLink", () => { + it("opens the requested pull request URL", async () => { + const openExternal = vi.fn(async () => undefined); + const targetUrl = "https://github.com/pingdotgg/t3code/pull/123"; + + await openPullRequestLink({ openExternal }, targetUrl); + + expect(openExternal).toHaveBeenCalledExactlyOnceWith(targetUrl); + }); + + it("reports bridge failures with a safe target origin", async () => { + const cause = new Error("desktop shell unavailable"); + const targetUrl = "https://github.com/pingdotgg/t3code/pull/123?token=secret"; + const openExternal = vi.fn(async () => Promise.reject(cause)); + + const result = openPullRequestLink({ openExternal }, targetUrl); + + await expect(result).rejects.toEqual( + new PullRequestLinkOpenError({ + targetOrigin: "https://github.com", + cause, + }), + ); + await expect(result).rejects.not.toHaveProperty("message", expect.stringContaining("secret")); + }); +}); diff --git a/apps/web/src/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts new file mode 100644 index 00000000000..acd3c5a062b --- /dev/null +++ b/apps/web/src/lib/openPullRequestLink.ts @@ -0,0 +1,75 @@ +import type { LocalApi } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import { type MouseEvent, useCallback } from "react"; + +import { stackedThreadToast, toastManager } from "../components/ui/toast"; +import { readLocalApi } from "../localApi"; + +export class PullRequestLinkOpenError extends Schema.TaggedErrorClass()( + "PullRequestLinkOpenError", + { + targetOrigin: Schema.NullOr(Schema.String), + cause: Schema.Defect(), + }, +) { + static fromCause(targetUrl: string, cause: unknown): PullRequestLinkOpenError { + let targetOrigin: string | null = null; + try { + targetOrigin = new URL(targetUrl).origin; + } catch { + // Keep malformed URLs out of diagnostics while preserving the open failure below. + } + return new PullRequestLinkOpenError({ targetOrigin, cause }); + } + + override get message(): string { + return this.targetOrigin === null + ? "Unable to open pull request link." + : `Unable to open pull request link at ${this.targetOrigin}.`; + } +} + +export async function openPullRequestLink( + shell: Pick, + targetUrl: string, +): Promise { + try { + await shell.openExternal(targetUrl); + } catch (cause) { + throw PullRequestLinkOpenError.fromCause(targetUrl, cause); + } +} + +/** + * Returns a click handler that opens a pull request URL in the system browser. + * + * Stops event propagation/default so activating the link does not also trigger + * an enclosing row or trigger (e.g. opening the branch dropdown), and surfaces a + * toast when the local API is unavailable or the open fails. + */ +export function useOpenPrLink() { + return useCallback((event: MouseEvent, prUrl: string) => { + event.preventDefault(); + event.stopPropagation(); + + const api = readLocalApi(); + if (!api) { + toastManager.add({ + type: "error", + title: "Link opening is unavailable.", + }); + return; + } + + void openPullRequestLink(api.shell, prUrl).catch((error) => { + console.error(error); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open pull request link", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }); + }, []); +} diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index e8e9a4ecc1a..31b76c2be27 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -352,8 +352,25 @@ export class GitManagerError extends Schema.TaggedErrorClass()( } } +export class GitPullRequestMaterializationError extends Schema.TaggedErrorClass()( + "GitPullRequestMaterializationError", + { + cwd: TrimmedNonEmptyStringSchema, + pullRequestNumber: PositiveInt, + headRepository: Schema.NullOr(TrimmedNonEmptyStringSchema), + headBranch: TrimmedNonEmptyStringSchema, + localBranch: TrimmedNonEmptyStringSchema, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to materialize pull request #${this.pullRequestNumber} branch ${this.headBranch} as ${this.localBranch}.`; + } +} + export const GitManagerServiceError = Schema.Union([ GitManagerError, + GitPullRequestMaterializationError, GitCommandError, SourceControlProviderError, TextGenerationError,