From e5542b7a880aa40ab5a4b5ad8012822c11643471 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 25 Feb 2026 22:50:17 -0800 Subject: [PATCH] delegate trimming to schema --- .../checkpointing/Layers/CheckpointStore.ts | 24 ++- apps/server/src/checkpointing/Utils.ts | 12 +- apps/server/src/codexAppServerManager.ts | 4 +- apps/server/src/git/Layers/GitService.ts | 2 - apps/server/src/keybindings.ts | 9 +- .../orchestration/Layers/CheckpointReactor.ts | 17 +- .../Layers/ProviderCommandReactor.ts | 17 +- .../Layers/ProviderRuntimeIngestion.ts | 3 +- .../src/provider/Layers/ProviderService.ts | 3 +- .../Layers/ProviderSessionDirectory.test.ts | 6 +- .../Layers/ProviderSessionDirectory.ts | 187 +++++++----------- packages/contracts/src/baseSchemas.ts | 42 ++++ packages/contracts/src/editor.ts | 3 +- packages/contracts/src/git.ts | 95 ++++----- packages/contracts/src/index.ts | 1 + packages/contracts/src/keybindings.ts | 5 +- packages/contracts/src/orchestration.test.ts | 42 +++- packages/contracts/src/orchestration.ts | 159 +++++++-------- packages/contracts/src/project.ts | 9 +- packages/contracts/src/provider.ts | 18 +- packages/contracts/src/providerRuntime.ts | 28 +-- packages/contracts/src/server.ts | 3 +- packages/contracts/src/terminal.ts | 12 +- packages/contracts/src/ws.test.ts | 19 ++ packages/contracts/src/ws.ts | 11 +- 25 files changed, 369 insertions(+), 362 deletions(-) create mode 100644 packages/contracts/src/baseSchemas.ts diff --git a/apps/server/src/checkpointing/Layers/CheckpointStore.ts b/apps/server/src/checkpointing/Layers/CheckpointStore.ts index 09bff7e5f8392b13ba507e15144c350d9e6efeb9..fac183ff7a1fab4364a0399272caa349955819e3 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointStore.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointStore.ts @@ -78,7 +78,7 @@ const makeCheckpointStore = Effect.gen(function* () { git .execute({ operation: "CheckpointStore.isGitRepository", - cwd: cwd.trim(), + cwd, args: ["rev-parse", "--is-inside-work-tree"], allowNonZeroExit: true, }) @@ -90,8 +90,6 @@ const makeCheckpointStore = Effect.gen(function* () { const captureCheckpoint: CheckpointStoreShape["captureCheckpoint"] = (input) => Effect.gen(function* () { const operation = "CheckpointStore.captureCheckpoint"; - const normalizedCwd = input.cwd.trim(); - const checkpointRef = input.checkpointRef.trim(); yield* Effect.acquireUseRelease( fs.makeTempDirectory({ prefix: "t3-fs-checkpoint-" }), @@ -107,11 +105,11 @@ const makeCheckpointStore = Effect.gen(function* () { GIT_COMMITTER_EMAIL: "t3code@users.noreply.github.com", }; - const headExists = yield* hasHeadCommit(normalizedCwd); + const headExists = yield* hasHeadCommit(input.cwd); if (headExists) { yield* git.execute({ operation, - cwd: normalizedCwd, + cwd: input.cwd, args: ["read-tree", "HEAD"], env: commitEnv, }); @@ -119,14 +117,14 @@ const makeCheckpointStore = Effect.gen(function* () { yield* git.execute({ operation, - cwd: normalizedCwd, + cwd: input.cwd, args: ["add", "-A", "--", "."], env: commitEnv, }); const writeTreeResult = yield* git.execute({ operation, - cwd: normalizedCwd, + cwd: input.cwd, args: ["write-tree"], env: commitEnv, }); @@ -135,15 +133,15 @@ const makeCheckpointStore = Effect.gen(function* () { return yield* new GitCommandError({ operation, command: "git write-tree", - cwd: normalizedCwd, + cwd: input.cwd, detail: "git write-tree returned an empty tree oid.", }); } - const message = `t3 checkpoint ref=${checkpointRef}`; + const message = `t3 checkpoint ref=${input.checkpointRef}`; const commitTreeResult = yield* git.execute({ operation, - cwd: normalizedCwd, + cwd: input.cwd, args: ["commit-tree", treeOid, "-m", message], env: commitEnv, }); @@ -152,15 +150,15 @@ const makeCheckpointStore = Effect.gen(function* () { return yield* new GitCommandError({ operation, command: "git commit-tree", - cwd: normalizedCwd, + cwd: input.cwd, detail: "git commit-tree returned an empty commit oid.", }); } yield* git.execute({ operation, - cwd: normalizedCwd, - args: ["update-ref", checkpointRef, commitOid], + cwd: input.cwd, + args: ["update-ref", input.checkpointRef, commitOid], }); }), (tempDir) => fs.remove(tempDir, { recursive: true }), diff --git a/apps/server/src/checkpointing/Utils.ts b/apps/server/src/checkpointing/Utils.ts index 1715f221127e56684b3a31a1f657cc5047f17e3f..3cd92f8510d418c3cf5a997e14b951a82843bd23 100644 --- a/apps/server/src/checkpointing/Utils.ts +++ b/apps/server/src/checkpointing/Utils.ts @@ -9,11 +9,6 @@ export function checkpointRefForThreadTurn(threadId: ThreadId, turnCount: number ); } -function trimToNonEmpty(value: string | null | undefined): string | undefined { - const normalized = value?.trim(); - return normalized && normalized.length > 0 ? normalized : undefined; -} - export function resolveThreadWorkspaceCwd(input: { readonly thread: { readonly projectId: ProjectId; @@ -24,13 +19,10 @@ export function resolveThreadWorkspaceCwd(input: { readonly workspaceRoot: string; }>; }): string | undefined { - const worktreeCwd = trimToNonEmpty(input.thread.worktreePath); + const worktreeCwd = input.thread.worktreePath ?? undefined; if (worktreeCwd) { return worktreeCwd; } - const workspaceRoot = input.projects.find( - (project) => project.id === input.thread.projectId, - )?.workspaceRoot; - return trimToNonEmpty(workspaceRoot); + return input.projects.find((project) => project.id === input.thread.projectId)?.workspaceRoot; } diff --git a/apps/server/src/codexAppServerManager.ts b/apps/server/src/codexAppServerManager.ts index b2ab09d6186c5a4bf441e2e8cac2c72418a45909..e94e8d650e83cc2a6c0c24812c93b05836122390 100644 --- a/apps/server/src/codexAppServerManager.ts +++ b/apps/server/src/codexAppServerManager.ts @@ -169,8 +169,8 @@ export class CodexAppServerManager extends EventEmitter 0) { - const whenAst = parseKeybindingWhenExpression(when); + if (rule.when !== undefined) { + const whenAst = parseKeybindingWhenExpression(rule.when); if (!whenAst) return null; return { command: rule.command, diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index 2dd3ee0aafa602094b4d5f64599f467b0110a113..cd4eba3d2c61b8646f936f07a94a5222ebea5106 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -33,13 +33,7 @@ type ReactorInput = }; function toTurnId(value: string | undefined): TurnId | null { - const normalized = value?.trim(); - return normalized && normalized.length > 0 ? TurnId.makeUnsafe(normalized) : null; -} - -function trimToNonEmpty(value: string | null | undefined): string | undefined { - const normalized = value?.trim(); - return normalized && normalized.length > 0 ? normalized : undefined; + return value === undefined ? null : TurnId.makeUnsafe(value); } function checkpointStatusFromRuntime(status: string | undefined): "ready" | "missing" | "error" { @@ -129,18 +123,17 @@ const make = Effect.gen(function* () { const readModel = yield* orchestrationEngine.getReadModel(); const thread = readModel.threads.find((entry) => entry.id === threadId); const projectedSessionId = thread?.session?.providerSessionId ?? null; - const projectedProviderThreadId = trimToNonEmpty(thread?.session?.providerThreadId); + const projectedProviderThreadId = thread?.session?.providerThreadId ?? undefined; const sessions = yield* providerService.listSessions(); const findSessionWithCwd = ( session: (typeof sessions)[number] | undefined, ): Option.Option<{ readonly sessionId: ProviderSessionId; readonly cwd: string }> => { - const cwd = session?.cwd?.trim(); - if (!session || !cwd) { + if (!session?.cwd) { return Option.none(); } - return Option.some({ sessionId: session.sessionId, cwd }); + return Option.some({ sessionId: session.sessionId, cwd: session.cwd }); }; if (projectedSessionId !== null) { @@ -153,7 +146,7 @@ const make = Effect.gen(function* () { if (projectedProviderThreadId) { const matchedSession = sessions.find( - (session) => session.threadId?.trim() === projectedProviderThreadId, + (session) => session.threadId === projectedProviderThreadId, ); const fromProviderThread = findSessionWithCwd(matchedSession); if (Option.isSome(fromProviderThread)) { diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 90baa6c5f06c8dc32111a0f48bfdcf7dbc24aec9..68fcc6644fa0e3782a0c9f24d84ebfc2569e8ee3 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -28,7 +28,7 @@ type ProviderIntentEvent = Extract< } >; -function trimToNonEmptyOrUndefined(value: string | undefined): string | undefined { +function toNonEmptyProviderInput(value: string | undefined): string | undefined { const normalized = value?.trim(); return normalized && normalized.length > 0 ? normalized : undefined; } @@ -43,15 +43,12 @@ function resolveEffectiveThreadCwd(input: { readonly workspaceRoot: string; }>; }): string | undefined { - const worktreeCwd = trimToNonEmptyOrUndefined(input.thread.worktreePath ?? undefined); + const worktreeCwd = input.thread.worktreePath ?? undefined; if (worktreeCwd) { return worktreeCwd; } - const workspaceRoot = input.projects.find( - (project) => project.id === input.thread.projectId, - )?.workspaceRoot; - return trimToNonEmptyOrUndefined(workspaceRoot); + return input.projects.find((project) => project.id === input.thread.projectId)?.workspaceRoot; } function mapProviderSessionStatusToOrchestrationStatus( @@ -158,7 +155,7 @@ const make = Effect.gen(function* () { const startedSession = yield* providerService.startSession({ ...(preferredProvider ? { provider: preferredProvider } : {}), ...(effectiveCwd ? { cwd: effectiveCwd } : {}), - ...(trimToNonEmptyOrUndefined(thread.model) ? { model: thread.model } : {}), + ...(thread.model ? { model: thread.model } : {}), }); yield* setThreadSession({ @@ -193,15 +190,15 @@ const make = Effect.gen(function* () { return; } const sessionId = yield* ensureSessionForThread(input.threadId, input.createdAt); - const normalizedInput = trimToNonEmptyOrUndefined(input.messageText); + const normalizedInput = toNonEmptyProviderInput(input.messageText); const normalizedAttachments = input.attachments ?? []; yield* providerService.sendTurn({ sessionId, ...(normalizedInput ? { input: normalizedInput } : {}), ...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}), - ...(trimToNonEmptyOrUndefined(input.model) ? { model: input.model } : {}), - ...(trimToNonEmptyOrUndefined(input.effort) ? { effort: input.effort } : {}), + ...(input.model !== undefined ? { model: input.model } : {}), + ...(input.effort !== undefined ? { effort: input.effort } : {}), }); }); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 8476a4751ed4a8092daa0a3c0d415eeed1c2d32e..7715bbfbfa2f80b5f15644f3d10f081e6848b31b 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -21,8 +21,7 @@ const providerCommandId = (event: ProviderRuntimeEvent, tag: string): CommandId CommandId.makeUnsafe(`provider:${event.eventId}:${tag}:${crypto.randomUUID()}`); function toTurnId(value: string | undefined): TurnId | undefined { - const normalized = value?.trim(); - return normalized?.length ? TurnId.makeUnsafe(normalized) : undefined; + return value === undefined ? undefined : TurnId.makeUnsafe(value); } function truncateDetail(value: string, limit = 180): string { diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 4cd5a1621b3af287b66b123307e5a2dd97d54191..d01ee4404b01cc010d59540b1df4c7220bd31f9e 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -242,8 +242,9 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) => Effect.gen(function* () { const adapter = yield* registry.getByProvider(input.provider); const activeSessions = yield* adapter.listSessions(); + const expectedProviderThreadId = asProviderThreadId(input.threadId); const existing = activeSessions.find( - (session) => session.threadId?.trim() === input.threadId.trim(), + (session) => session.threadId === expectedProviderThreadId, ); if (existing) { const existingThreadId = yield* upsertSessionBinding( diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts index fc807f84af92e03250c0accf191b526452df4eb5..be1c33d38a71bd9c72ad927def2208750e5d8d4b 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts @@ -79,12 +79,12 @@ layer("ProviderSessionDirectoryLive", (it) => { assertFailure(missingProvider, new ProviderSessionNotFoundError({ sessionId: "sess-1" })); })); - it("fails upsert for empty session id", () => + it("fails upsert when thread id is unavailable", () => Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; const result = yield* Effect.result( directory.upsert({ - sessionId: sessionId(" "), + sessionId: sessionId("sess-no-thread"), provider: "codex", }), ); @@ -92,7 +92,7 @@ layer("ProviderSessionDirectoryLive", (it) => { result, new ProviderValidationError({ operation: "ProviderSessionDirectory.upsert", - issue: "sessionId must be a non-empty string.", + issue: "threadId must be a non-empty string.", }), ); })); diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index 6142627784829c59bd54c6d918b927041138e937..dabfe9384f6334860f1cd4e030e7eb565f119cd7 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -1,9 +1,4 @@ -import { - ProviderSessionId, - ProviderThreadId, - ThreadId, - type ProviderKind, -} from "@t3tools/contracts"; +import { ProviderSessionId, type ProviderKind } from "@t3tools/contracts"; import { Effect, Layer, Option } from "effect"; import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts"; @@ -27,31 +22,6 @@ function toPersistenceError(operation: string) { }); } -function normalizeBinding(binding: ProviderSessionBinding): ProviderSessionBinding { - const sessionId = binding.sessionId.trim(); - const threadId = binding.threadId?.trim(); - const adapterKey = binding.adapterKey?.trim(); - const providerThreadId = - binding.providerThreadId === null ? null : binding.providerThreadId?.trim(); - - return { - sessionId: ProviderSessionId.makeUnsafe(sessionId), - provider: binding.provider, - ...(adapterKey !== undefined && adapterKey.length > 0 ? { adapterKey } : {}), - ...(threadId !== undefined && threadId.length > 0 - ? { threadId: ThreadId.makeUnsafe(threadId) } - : {}), - ...(providerThreadId === null - ? { providerThreadId: null } - : providerThreadId !== undefined && providerThreadId.length > 0 - ? { providerThreadId: ProviderThreadId.makeUnsafe(providerThreadId) } - : {}), - ...(binding.status !== undefined ? { status: binding.status } : {}), - ...(binding.resumeCursor !== undefined ? { resumeCursor: binding.resumeCursor } : {}), - ...(binding.runtimePayload !== undefined ? { runtimePayload: binding.runtimePayload } : {}), - }; -} - function decodeProviderKind( providerName: string, operation: string, @@ -90,54 +60,6 @@ function mergeRuntimePayload( const makeProviderSessionDirectory = Effect.gen(function* () { const repository = yield* ProviderSessionRuntimeRepository; - const persistUpsert = (binding: ProviderSessionBinding) => - Effect.gen(function* () { - const existing = yield* repository - .getBySessionId({ - providerSessionId: binding.sessionId, - }) - .pipe( - Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:getBySessionId")), - ); - - const existingRuntime = Option.getOrUndefined(existing); - const resolvedThreadId = binding.threadId ?? existingRuntime?.threadId; - if (!resolvedThreadId) { - return yield* new ProviderValidationError({ - operation: "ProviderSessionDirectory.upsert", - issue: "threadId must be a non-empty string.", - }); - } - - const now = new Date().toISOString(); - yield* repository - .upsert({ - providerSessionId: binding.sessionId, - threadId: resolvedThreadId, - providerName: binding.provider, - adapterKey: binding.adapterKey ?? existingRuntime?.adapterKey ?? binding.provider, - providerThreadId: binding.providerThreadId ?? existingRuntime?.providerThreadId ?? null, - status: binding.status ?? existingRuntime?.status ?? "running", - lastSeenAt: now, - resumeCursor: - binding.resumeCursor !== undefined - ? binding.resumeCursor - : (existingRuntime?.resumeCursor ?? null), - runtimePayload: mergeRuntimePayload( - existingRuntime?.runtimePayload ?? null, - binding.runtimePayload, - ), - }) - .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:upsert"))); - }); - - const persistDelete = (sessionId: ProviderSessionId) => - repository - .deleteBySessionId({ providerSessionId: sessionId }) - .pipe( - Effect.mapError(toPersistenceError("ProviderSessionDirectory.remove:deleteBySessionId")), - ); - const getBinding = (sessionId: ProviderSessionId) => repository.getBySessionId({ providerSessionId: sessionId }).pipe( Effect.mapError(toPersistenceError("ProviderSessionDirectory.getBinding:getBySessionId")), @@ -158,7 +80,72 @@ const makeProviderSessionDirectory = Effect.gen(function* () { ), ); - const listBindings = () => + const upsert: ProviderSessionDirectoryShape["upsert"] = Effect.fn(function* (binding) { + const existing = yield* repository + .getBySessionId({ + providerSessionId: binding.sessionId, + }) + .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:getBySessionId"))); + + const existingRuntime = Option.getOrUndefined(existing); + const resolvedThreadId = binding.threadId ?? existingRuntime?.threadId; + if (!resolvedThreadId) { + return yield* new ProviderValidationError({ + operation: "ProviderSessionDirectory.upsert", + issue: "threadId must be a non-empty string.", + }); + } + + const now = new Date().toISOString(); + yield* repository + .upsert({ + providerSessionId: binding.sessionId, + threadId: resolvedThreadId, + providerName: binding.provider, + adapterKey: binding.adapterKey ?? existingRuntime?.adapterKey ?? binding.provider, + providerThreadId: binding.providerThreadId ?? existingRuntime?.providerThreadId ?? null, + status: binding.status ?? existingRuntime?.status ?? "running", + lastSeenAt: now, + resumeCursor: + binding.resumeCursor !== undefined + ? binding.resumeCursor + : (existingRuntime?.resumeCursor ?? null), + runtimePayload: mergeRuntimePayload( + existingRuntime?.runtimePayload ?? null, + binding.runtimePayload, + ), + }) + .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:upsert"))); + }); + + const getProvider: ProviderSessionDirectoryShape["getProvider"] = (sessionId) => + getBinding(sessionId).pipe( + Effect.flatMap((binding) => + Option.match(binding, { + onSome: (value) => Effect.succeed(value.provider), + onNone: () => Effect.fail(new ProviderSessionNotFoundError({ sessionId })), + }), + ), + ); + + const getThreadId: ProviderSessionDirectoryShape["getThreadId"] = (sessionId) => + getBinding(sessionId).pipe( + Effect.flatMap((binding) => + Option.match(binding, { + onSome: (value) => Effect.succeed(Option.fromNullishOr(value.threadId)), + onNone: () => Effect.fail(new ProviderSessionNotFoundError({ sessionId })), + }), + ), + ); + + const remove: ProviderSessionDirectoryShape["remove"] = (sessionId) => + repository + .deleteBySessionId({ providerSessionId: sessionId }) + .pipe( + Effect.mapError(toPersistenceError("ProviderSessionDirectory.remove:deleteBySessionId")), + ); + + const listSessionIds: ProviderSessionDirectoryShape["listSessionIds"] = () => repository.list().pipe( Effect.mapError(toPersistenceError("ProviderSessionDirectory.listSessionIds:list")), Effect.flatMap((rows) => @@ -175,47 +162,9 @@ const makeProviderSessionDirectory = Effect.gen(function* () { { concurrency: "unbounded" }, ), ), + Effect.map((bindings) => bindings.map((binding) => binding.sessionId)), ); - const upsert: ProviderSessionDirectoryShape["upsert"] = (binding) => { - const normalized = normalizeBinding(binding); - if (normalized.sessionId.length === 0) { - return Effect.fail( - new ProviderValidationError({ - operation: "ProviderSessionDirectory.upsert", - issue: "sessionId must be a non-empty string.", - }), - ); - } - - return persistUpsert(normalized); - }; - - const getProvider: ProviderSessionDirectoryShape["getProvider"] = (sessionId) => - getBinding(sessionId).pipe( - Effect.flatMap((binding) => - Option.match(binding, { - onSome: (value) => Effect.succeed(value.provider), - onNone: () => Effect.fail(new ProviderSessionNotFoundError({ sessionId })), - }), - ), - ); - - const getThreadId: ProviderSessionDirectoryShape["getThreadId"] = (sessionId) => - getBinding(sessionId).pipe( - Effect.flatMap((binding) => - Option.match(binding, { - onSome: (value) => Effect.succeed(Option.fromNullishOr(value.threadId)), - onNone: () => Effect.fail(new ProviderSessionNotFoundError({ sessionId })), - }), - ), - ); - - const remove: ProviderSessionDirectoryShape["remove"] = (sessionId) => persistDelete(sessionId); - - const listSessionIds: ProviderSessionDirectoryShape["listSessionIds"] = () => - listBindings().pipe(Effect.map((bindings) => bindings.map((binding) => binding.sessionId))); - return { upsert, getProvider, diff --git a/packages/contracts/src/baseSchemas.ts b/packages/contracts/src/baseSchemas.ts new file mode 100644 index 0000000000000000000000000000000000000000..52fa5d9e66e3071811cf790a6283660566056912 --- /dev/null +++ b/packages/contracts/src/baseSchemas.ts @@ -0,0 +1,42 @@ +import { Schema } from "effect"; + +export const TrimmedString = Schema.Trim; +export const TrimmedNonEmptyString = TrimmedString.check(Schema.isNonEmpty()); + +export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)); +export const PositiveInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)); + +export const IsoDateTime = Schema.String; +export type IsoDateTime = typeof IsoDateTime.Type; + +/** + * Construct a branded identifier. Enforces non-empty trimmed strings + */ +const makeEntityId = (brand: Brand) => + TrimmedNonEmptyString.pipe(Schema.brand(brand)); + +export const ThreadId = makeEntityId("ThreadId"); +export type ThreadId = typeof ThreadId.Type; +export const ProjectId = makeEntityId("ProjectId"); +export type ProjectId = typeof ProjectId.Type; +export const CommandId = makeEntityId("CommandId"); +export type CommandId = typeof CommandId.Type; +export const EventId = makeEntityId("EventId"); +export type EventId = typeof EventId.Type; +export const MessageId = makeEntityId("MessageId"); +export type MessageId = typeof MessageId.Type; +export const TurnId = makeEntityId("TurnId"); +export type TurnId = typeof TurnId.Type; + +export const ProviderSessionId = makeEntityId("ProviderSessionId"); +export type ProviderSessionId = typeof ProviderSessionId.Type; +export const ProviderThreadId = makeEntityId("ProviderThreadId"); +export type ProviderThreadId = typeof ProviderThreadId.Type; +export const ProviderTurnId = makeEntityId("ProviderTurnId"); +export type ProviderTurnId = typeof ProviderTurnId.Type; +export const ProviderItemId = makeEntityId("ProviderItemId"); +export type ProviderItemId = typeof ProviderItemId.Type; +export const ApprovalRequestId = makeEntityId("ApprovalRequestId"); +export type ApprovalRequestId = typeof ApprovalRequestId.Type; +export const CheckpointRef = makeEntityId("CheckpointRef"); +export type CheckpointRef = typeof CheckpointRef.Type; diff --git a/packages/contracts/src/editor.ts b/packages/contracts/src/editor.ts index 75871f69a0967bfbae33e2340fbb6a5274dd456f..a53df901b12b5032efbdef2fe8661ecd52adccb5 100644 --- a/packages/contracts/src/editor.ts +++ b/packages/contracts/src/editor.ts @@ -1,4 +1,5 @@ import { Schema } from "effect"; +import { TrimmedNonEmptyString } from "./baseSchemas"; export const EDITORS = [ { id: "cursor", label: "Cursor", command: "cursor" }, @@ -9,7 +10,7 @@ export const EditorId = Schema.Literals(EDITORS.map((e) => e.id)); export type EditorId = typeof EditorId.Type; export const OpenInEditorInput = Schema.Struct({ - cwd: Schema.String, + cwd: TrimmedNonEmptyString, editor: EditorId, }); export type OpenInEditorInput = typeof OpenInEditorInput.Type; diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index a8e0980c8dc66c0fcabc52d1e49a21a1d5eeeecc..7ac616aff713b3db32d8183ae65f0e98a7ef00c3 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -1,6 +1,7 @@ import { Schema } from "effect"; +import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas"; -const TrimmedNonEmptyString = Schema.Trimmed.check(Schema.isNonEmpty()); +const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; // Domain Types @@ -19,101 +20,103 @@ export const GitPrStepStatus = Schema.Literals([ ]); export const GitBranch = Schema.Struct({ - name: TrimmedNonEmptyString, + name: TrimmedNonEmptyStringSchema, current: Schema.Boolean, isDefault: Schema.Boolean, - worktreePath: TrimmedNonEmptyString.pipe(Schema.NullOr), + worktreePath: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr), }); export type GitBranch = typeof GitBranch.Type; export const GitWorktree = Schema.Struct({ - path: TrimmedNonEmptyString, - branch: TrimmedNonEmptyString, + path: TrimmedNonEmptyStringSchema, + branch: TrimmedNonEmptyStringSchema, }); export type GitWorktree = typeof GitWorktree.Type; // RPC Inputs export const GitStatusInput = Schema.Struct({ - cwd: TrimmedNonEmptyString, + cwd: TrimmedNonEmptyStringSchema, }); export type GitStatusInput = typeof GitStatusInput.Type; export const GitPullInput = Schema.Struct({ - cwd: TrimmedNonEmptyString, + cwd: TrimmedNonEmptyStringSchema, }); export type GitPullInput = typeof GitPullInput.Type; export const GitRunStackedActionInput = Schema.Struct({ - cwd: TrimmedNonEmptyString, + cwd: TrimmedNonEmptyStringSchema, action: GitStackedAction, - commitMessage: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(10_000))), + commitMessage: Schema.optional(TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(10_000))), }); export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type; export const GitListBranchesInput = Schema.Struct({ - cwd: TrimmedNonEmptyString, + cwd: TrimmedNonEmptyStringSchema, }); export type GitListBranchesInput = typeof GitListBranchesInput.Type; export const GitCreateWorktreeInput = Schema.Struct({ - cwd: TrimmedNonEmptyString, - branch: TrimmedNonEmptyString, - newBranch: TrimmedNonEmptyString, - path: TrimmedNonEmptyString.pipe(Schema.NullOr), + cwd: TrimmedNonEmptyStringSchema, + branch: TrimmedNonEmptyStringSchema, + newBranch: TrimmedNonEmptyStringSchema, + path: Schema.NullOr(TrimmedNonEmptyStringSchema), }); export type GitCreateWorktreeInput = typeof GitCreateWorktreeInput.Type; export const GitRemoveWorktreeInput = Schema.Struct({ - cwd: TrimmedNonEmptyString, - path: TrimmedNonEmptyString, + cwd: TrimmedNonEmptyStringSchema, + path: TrimmedNonEmptyStringSchema, force: Schema.optional(Schema.Boolean), }); export type GitRemoveWorktreeInput = typeof GitRemoveWorktreeInput.Type; export const GitCreateBranchInput = Schema.Struct({ - cwd: TrimmedNonEmptyString, - branch: TrimmedNonEmptyString, + cwd: TrimmedNonEmptyStringSchema, + branch: TrimmedNonEmptyStringSchema, }); export type GitCreateBranchInput = typeof GitCreateBranchInput.Type; export const GitCheckoutInput = Schema.Struct({ - cwd: TrimmedNonEmptyString, - branch: TrimmedNonEmptyString, + cwd: TrimmedNonEmptyStringSchema, + branch: TrimmedNonEmptyStringSchema, }); export type GitCheckoutInput = typeof GitCheckoutInput.Type; export const GitInitInput = Schema.Struct({ - cwd: TrimmedNonEmptyString, + cwd: TrimmedNonEmptyStringSchema, }); export type GitInitInput = typeof GitInitInput.Type; // RPC Results export const GitStatusResult = Schema.Struct({ - branch: TrimmedNonEmptyString.pipe(Schema.NullOr), + branch: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr), hasWorkingTreeChanges: Schema.Boolean, workingTree: Schema.Struct({ files: Schema.Array( Schema.Struct({ - path: TrimmedNonEmptyString, - insertions: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), - deletions: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), + path: TrimmedNonEmptyStringSchema, + insertions: NonNegativeInt, + deletions: NonNegativeInt, }), ), - insertions: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), - deletions: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), + insertions: NonNegativeInt, + deletions: NonNegativeInt, }), hasUpstream: Schema.Boolean, - aheadCount: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), - behindCount: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)), - openPr: Schema.Struct({ - number: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)), - title: TrimmedNonEmptyString, - url: Schema.String, - baseBranch: TrimmedNonEmptyString, - headBranch: TrimmedNonEmptyString, - }).pipe(Schema.NullOr), + aheadCount: NonNegativeInt, + behindCount: NonNegativeInt, + openPr: Schema.NullOr( + Schema.Struct({ + number: PositiveInt, + title: TrimmedNonEmptyStringSchema, + url: Schema.String, + baseBranch: TrimmedNonEmptyStringSchema, + headBranch: TrimmedNonEmptyStringSchema, + }), + ), }); export type GitStatusResult = typeof GitStatusResult.Type; @@ -132,29 +135,29 @@ export const GitRunStackedActionResult = Schema.Struct({ action: GitStackedAction, commit: Schema.Struct({ status: GitCommitStepStatus, - commitSha: Schema.optional(TrimmedNonEmptyString), - subject: Schema.optional(TrimmedNonEmptyString), + commitSha: Schema.optional(TrimmedNonEmptyStringSchema), + subject: Schema.optional(TrimmedNonEmptyStringSchema), }), push: Schema.Struct({ status: GitPushStepStatus, - branch: Schema.optional(TrimmedNonEmptyString), - upstreamBranch: Schema.optional(TrimmedNonEmptyString), + branch: Schema.optional(TrimmedNonEmptyStringSchema), + upstreamBranch: Schema.optional(TrimmedNonEmptyStringSchema), setUpstream: Schema.optional(Schema.Boolean), }), pr: Schema.Struct({ status: GitPrStepStatus, url: Schema.optional(Schema.String), - number: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))), - baseBranch: Schema.optional(TrimmedNonEmptyString), - headBranch: Schema.optional(TrimmedNonEmptyString), - title: Schema.optional(TrimmedNonEmptyString), + number: Schema.optional(PositiveInt), + baseBranch: Schema.optional(TrimmedNonEmptyStringSchema), + headBranch: Schema.optional(TrimmedNonEmptyStringSchema), + title: Schema.optional(TrimmedNonEmptyStringSchema), }), }); export type GitRunStackedActionResult = typeof GitRunStackedActionResult.Type; export const GitPullResult = Schema.Struct({ status: Schema.Literals(["pulled", "skipped_up_to_date"]), - branch: TrimmedNonEmptyString, - upstreamBranch: TrimmedNonEmptyString.pipe(Schema.NullOr), + branch: TrimmedNonEmptyStringSchema, + upstreamBranch: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr), }); export type GitPullResult = typeof GitPullResult.Type; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index fbbf25ed95ed0f8c926278ecbc1df07c063055ba..0f37a9351570c03a0865c9c55cecf880ebca5c37 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1,3 +1,4 @@ +export * from "./baseSchemas"; export * from "./ipc"; export * from "./terminal"; export * from "./provider"; diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index 46b11907f84f98b8fba5847b67f76e6b17b7ffa3..dcba2c7c5931641dd53d767c0e6ec24e790ed6c9 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -1,4 +1,5 @@ import { Schema } from "effect"; +import { TrimmedString } from "./baseSchemas"; export const MAX_KEYBINDING_COMMAND_LENGTH = 96; export const MAX_KEYBINDING_VALUE_LENGTH = 64; @@ -33,13 +34,13 @@ export const KeybindingCommand = Schema.Union([ ]); export type KeybindingCommand = typeof KeybindingCommand.Type; -export const KeybindingValue = Schema.Trim.check( +export const KeybindingValue = TrimmedString.check( Schema.isMinLength(1), Schema.isMaxLength(MAX_KEYBINDING_VALUE_LENGTH), ); export type KeybindingValue = typeof KeybindingValue.Type; -export const KeybindingWhen = Schema.Trim.check( +export const KeybindingWhen = TrimmedString.check( Schema.isMinLength(1), Schema.isMaxLength(MAX_KEYBINDING_WHEN_LENGTH), ); diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 1d95d9b00e3ac6af339da97b8eb31c835a6150f0..29589c9e2c6873ffcd9f21ded0ce6d3e2cae4538 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -1,10 +1,15 @@ import { assert, it } from "@effect/vitest"; import { Effect, Schema } from "effect"; -import { OrchestrationGetTurnDiffInput, ThreadTurnDiff } from "./orchestration"; +import { + OrchestrationGetTurnDiffInput, + ProjectCreateCommand, + ThreadTurnDiff, +} from "./orchestration"; const decodeTurnDiffInput = Schema.decodeUnknownEffect(OrchestrationGetTurnDiffInput); const decodeThreadTurnDiff = Schema.decodeUnknownEffect(ThreadTurnDiff); +const decodeProjectCreateCommand = Schema.decodeUnknownEffect(ProjectCreateCommand); it.effect("parses turn diff input when fromTurnCount <= toTurnCount", () => Effect.gen(function* () { @@ -44,3 +49,38 @@ it.effect("rejects thread turn diff when fromTurnCount > toTurnCount", () => assert.strictEqual(result._tag, "Failure"); }), ); + +it.effect("trims branded ids and command string fields at decode boundaries", () => + Effect.gen(function* () { + const parsed = yield* decodeProjectCreateCommand({ + type: "project.create", + commandId: " cmd-1 ", + projectId: " project-1 ", + title: " Project Title ", + workspaceRoot: " /tmp/workspace ", + defaultModel: " gpt-5.2 ", + createdAt: "2026-01-01T00:00:00.000Z", + }); + assert.strictEqual(parsed.commandId, "cmd-1"); + assert.strictEqual(parsed.projectId, "project-1"); + assert.strictEqual(parsed.title, "Project Title"); + assert.strictEqual(parsed.workspaceRoot, "/tmp/workspace"); + assert.strictEqual(parsed.defaultModel, "gpt-5.2"); + }), +); + +it.effect("rejects command fields that become empty after trim", () => + Effect.gen(function* () { + const result = yield* Effect.exit( + decodeProjectCreateCommand({ + type: "project.create", + commandId: "cmd-1", + projectId: "project-1", + title: " ", + workspaceRoot: "/tmp/workspace", + createdAt: "2026-01-01T00:00:00.000Z", + }), + ); + assert.strictEqual(result._tag, "Failure"); + }), +); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 447d228abba14a7b7a815cc5b1c21bfa4feaa7e9..e9d57d4f395a01537c73a548246bd7de4c20afbc 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -1,4 +1,21 @@ import { Option, Schema, SchemaIssue, Struct } from "effect"; +import { + ApprovalRequestId, + CheckpointRef, + CommandId, + EventId, + IsoDateTime, + MessageId, + NonNegativeInt, + ProjectId, + ProviderItemId, + ProviderSessionId, + ProviderThreadId, + ProviderTurnId, + ThreadId, + TrimmedNonEmptyString, + TurnId, +} from "./baseSchemas"; export const ORCHESTRATION_WS_METHODS = { getSnapshot: "orchestration.getSnapshot", @@ -12,35 +29,6 @@ export const ORCHESTRATION_WS_CHANNELS = { domainEvent: "orchestration.domainEvent", } as const; -export const IsoDateTime = Schema.String; -export type IsoDateTime = typeof IsoDateTime.Type; - -export const ThreadId = Schema.String.pipe(Schema.brand("ThreadId")); -export type ThreadId = typeof ThreadId.Type; -export const ProjectId = Schema.String.pipe(Schema.brand("ProjectId")); -export type ProjectId = typeof ProjectId.Type; -export const CommandId = Schema.String.pipe(Schema.brand("CommandId")); -export type CommandId = typeof CommandId.Type; -export const EventId = Schema.String.pipe(Schema.brand("EventId")); -export type EventId = typeof EventId.Type; -export const MessageId = Schema.String.pipe(Schema.brand("MessageId")); -export type MessageId = typeof MessageId.Type; -export const TurnId = Schema.String.pipe(Schema.brand("TurnId")); -export type TurnId = typeof TurnId.Type; - -export const ProviderSessionId = Schema.String.pipe(Schema.brand("ProviderSessionId")); -export type ProviderSessionId = typeof ProviderSessionId.Type; -export const ProviderThreadId = Schema.String.pipe(Schema.brand("ProviderThreadId")); -export type ProviderThreadId = typeof ProviderThreadId.Type; -export const ProviderTurnId = Schema.String.pipe(Schema.brand("ProviderTurnId")); -export type ProviderTurnId = typeof ProviderTurnId.Type; -export const ProviderItemId = Schema.String.pipe(Schema.brand("ProviderItemId")); -export type ProviderItemId = typeof ProviderItemId.Type; -export const ApprovalRequestId = Schema.String.pipe(Schema.brand("ApprovalRequestId")); -export type ApprovalRequestId = typeof ApprovalRequestId.Type; -export const CheckpointRef = Schema.String.pipe(Schema.brand("CheckpointRef")); -export type CheckpointRef = typeof CheckpointRef.Type; - export const ProviderKind = Schema.Literals(["codex", "claudeCode"]); export type ProviderKind = typeof ProviderKind.Type; export const ProviderApprovalPolicy = Schema.Literals([ @@ -75,21 +63,12 @@ export const PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS = 14_000_000; export const CorrelationId = CommandId; export type CorrelationId = typeof CorrelationId.Type; -export const NonNegativeInt = Schema.Number.check(Schema.isInt()).check( - Schema.isGreaterThanOrEqualTo(0), -); - -const TrimmedNonEmptyStringSchema = Schema.Trimmed.check(Schema.isNonEmpty()); - export const ChatImageAttachment = Schema.Struct({ type: Schema.Literal("image"), - name: TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(255)), - mimeType: TrimmedNonEmptyStringSchema.check( - Schema.isMaxLength(100), - Schema.isPattern(/^image\//i), - ), + name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)), + mimeType: TrimmedNonEmptyString.check(Schema.isMaxLength(100), Schema.isPattern(/^image\//i)), sizeBytes: NonNegativeInt.check(Schema.isLessThanOrEqualTo(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES)), - dataUrl: TrimmedNonEmptyStringSchema.check( + dataUrl: TrimmedNonEmptyString.check( Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS), ), }); @@ -109,9 +88,9 @@ export const ProjectScriptIcon = Schema.Literals([ export type ProjectScriptIcon = typeof ProjectScriptIcon.Type; export const ProjectScript = Schema.Struct({ - id: Schema.String, - name: Schema.String, - command: Schema.String, + id: TrimmedNonEmptyString, + name: TrimmedNonEmptyString, + command: TrimmedNonEmptyString, icon: ProjectScriptIcon, runOnWorktreeCreate: Schema.Boolean, }); @@ -119,9 +98,9 @@ export type ProjectScript = typeof ProjectScript.Type; export const OrchestrationProject = Schema.Struct({ id: ProjectId, - title: Schema.String, - workspaceRoot: Schema.String, - defaultModel: Schema.NullOr(Schema.String), + title: TrimmedNonEmptyString, + workspaceRoot: TrimmedNonEmptyString, + defaultModel: Schema.NullOr(TrimmedNonEmptyString), scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, updatedAt: IsoDateTime, @@ -158,18 +137,18 @@ export type OrchestrationSessionStatus = typeof OrchestrationSessionStatus.Type; export const OrchestrationSession = Schema.Struct({ threadId: ThreadId, status: OrchestrationSessionStatus, - providerName: Schema.NullOr(Schema.String), + providerName: Schema.NullOr(TrimmedNonEmptyString), providerSessionId: Schema.NullOr(ProviderSessionId), providerThreadId: Schema.NullOr(ProviderThreadId), activeTurnId: Schema.NullOr(TurnId), - lastError: Schema.NullOr(Schema.String), + lastError: Schema.NullOr(TrimmedNonEmptyString), updatedAt: IsoDateTime, }); export type OrchestrationSession = typeof OrchestrationSession.Type; export const OrchestrationCheckpointFile = Schema.Struct({ - path: Schema.String, - kind: Schema.String, + path: TrimmedNonEmptyString, + kind: TrimmedNonEmptyString, additions: NonNegativeInt, deletions: NonNegativeInt, }); @@ -200,8 +179,8 @@ export type OrchestrationThreadActivityTone = typeof OrchestrationThreadActivity export const OrchestrationThreadActivity = Schema.Struct({ id: EventId, tone: OrchestrationThreadActivityTone, - kind: Schema.String, - summary: Schema.String, + kind: TrimmedNonEmptyString, + summary: TrimmedNonEmptyString, payload: Schema.Unknown, turnId: Schema.NullOr(TurnId), createdAt: IsoDateTime, @@ -211,10 +190,10 @@ export type OrchestrationThreadActivity = typeof OrchestrationThreadActivity.Typ export const OrchestrationThread = Schema.Struct({ id: ThreadId, projectId: ProjectId, - title: Schema.String, - model: Schema.String, - branch: Schema.NullOr(Schema.String), - worktreePath: Schema.NullOr(Schema.String), + title: TrimmedNonEmptyString, + model: TrimmedNonEmptyString, + branch: Schema.NullOr(TrimmedNonEmptyString), + worktreePath: Schema.NullOr(TrimmedNonEmptyString), latestTurnId: Schema.NullOr(TurnId), createdAt: IsoDateTime, updatedAt: IsoDateTime, @@ -238,9 +217,9 @@ export const ProjectCreateCommand = Schema.Struct({ type: Schema.Literal("project.create"), commandId: CommandId, projectId: ProjectId, - title: Schema.String, - workspaceRoot: Schema.String, - defaultModel: Schema.optional(Schema.String), + title: TrimmedNonEmptyString, + workspaceRoot: TrimmedNonEmptyString, + defaultModel: Schema.optional(TrimmedNonEmptyString), createdAt: IsoDateTime, }); @@ -248,9 +227,9 @@ export const ProjectMetaUpdateCommand = Schema.Struct({ type: Schema.Literal("project.meta.update"), commandId: CommandId, projectId: ProjectId, - title: Schema.optional(Schema.String), - workspaceRoot: Schema.optional(Schema.String), - defaultModel: Schema.optional(Schema.String), + title: Schema.optional(TrimmedNonEmptyString), + workspaceRoot: Schema.optional(TrimmedNonEmptyString), + defaultModel: Schema.optional(TrimmedNonEmptyString), scripts: Schema.optional(Schema.Array(ProjectScript)), }); @@ -265,10 +244,10 @@ export const ThreadCreateCommand = Schema.Struct({ commandId: CommandId, threadId: ThreadId, projectId: ProjectId, - title: Schema.String, - model: Schema.String, - branch: Schema.NullOr(Schema.String), - worktreePath: Schema.NullOr(Schema.String), + title: TrimmedNonEmptyString, + model: TrimmedNonEmptyString, + branch: Schema.NullOr(TrimmedNonEmptyString), + worktreePath: Schema.NullOr(TrimmedNonEmptyString), createdAt: IsoDateTime, }); @@ -282,10 +261,10 @@ export const ThreadMetaUpdateCommand = Schema.Struct({ type: Schema.Literal("thread.meta.update"), commandId: CommandId, threadId: ThreadId, - title: Schema.optional(Schema.String), - model: Schema.optional(Schema.String), - branch: Schema.optional(Schema.NullOr(Schema.String)), - worktreePath: Schema.optional(Schema.NullOr(Schema.String)), + title: Schema.optional(TrimmedNonEmptyString), + model: Schema.optional(TrimmedNonEmptyString), + branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), }); export const ThreadTurnStartCommand = Schema.Struct({ @@ -298,8 +277,8 @@ export const ThreadTurnStartCommand = Schema.Struct({ text: Schema.String, attachments: Schema.Array(ChatAttachment), }), - model: Schema.optional(Schema.String), - effort: Schema.optional(Schema.String), + model: Schema.optional(TrimmedNonEmptyString), + effort: Schema.optional(TrimmedNonEmptyString), createdAt: IsoDateTime, }); @@ -449,9 +428,9 @@ export const OrchestrationActorKind = Schema.Literals(["client", "server", "prov export const ProjectCreatedPayload = Schema.Struct({ projectId: ProjectId, - title: Schema.String, - workspaceRoot: Schema.String, - defaultModel: Schema.NullOr(Schema.String), + title: TrimmedNonEmptyString, + workspaceRoot: TrimmedNonEmptyString, + defaultModel: Schema.NullOr(TrimmedNonEmptyString), scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, updatedAt: IsoDateTime, @@ -459,9 +438,9 @@ export const ProjectCreatedPayload = Schema.Struct({ export const ProjectMetaUpdatedPayload = Schema.Struct({ projectId: ProjectId, - title: Schema.optional(Schema.String), - workspaceRoot: Schema.optional(Schema.String), - defaultModel: Schema.optional(Schema.NullOr(Schema.String)), + title: Schema.optional(TrimmedNonEmptyString), + workspaceRoot: Schema.optional(TrimmedNonEmptyString), + defaultModel: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), scripts: Schema.optional(Schema.Array(ProjectScript)), updatedAt: IsoDateTime, }); @@ -474,10 +453,10 @@ export const ProjectDeletedPayload = Schema.Struct({ export const ThreadCreatedPayload = Schema.Struct({ threadId: ThreadId, projectId: ProjectId, - title: Schema.String, - model: Schema.String, - branch: Schema.NullOr(Schema.String), - worktreePath: Schema.NullOr(Schema.String), + title: TrimmedNonEmptyString, + model: TrimmedNonEmptyString, + branch: Schema.NullOr(TrimmedNonEmptyString), + worktreePath: Schema.NullOr(TrimmedNonEmptyString), createdAt: IsoDateTime, updatedAt: IsoDateTime, }); @@ -489,10 +468,10 @@ export const ThreadDeletedPayload = Schema.Struct({ export const ThreadMetaUpdatedPayload = Schema.Struct({ threadId: ThreadId, - title: Schema.optional(Schema.String), - model: Schema.optional(Schema.String), - branch: Schema.optional(Schema.NullOr(Schema.String)), - worktreePath: Schema.optional(Schema.NullOr(Schema.String)), + title: Schema.optional(TrimmedNonEmptyString), + model: Schema.optional(TrimmedNonEmptyString), + branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), updatedAt: IsoDateTime, }); @@ -511,8 +490,8 @@ export const ThreadMessageSentPayload = Schema.Struct({ export const ThreadTurnStartRequestedPayload = Schema.Struct({ threadId: ThreadId, messageId: MessageId, - model: Schema.optional(Schema.String), - effort: Schema.optional(Schema.String), + model: Schema.optional(TrimmedNonEmptyString), + effort: Schema.optional(TrimmedNonEmptyString), createdAt: IsoDateTime, }); @@ -571,7 +550,7 @@ export const OrchestrationEventMetadata = Schema.Struct({ providerThreadId: Schema.optional(ProviderThreadId), providerTurnId: Schema.optional(ProviderTurnId), providerItemId: Schema.optional(ProviderItemId), - adapterKey: Schema.optional(Schema.String), + adapterKey: Schema.optional(TrimmedNonEmptyString), requestId: Schema.optional(ApprovalRequestId), ingestedAt: Schema.optional(IsoDateTime), }); diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index 343fd8cc535976aff040383c199f340d9f945733..f3468d55d06e2e31c831f9282d59b3e189d85246 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -1,15 +1,12 @@ import { Schema } from "effect"; +import { PositiveInt, TrimmedNonEmptyString } from "./baseSchemas"; export const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200; -const TrimmedNonEmptyString = Schema.Trimmed.check(Schema.isNonEmpty()); export const ProjectSearchEntriesInput = Schema.Struct({ cwd: TrimmedNonEmptyString, - query: Schema.NonEmptyString.check(Schema.isMaxLength(256)), - limit: Schema.Int.check( - Schema.isGreaterThanOrEqualTo(1), - Schema.isLessThanOrEqualTo(PROJECT_SEARCH_ENTRIES_MAX_LIMIT), - ), + query: TrimmedNonEmptyString.check(Schema.isMaxLength(256)), + limit: PositiveInt.check(Schema.isLessThanOrEqualTo(PROJECT_SEARCH_ENTRIES_MAX_LIMIT)), }); export type ProjectSearchEntriesInput = typeof ProjectSearchEntriesInput.Type; diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index 47533cf6aac0a7f9d95179b1074f1f7178661241..eb5f021320ea8464a4a35afd714c5d23aa981ab4 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -1,27 +1,29 @@ import { Schema } from "effect"; - +import { TrimmedNonEmptyString } from "./baseSchemas"; import { ApprovalRequestId, - ChatAttachment, EventId, IsoDateTime, NonNegativeInt, + ProviderItemId, + ProviderSessionId, + ProviderThreadId, + ProviderTurnId, + ThreadId, +} from "./baseSchemas"; +import { + ChatAttachment, PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_INPUT_CHARS, ProviderApprovalDecision, ProviderApprovalPolicy, ProviderKind, - ProviderItemId, ProviderRequestKind, ProviderSandboxMode, - ProviderSessionId, - ProviderThreadId, - ProviderTurnId, - ThreadId, TurnCountRange, } from "./orchestration"; -const TrimmedNonEmptyStringSchema = Schema.Trimmed.check(Schema.isNonEmpty()); +const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; export const ProviderSessionStatus = Schema.Literals([ "connecting", diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 15ca77fa6076d685014a196bee7539c5c4588cf8..daf70550425ebf947b6e95b9fa41b6aa488182ac 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -1,21 +1,21 @@ import { Schema } from "effect"; +import { TrimmedNonEmptyString } from "./baseSchemas"; import { ApprovalRequestId, EventId, - IsoDateTime, NonNegativeInt, - ProviderApprovalDecision, ProviderItemId, - ProviderKind, - ProviderRequestKind, ProviderSessionId, ProviderThreadId, ProviderTurnId, ThreadId, TurnId, -} from "./orchestration"; + IsoDateTime, +} from "./baseSchemas"; +import { ProviderApprovalDecision, ProviderKind, ProviderRequestKind } from "./orchestration"; +const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; const RuntimeThreadIdSchema = Schema.Union([ThreadId, ProviderThreadId]); const RuntimeTurnIdSchema = Schema.Union([TurnId, ProviderTurnId]); @@ -37,7 +37,7 @@ export const ProviderRuntimeSessionStartedEvent = Schema.Struct({ sessionId: ProviderSessionId, createdAt: IsoDateTime, threadId: Schema.optional(RuntimeThreadIdSchema), - message: Schema.optional(Schema.String), + message: Schema.optional(TrimmedNonEmptyStringSchema), }); export type ProviderRuntimeSessionStartedEvent = typeof ProviderRuntimeSessionStartedEvent.Type; @@ -48,7 +48,7 @@ export const ProviderRuntimeSessionExitedEvent = Schema.Struct({ sessionId: ProviderSessionId, createdAt: IsoDateTime, threadId: Schema.optional(RuntimeThreadIdSchema), - message: Schema.optional(Schema.String), + message: Schema.optional(TrimmedNonEmptyStringSchema), }); export type ProviderRuntimeSessionExitedEvent = typeof ProviderRuntimeSessionExitedEvent.Type; @@ -82,7 +82,7 @@ export const ProviderRuntimeTurnCompletedEvent = Schema.Struct({ threadId: Schema.optional(RuntimeThreadIdSchema), turnId: Schema.optional(RuntimeTurnIdSchema), status: Schema.optional(ProviderRuntimeTurnStatus), - errorMessage: Schema.optional(Schema.String), + errorMessage: Schema.optional(TrimmedNonEmptyStringSchema), }); export type ProviderRuntimeTurnCompletedEvent = typeof ProviderRuntimeTurnCompletedEvent.Type; @@ -121,8 +121,8 @@ export const ProviderRuntimeToolStartedEvent = Schema.Struct({ turnId: Schema.optional(RuntimeTurnIdSchema), itemId: Schema.optional(ProviderItemId), toolKind: ProviderRuntimeToolKind, - title: Schema.String, - detail: Schema.optional(Schema.String), + title: TrimmedNonEmptyStringSchema, + detail: Schema.optional(TrimmedNonEmptyStringSchema), }); export type ProviderRuntimeToolStartedEvent = typeof ProviderRuntimeToolStartedEvent.Type; @@ -136,8 +136,8 @@ export const ProviderRuntimeToolCompletedEvent = Schema.Struct({ turnId: Schema.optional(RuntimeTurnIdSchema), itemId: Schema.optional(ProviderItemId), toolKind: ProviderRuntimeToolKind, - title: Schema.String, - detail: Schema.optional(Schema.String), + title: TrimmedNonEmptyStringSchema, + detail: Schema.optional(TrimmedNonEmptyStringSchema), }); export type ProviderRuntimeToolCompletedEvent = typeof ProviderRuntimeToolCompletedEvent.Type; @@ -152,7 +152,7 @@ export const ProviderRuntimeApprovalRequestedEvent = Schema.Struct({ itemId: Schema.optional(ProviderItemId), requestId: ApprovalRequestId, requestKind: ProviderRequestKind, - detail: Schema.optional(Schema.String), + detail: Schema.optional(TrimmedNonEmptyStringSchema), }); export type ProviderRuntimeApprovalRequestedEvent = typeof ProviderRuntimeApprovalRequestedEvent.Type; @@ -195,7 +195,7 @@ export const ProviderRuntimeErrorEvent = Schema.Struct({ threadId: Schema.optional(RuntimeThreadIdSchema), turnId: Schema.optional(RuntimeTurnIdSchema), itemId: Schema.optional(ProviderItemId), - message: Schema.String, + message: TrimmedNonEmptyStringSchema, }); export type ProviderRuntimeErrorEvent = typeof ProviderRuntimeErrorEvent.Type; diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index ad491ce11230fa26d49232eb6753f29be6f8ba3f..774cf5d37c8a5f703bf26a2d6d1501fbf5ce5853 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -1,8 +1,9 @@ import { Schema } from "effect"; +import { TrimmedNonEmptyString } from "./baseSchemas"; import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings"; export const ServerConfig = Schema.Struct({ - cwd: Schema.NonEmptyString, + cwd: TrimmedNonEmptyString, keybindings: ResolvedKeybindingsConfig, }); export type ServerConfig = typeof ServerConfig.Type; diff --git a/packages/contracts/src/terminal.ts b/packages/contracts/src/terminal.ts index b44ca7c44d53f90c0cb9e76668616a3ca935bd76..1dea8963ec7d892e6d6be51d4d65589131ff88c7 100644 --- a/packages/contracts/src/terminal.ts +++ b/packages/contracts/src/terminal.ts @@ -1,26 +1,22 @@ import { Schema } from "effect"; +import { TrimmedNonEmptyString } from "./baseSchemas"; export const DEFAULT_TERMINAL_ID = "default"; -const TrimmedNonEmptyStringSchema = Schema.Trim.check(Schema.isNonEmpty()); +const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; const TerminalColsSchema = Schema.Int.check(Schema.isGreaterThanOrEqualTo(20)).check( Schema.isLessThanOrEqualTo(400), ); const TerminalRowsSchema = Schema.Int.check(Schema.isGreaterThanOrEqualTo(5)).check( Schema.isLessThanOrEqualTo(200), ); -const TerminalIdSchema = Schema.Trim.check(Schema.isNonEmpty()).check(Schema.isMaxLength(128)); +const TerminalIdSchema = TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(128)); const TerminalEnvKeySchema = Schema.String.check( Schema.isPattern(/^[A-Za-z_][A-Za-z0-9_]*$/), ).check(Schema.isMaxLength(128)); const TerminalEnvValueSchema = Schema.String.check(Schema.isMaxLength(8_192)); const TerminalEnvSchema = Schema.Record(TerminalEnvKeySchema, TerminalEnvValueSchema).check( - Schema.makeFilter>((env) => { - if (Object.keys(env).length > 128) { - return { path: [], message: "Too many terminal env vars" }; - } - return true; - }), + Schema.isMaxProperties(128), ); const TerminalIdWithDefaultSchema = TerminalIdSchema.pipe( diff --git a/packages/contracts/src/ws.test.ts b/packages/contracts/src/ws.test.ts index 9af5ce4059bfaaea7c867a2d0b9628c95308934d..be103f3046ffd36cb0c44d34ed7f2c3f8a3c3b5a 100644 --- a/packages/contracts/src/ws.test.ts +++ b/packages/contracts/src/ws.test.ts @@ -37,3 +37,22 @@ it.effect("rejects getTurnDiff requests when fromTurnCount > toTurnCount", () => assert.strictEqual(result._tag, "Failure"); }), ); + +it.effect("trims websocket request id and nested orchestration ids", () => + Effect.gen(function* () { + const parsed = yield* decodeWebSocketRequest({ + id: " req-1 ", + body: { + _tag: ORCHESTRATION_WS_METHODS.getTurnDiff, + threadId: " thread-1 ", + fromTurnCount: 0, + toTurnCount: 0, + }, + }); + assert.strictEqual(parsed.id, "req-1"); + assert.strictEqual(parsed.body._tag, ORCHESTRATION_WS_METHODS.getTurnDiff); + if (parsed.body._tag === ORCHESTRATION_WS_METHODS.getTurnDiff) { + assert.strictEqual(parsed.body.threadId, "thread-1"); + } + }), +); diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index 31d77da9697f25307345eec3deed473889a564a2..f19f39b967c1785532929322583437407ad715bf 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -1,4 +1,5 @@ import { Schema, Struct } from "effect"; +import { TrimmedNonEmptyString } from "./baseSchemas"; import { ClientOrchestrationCommand, @@ -128,13 +129,13 @@ export const WebSocketRequestBody = Schema.Union([ ]); export const WebSocketRequest = Schema.Struct({ - id: Schema.String, + id: TrimmedNonEmptyString, body: WebSocketRequestBody, }); export type WebSocketRequest = typeof WebSocketRequest.Type; export const WebSocketResponse = Schema.Struct({ - id: Schema.String, + id: TrimmedNonEmptyString, result: Schema.optional(Schema.Unknown), error: Schema.optional( Schema.Struct({ @@ -146,7 +147,7 @@ export type WebSocketResponse = typeof WebSocketResponse.Type; export const WsPush = Schema.Struct({ type: Schema.Literal("push"), - channel: Schema.String, + channel: TrimmedNonEmptyString, data: Schema.Unknown, }); export type WsPush = typeof WsPush.Type; @@ -159,7 +160,7 @@ export type WsResponse = typeof WsResponse.Type; // ── Server welcome payload ─────────────────────────────────────────── export const WsWelcomePayload = Schema.Struct({ - cwd: Schema.String, - projectName: Schema.String, + cwd: TrimmedNonEmptyString, + projectName: TrimmedNonEmptyString, }); export type WsWelcomePayload = typeof WsWelcomePayload.Type; -- 2.54.0