authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-25 22:50:17-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-26 13:20:36-08:00
loge5542b7a880aa40ab5a4b5ad8012822c11643471
tree5095728ead2c41212ce5918ff55c1d2ebc43dcf7
parent76208f79de4cb79146f1501feed6162eef71e24b

delegate trimming to schema


25 files changed, 361 insertions(+), 354 deletions(-)

apps/server/src/checkpointing/Layers/CheckpointStore.ts+11-13
......@@ -78,7 +78,7 @@ const makeCheckpointStore = Effect.gen(function* () {
7878 git
7979 .execute({
8080 operation: "CheckpointStore.isGitRepository",
81 cwd: cwd.trim(),
81 cwd,
8282 args: ["rev-parse", "--is-inside-work-tree"],
8383 allowNonZeroExit: true,
8484 })
......@@ -90,8 +90,6 @@ const makeCheckpointStore = Effect.gen(function* () {
9090 const captureCheckpoint: CheckpointStoreShape["captureCheckpoint"] = (input) =>
9191 Effect.gen(function* () {
9292 const operation = "CheckpointStore.captureCheckpoint";
93 const normalizedCwd = input.cwd.trim();
94 const checkpointRef = input.checkpointRef.trim();
9593
9694 yield* Effect.acquireUseRelease(
9795 fs.makeTempDirectory({ prefix: "t3-fs-checkpoint-" }),
......@@ -107,11 +105,11 @@ const makeCheckpointStore = Effect.gen(function* () {
107105 GIT_COMMITTER_EMAIL: "t3code@users.noreply.github.com",
108106 };
109107
110 const headExists = yield* hasHeadCommit(normalizedCwd);
108 const headExists = yield* hasHeadCommit(input.cwd);
111109 if (headExists) {
112110 yield* git.execute({
113111 operation,
114 cwd: normalizedCwd,
112 cwd: input.cwd,
115113 args: ["read-tree", "HEAD"],
116114 env: commitEnv,
117115 });
......@@ -119,14 +117,14 @@ const makeCheckpointStore = Effect.gen(function* () {
119117
120118 yield* git.execute({
121119 operation,
122 cwd: normalizedCwd,
120 cwd: input.cwd,
123121 args: ["add", "-A", "--", "."],
124122 env: commitEnv,
125123 });
126124
127125 const writeTreeResult = yield* git.execute({
128126 operation,
129 cwd: normalizedCwd,
127 cwd: input.cwd,
130128 args: ["write-tree"],
131129 env: commitEnv,
132130 });
......@@ -135,15 +133,15 @@ const makeCheckpointStore = Effect.gen(function* () {
135133 return yield* new GitCommandError({
136134 operation,
137135 command: "git write-tree",
138 cwd: normalizedCwd,
136 cwd: input.cwd,
139137 detail: "git write-tree returned an empty tree oid.",
140138 });
141139 }
142140
143 const message = `t3 checkpoint ref=${checkpointRef}`;
141 const message = `t3 checkpoint ref=${input.checkpointRef}`;
144142 const commitTreeResult = yield* git.execute({
145143 operation,
146 cwd: normalizedCwd,
144 cwd: input.cwd,
147145 args: ["commit-tree", treeOid, "-m", message],
148146 env: commitEnv,
149147 });
......@@ -152,15 +150,15 @@ const makeCheckpointStore = Effect.gen(function* () {
152150 return yield* new GitCommandError({
153151 operation,
154152 command: "git commit-tree",
155 cwd: normalizedCwd,
153 cwd: input.cwd,
156154 detail: "git commit-tree returned an empty commit oid.",
157155 });
158156 }
159157
160158 yield* git.execute({
161159 operation,
162 cwd: normalizedCwd,
163 args: ["update-ref", checkpointRef, commitOid],
160 cwd: input.cwd,
161 args: ["update-ref", input.checkpointRef, commitOid],
164162 });
165163 }),
166164 (tempDir) => fs.remove(tempDir, { recursive: true }),
apps/server/src/checkpointing/Utils.ts+2-10
......@@ -9,11 +9,6 @@ export function checkpointRefForThreadTurn(threadId: ThreadId, turnCount: number
99 );
1010}
1111
12function trimToNonEmpty(value: string | null | undefined): string | undefined {
13 const normalized = value?.trim();
14 return normalized && normalized.length > 0 ? normalized : undefined;
15}
16
1712export function resolveThreadWorkspaceCwd(input: {
1813 readonly thread: {
1914 readonly projectId: ProjectId;
......@@ -24,13 +19,10 @@ export function resolveThreadWorkspaceCwd(input: {
2419 readonly workspaceRoot: string;
2520 }>;
2621}): string | undefined {
27 const worktreeCwd = trimToNonEmpty(input.thread.worktreePath);
22 const worktreeCwd = input.thread.worktreePath ?? undefined;
2823 if (worktreeCwd) {
2924 return worktreeCwd;
3025 }
3126
32 const workspaceRoot = input.projects.find(
33 (project) => project.id === input.thread.projectId,
34 )?.workspaceRoot;
35 return trimToNonEmpty(workspaceRoot);
27 return input.projects.find((project) => project.id === input.thread.projectId)?.workspaceRoot;
3628}
apps/server/src/codexAppServerManager.ts+2-2
......@@ -169,8 +169,8 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
169169 updatedAt: now,
170170 };
171171
172 const codexBinaryPath = input.codexBinaryPath?.trim() || "codex";
173 const codexHomePath = input.codexHomePath?.trim();
172 const codexBinaryPath = input.codexBinaryPath ?? "codex";
173 const codexHomePath = input.codexHomePath;
174174 const child = spawn(codexBinaryPath, ["app-server"], {
175175 cwd: resolvedCwd,
176176 env: {
apps/server/src/git/Layers/GitService.ts-2
......@@ -71,10 +71,8 @@ const makeGitService = Effect.gen(function* () {
7171 const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
7272
7373 const execute: GitServiceShape["execute"] = Effect.fnUntraced(function* (input) {
74 const normalizedCwd = input.cwd.trim();
7574 const commandInput = {
7675 ...input,
77 cwd: normalizedCwd,
7876 args: [...input.args],
7977 } as const;
8078 const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
apps/server/src/keybindings.ts+3-6
......@@ -265,14 +265,11 @@ function parseKeybindingWhenExpression(expression: string): KeybindingWhenNode |
265265
266266/** @internal - Exported for testing */
267267export function compileResolvedKeybindingRule(rule: KeybindingRule): ResolvedKeybindingRule | null {
268 const key = rule.key.trim();
269 if (key.length === 0) return null;
270 const shortcut = parseKeybindingShortcut(key);
268 const shortcut = parseKeybindingShortcut(rule.key);
271269 if (!shortcut) return null;
272270
273 const when = rule.when?.trim();
274 if (when && when.length > 0) {
275 const whenAst = parseKeybindingWhenExpression(when);
271 if (rule.when !== undefined) {
272 const whenAst = parseKeybindingWhenExpression(rule.when);
276273 if (!whenAst) return null;
277274 return {
278275 command: rule.command,
apps/server/src/orchestration/Layers/CheckpointReactor.ts+5-12
......@@ -33,13 +33,7 @@ type ReactorInput =
3333 };
3434
3535function toTurnId(value: string | undefined): TurnId | null {
36 const normalized = value?.trim();
37 return normalized && normalized.length > 0 ? TurnId.makeUnsafe(normalized) : null;
38}
39
40function trimToNonEmpty(value: string | null | undefined): string | undefined {
41 const normalized = value?.trim();
42 return normalized && normalized.length > 0 ? normalized : undefined;
36 return value === undefined ? null : TurnId.makeUnsafe(value);
4337}
4438
4539function checkpointStatusFromRuntime(status: string | undefined): "ready" | "missing" | "error" {
......@@ -129,18 +123,17 @@ const make = Effect.gen(function* () {
129123 const readModel = yield* orchestrationEngine.getReadModel();
130124 const thread = readModel.threads.find((entry) => entry.id === threadId);
131125 const projectedSessionId = thread?.session?.providerSessionId ?? null;
132 const projectedProviderThreadId = trimToNonEmpty(thread?.session?.providerThreadId);
126 const projectedProviderThreadId = thread?.session?.providerThreadId ?? undefined;
133127
134128 const sessions = yield* providerService.listSessions();
135129
136130 const findSessionWithCwd = (
137131 session: (typeof sessions)[number] | undefined,
138132 ): Option.Option<{ readonly sessionId: ProviderSessionId; readonly cwd: string }> => {
139 const cwd = session?.cwd?.trim();
140 if (!session || !cwd) {
133 if (!session?.cwd) {
141134 return Option.none();
142135 }
143 return Option.some({ sessionId: session.sessionId, cwd });
136 return Option.some({ sessionId: session.sessionId, cwd: session.cwd });
144137 };
145138
146139 if (projectedSessionId !== null) {
......@@ -153,7 +146,7 @@ const make = Effect.gen(function* () {
153146
154147 if (projectedProviderThreadId) {
155148 const matchedSession = sessions.find(
156 (session) => session.threadId?.trim() === projectedProviderThreadId,
149 (session) => session.threadId === projectedProviderThreadId,
157150 );
158151 const fromProviderThread = findSessionWithCwd(matchedSession);
159152 if (Option.isSome(fromProviderThread)) {
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+7-10
......@@ -28,7 +28,7 @@ type ProviderIntentEvent = Extract<
2828 }
2929>;
3030
31function trimToNonEmptyOrUndefined(value: string | undefined): string | undefined {
31function toNonEmptyProviderInput(value: string | undefined): string | undefined {
3232 const normalized = value?.trim();
3333 return normalized && normalized.length > 0 ? normalized : undefined;
3434}
......@@ -43,15 +43,12 @@ function resolveEffectiveThreadCwd(input: {
4343 readonly workspaceRoot: string;
4444 }>;
4545}): string | undefined {
46 const worktreeCwd = trimToNonEmptyOrUndefined(input.thread.worktreePath ?? undefined);
46 const worktreeCwd = input.thread.worktreePath ?? undefined;
4747 if (worktreeCwd) {
4848 return worktreeCwd;
4949 }
5050
51 const workspaceRoot = input.projects.find(
52 (project) => project.id === input.thread.projectId,
53 )?.workspaceRoot;
54 return trimToNonEmptyOrUndefined(workspaceRoot);
51 return input.projects.find((project) => project.id === input.thread.projectId)?.workspaceRoot;
5552}
5653
5754function mapProviderSessionStatusToOrchestrationStatus(
......@@ -158,7 +155,7 @@ const make = Effect.gen(function* () {
158155 const startedSession = yield* providerService.startSession({
159156 ...(preferredProvider ? { provider: preferredProvider } : {}),
160157 ...(effectiveCwd ? { cwd: effectiveCwd } : {}),
161 ...(trimToNonEmptyOrUndefined(thread.model) ? { model: thread.model } : {}),
158 ...(thread.model ? { model: thread.model } : {}),
162159 });
163160
164161 yield* setThreadSession({
......@@ -193,15 +190,15 @@ const make = Effect.gen(function* () {
193190 return;
194191 }
195192 const sessionId = yield* ensureSessionForThread(input.threadId, input.createdAt);
196 const normalizedInput = trimToNonEmptyOrUndefined(input.messageText);
193 const normalizedInput = toNonEmptyProviderInput(input.messageText);
197194 const normalizedAttachments = input.attachments ?? [];
198195
199196 yield* providerService.sendTurn({
200197 sessionId,
201198 ...(normalizedInput ? { input: normalizedInput } : {}),
202199 ...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}),
203 ...(trimToNonEmptyOrUndefined(input.model) ? { model: input.model } : {}),
204 ...(trimToNonEmptyOrUndefined(input.effort) ? { effort: input.effort } : {}),
200 ...(input.model !== undefined ? { model: input.model } : {}),
201 ...(input.effort !== undefined ? { effort: input.effort } : {}),
205202 });
206203 });
207204
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+1-2
......@@ -21,8 +21,7 @@ const providerCommandId = (event: ProviderRuntimeEvent, tag: string): CommandId
2121 CommandId.makeUnsafe(`provider:${event.eventId}:${tag}:${crypto.randomUUID()}`);
2222
2323function toTurnId(value: string | undefined): TurnId | undefined {
24 const normalized = value?.trim();
25 return normalized?.length ? TurnId.makeUnsafe(normalized) : undefined;
24 return value === undefined ? undefined : TurnId.makeUnsafe(value);
2625}
2726
2827function truncateDetail(value: string, limit = 180): string {
apps/server/src/provider/Layers/ProviderService.ts+2-1
......@@ -242,8 +242,9 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
242242 Effect.gen(function* () {
243243 const adapter = yield* registry.getByProvider(input.provider);
244244 const activeSessions = yield* adapter.listSessions();
245 const expectedProviderThreadId = asProviderThreadId(input.threadId);
245246 const existing = activeSessions.find(
246 (session) => session.threadId?.trim() === input.threadId.trim(),
247 (session) => session.threadId === expectedProviderThreadId,
247248 );
248249 if (existing) {
249250 const existingThreadId = yield* upsertSessionBinding(
apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts+3-3
......@@ -79,12 +79,12 @@ layer("ProviderSessionDirectoryLive", (it) => {
7979 assertFailure(missingProvider, new ProviderSessionNotFoundError({ sessionId: "sess-1" }));
8080 }));
8181
82 it("fails upsert for empty session id", () =>
82 it("fails upsert when thread id is unavailable", () =>
8383 Effect.gen(function* () {
8484 const directory = yield* ProviderSessionDirectory;
8585 const result = yield* Effect.result(
8686 directory.upsert({
87 sessionId: sessionId(" "),
87 sessionId: sessionId("sess-no-thread"),
8888 provider: "codex",
8989 }),
9090 );
......@@ -92,7 +92,7 @@ layer("ProviderSessionDirectoryLive", (it) => {
9292 result,
9393 new ProviderValidationError({
9494 operation: "ProviderSessionDirectory.upsert",
95 issue: "sessionId must be a non-empty string.",
95 issue: "threadId must be a non-empty string.",
9696 }),
9797 );
9898 }));
apps/server/src/provider/Layers/ProviderSessionDirectory.ts+60-111
......@@ -1,9 +1,4 @@
1import {
2 ProviderSessionId,
3 ProviderThreadId,
4 ThreadId,
5 type ProviderKind,
6} from "@t3tools/contracts";
1import { ProviderSessionId, type ProviderKind } from "@t3tools/contracts";
72import { Effect, Layer, Option } from "effect";
83
94import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts";
......@@ -27,31 +22,6 @@ function toPersistenceError(operation: string) {
2722 });
2823}
2924
30function normalizeBinding(binding: ProviderSessionBinding): ProviderSessionBinding {
31 const sessionId = binding.sessionId.trim();
32 const threadId = binding.threadId?.trim();
33 const adapterKey = binding.adapterKey?.trim();
34 const providerThreadId =
35 binding.providerThreadId === null ? null : binding.providerThreadId?.trim();
36
37 return {
38 sessionId: ProviderSessionId.makeUnsafe(sessionId),
39 provider: binding.provider,
40 ...(adapterKey !== undefined && adapterKey.length > 0 ? { adapterKey } : {}),
41 ...(threadId !== undefined && threadId.length > 0
42 ? { threadId: ThreadId.makeUnsafe(threadId) }
43 : {}),
44 ...(providerThreadId === null
45 ? { providerThreadId: null }
46 : providerThreadId !== undefined && providerThreadId.length > 0
47 ? { providerThreadId: ProviderThreadId.makeUnsafe(providerThreadId) }
48 : {}),
49 ...(binding.status !== undefined ? { status: binding.status } : {}),
50 ...(binding.resumeCursor !== undefined ? { resumeCursor: binding.resumeCursor } : {}),
51 ...(binding.runtimePayload !== undefined ? { runtimePayload: binding.runtimePayload } : {}),
52 };
53}
54
5525function decodeProviderKind(
5626 providerName: string,
5727 operation: string,
......@@ -90,54 +60,6 @@ function mergeRuntimePayload(
9060const makeProviderSessionDirectory = Effect.gen(function* () {
9161 const repository = yield* ProviderSessionRuntimeRepository;
9262
93 const persistUpsert = (binding: ProviderSessionBinding) =>
94 Effect.gen(function* () {
95 const existing = yield* repository
96 .getBySessionId({
97 providerSessionId: binding.sessionId,
98 })
99 .pipe(
100 Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:getBySessionId")),
101 );
102
103 const existingRuntime = Option.getOrUndefined(existing);
104 const resolvedThreadId = binding.threadId ?? existingRuntime?.threadId;
105 if (!resolvedThreadId) {
106 return yield* new ProviderValidationError({
107 operation: "ProviderSessionDirectory.upsert",
108 issue: "threadId must be a non-empty string.",
109 });
110 }
111
112 const now = new Date().toISOString();
113 yield* repository
114 .upsert({
115 providerSessionId: binding.sessionId,
116 threadId: resolvedThreadId,
117 providerName: binding.provider,
118 adapterKey: binding.adapterKey ?? existingRuntime?.adapterKey ?? binding.provider,
119 providerThreadId: binding.providerThreadId ?? existingRuntime?.providerThreadId ?? null,
120 status: binding.status ?? existingRuntime?.status ?? "running",
121 lastSeenAt: now,
122 resumeCursor:
123 binding.resumeCursor !== undefined
124 ? binding.resumeCursor
125 : (existingRuntime?.resumeCursor ?? null),
126 runtimePayload: mergeRuntimePayload(
127 existingRuntime?.runtimePayload ?? null,
128 binding.runtimePayload,
129 ),
130 })
131 .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:upsert")));
132 });
133
134 const persistDelete = (sessionId: ProviderSessionId) =>
135 repository
136 .deleteBySessionId({ providerSessionId: sessionId })
137 .pipe(
138 Effect.mapError(toPersistenceError("ProviderSessionDirectory.remove:deleteBySessionId")),
139 );
140
14163 const getBinding = (sessionId: ProviderSessionId) =>
14264 repository.getBySessionId({ providerSessionId: sessionId }).pipe(
14365 Effect.mapError(toPersistenceError("ProviderSessionDirectory.getBinding:getBySessionId")),
......@@ -158,38 +80,43 @@ const makeProviderSessionDirectory = Effect.gen(function* () {
15880 ),
15981 );
16082
161 const listBindings = () =>
162 repository.list().pipe(
163 Effect.mapError(toPersistenceError("ProviderSessionDirectory.listSessionIds:list")),
164 Effect.flatMap((rows) =>
165 Effect.forEach(
166 rows,
167 (row) =>
168 decodeProviderKind(row.providerName, "ProviderSessionDirectory.listSessionIds").pipe(
169 Effect.map((provider) => ({
170 sessionId: row.providerSessionId,
171 provider,
172 threadId: row.threadId,
173 })),
174 ),
175 { concurrency: "unbounded" },
176 ),
177 ),
178 );
179
180 const upsert: ProviderSessionDirectoryShape["upsert"] = (binding) => {
181 const normalized = normalizeBinding(binding);
182 if (normalized.sessionId.length === 0) {
183 return Effect.fail(
184 new ProviderValidationError({
185 operation: "ProviderSessionDirectory.upsert",
186 issue: "sessionId must be a non-empty string.",
187 }),
188 );
83 const upsert: ProviderSessionDirectoryShape["upsert"] = Effect.fn(function* (binding) {
84 const existing = yield* repository
85 .getBySessionId({
86 providerSessionId: binding.sessionId,
87 })
88 .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:getBySessionId")));
89
90 const existingRuntime = Option.getOrUndefined(existing);
91 const resolvedThreadId = binding.threadId ?? existingRuntime?.threadId;
92 if (!resolvedThreadId) {
93 return yield* new ProviderValidationError({
94 operation: "ProviderSessionDirectory.upsert",
95 issue: "threadId must be a non-empty string.",
96 });
18997 }
19098
191 return persistUpsert(normalized);
192 };
99 const now = new Date().toISOString();
100 yield* repository
101 .upsert({
102 providerSessionId: binding.sessionId,
103 threadId: resolvedThreadId,
104 providerName: binding.provider,
105 adapterKey: binding.adapterKey ?? existingRuntime?.adapterKey ?? binding.provider,
106 providerThreadId: binding.providerThreadId ?? existingRuntime?.providerThreadId ?? null,
107 status: binding.status ?? existingRuntime?.status ?? "running",
108 lastSeenAt: now,
109 resumeCursor:
110 binding.resumeCursor !== undefined
111 ? binding.resumeCursor
112 : (existingRuntime?.resumeCursor ?? null),
113 runtimePayload: mergeRuntimePayload(
114 existingRuntime?.runtimePayload ?? null,
115 binding.runtimePayload,
116 ),
117 })
118 .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:upsert")));
119 });
193120
194121 const getProvider: ProviderSessionDirectoryShape["getProvider"] = (sessionId) =>
195122 getBinding(sessionId).pipe(
......@@ -211,10 +138,32 @@ const makeProviderSessionDirectory = Effect.gen(function* () {
211138 ),
212139 );
213140
214 const remove: ProviderSessionDirectoryShape["remove"] = (sessionId) => persistDelete(sessionId);
141 const remove: ProviderSessionDirectoryShape["remove"] = (sessionId) =>
142 repository
143 .deleteBySessionId({ providerSessionId: sessionId })
144 .pipe(
145 Effect.mapError(toPersistenceError("ProviderSessionDirectory.remove:deleteBySessionId")),
146 );
215147
216148 const listSessionIds: ProviderSessionDirectoryShape["listSessionIds"] = () =>
217 listBindings().pipe(Effect.map((bindings) => bindings.map((binding) => binding.sessionId)));
149 repository.list().pipe(
150 Effect.mapError(toPersistenceError("ProviderSessionDirectory.listSessionIds:list")),
151 Effect.flatMap((rows) =>
152 Effect.forEach(
153 rows,
154 (row) =>
155 decodeProviderKind(row.providerName, "ProviderSessionDirectory.listSessionIds").pipe(
156 Effect.map((provider) => ({
157 sessionId: row.providerSessionId,
158 provider,
159 threadId: row.threadId,
160 })),
161 ),
162 { concurrency: "unbounded" },
163 ),
164 ),
165 Effect.map((bindings) => bindings.map((binding) => binding.sessionId)),
166 );
218167
219168 return {
220169 upsert,
packages/contracts/src/baseSchemas.ts created+42
......@@ -0,0 +1,42 @@
1import { Schema } from "effect";
2
3export const TrimmedString = Schema.Trim;
4export const TrimmedNonEmptyString = TrimmedString.check(Schema.isNonEmpty());
5
6export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0));
7export const PositiveInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1));
8
9export const IsoDateTime = Schema.String;
10export type IsoDateTime = typeof IsoDateTime.Type;
11
12/**
13 * Construct a branded identifier. Enforces non-empty trimmed strings
14 */
15const makeEntityId = <Brand extends string>(brand: Brand) =>
16 TrimmedNonEmptyString.pipe(Schema.brand(brand));
17
18export const ThreadId = makeEntityId("ThreadId");
19export type ThreadId = typeof ThreadId.Type;
20export const ProjectId = makeEntityId("ProjectId");
21export type ProjectId = typeof ProjectId.Type;
22export const CommandId = makeEntityId("CommandId");
23export type CommandId = typeof CommandId.Type;
24export const EventId = makeEntityId("EventId");
25export type EventId = typeof EventId.Type;
26export const MessageId = makeEntityId("MessageId");
27export type MessageId = typeof MessageId.Type;
28export const TurnId = makeEntityId("TurnId");
29export type TurnId = typeof TurnId.Type;
30
31export const ProviderSessionId = makeEntityId("ProviderSessionId");
32export type ProviderSessionId = typeof ProviderSessionId.Type;
33export const ProviderThreadId = makeEntityId("ProviderThreadId");
34export type ProviderThreadId = typeof ProviderThreadId.Type;
35export const ProviderTurnId = makeEntityId("ProviderTurnId");
36export type ProviderTurnId = typeof ProviderTurnId.Type;
37export const ProviderItemId = makeEntityId("ProviderItemId");
38export type ProviderItemId = typeof ProviderItemId.Type;
39export const ApprovalRequestId = makeEntityId("ApprovalRequestId");
40export type ApprovalRequestId = typeof ApprovalRequestId.Type;
41export const CheckpointRef = makeEntityId("CheckpointRef");
42export type CheckpointRef = typeof CheckpointRef.Type;
packages/contracts/src/editor.ts+2-1
......@@ -1,4 +1,5 @@
11import { Schema } from "effect";
2import { TrimmedNonEmptyString } from "./baseSchemas";
23
34export const EDITORS = [
45 { id: "cursor", label: "Cursor", command: "cursor" },
......@@ -9,7 +10,7 @@ export const EditorId = Schema.Literals(EDITORS.map((e) => e.id));
910export type EditorId = typeof EditorId.Type;
1011
1112export const OpenInEditorInput = Schema.Struct({
12 cwd: Schema.String,
13 cwd: TrimmedNonEmptyString,
1314 editor: EditorId,
1415});
1516export type OpenInEditorInput = typeof OpenInEditorInput.Type;
packages/contracts/src/git.ts+49-46
......@@ -1,6 +1,7 @@
11import { Schema } from "effect";
2import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas";
23
3const TrimmedNonEmptyString = Schema.Trimmed.check(Schema.isNonEmpty());
4const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;
45
56// Domain Types
67
......@@ -19,101 +20,103 @@ export const GitPrStepStatus = Schema.Literals([
1920]);
2021
2122export const GitBranch = Schema.Struct({
22 name: TrimmedNonEmptyString,
23 name: TrimmedNonEmptyStringSchema,
2324 current: Schema.Boolean,
2425 isDefault: Schema.Boolean,
25 worktreePath: TrimmedNonEmptyString.pipe(Schema.NullOr),
26 worktreePath: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr),
2627});
2728export type GitBranch = typeof GitBranch.Type;
2829
2930export const GitWorktree = Schema.Struct({
30 path: TrimmedNonEmptyString,
31 branch: TrimmedNonEmptyString,
31 path: TrimmedNonEmptyStringSchema,
32 branch: TrimmedNonEmptyStringSchema,
3233});
3334export type GitWorktree = typeof GitWorktree.Type;
3435
3536// RPC Inputs
3637
3738export const GitStatusInput = Schema.Struct({
38 cwd: TrimmedNonEmptyString,
39 cwd: TrimmedNonEmptyStringSchema,
3940});
4041export type GitStatusInput = typeof GitStatusInput.Type;
4142
4243export const GitPullInput = Schema.Struct({
43 cwd: TrimmedNonEmptyString,
44 cwd: TrimmedNonEmptyStringSchema,
4445});
4546export type GitPullInput = typeof GitPullInput.Type;
4647
4748export const GitRunStackedActionInput = Schema.Struct({
48 cwd: TrimmedNonEmptyString,
49 cwd: TrimmedNonEmptyStringSchema,
4950 action: GitStackedAction,
50 commitMessage: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(10_000))),
51 commitMessage: Schema.optional(TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(10_000))),
5152});
5253export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type;
5354
5455export const GitListBranchesInput = Schema.Struct({
55 cwd: TrimmedNonEmptyString,
56 cwd: TrimmedNonEmptyStringSchema,
5657});
5758export type GitListBranchesInput = typeof GitListBranchesInput.Type;
5859
5960export const GitCreateWorktreeInput = Schema.Struct({
60 cwd: TrimmedNonEmptyString,
61 branch: TrimmedNonEmptyString,
62 newBranch: TrimmedNonEmptyString,
63 path: TrimmedNonEmptyString.pipe(Schema.NullOr),
61 cwd: TrimmedNonEmptyStringSchema,
62 branch: TrimmedNonEmptyStringSchema,
63 newBranch: TrimmedNonEmptyStringSchema,
64 path: Schema.NullOr(TrimmedNonEmptyStringSchema),
6465});
6566export type GitCreateWorktreeInput = typeof GitCreateWorktreeInput.Type;
6667
6768export const GitRemoveWorktreeInput = Schema.Struct({
68 cwd: TrimmedNonEmptyString,
69 path: TrimmedNonEmptyString,
69 cwd: TrimmedNonEmptyStringSchema,
70 path: TrimmedNonEmptyStringSchema,
7071 force: Schema.optional(Schema.Boolean),
7172});
7273export type GitRemoveWorktreeInput = typeof GitRemoveWorktreeInput.Type;
7374
7475export const GitCreateBranchInput = Schema.Struct({
75 cwd: TrimmedNonEmptyString,
76 branch: TrimmedNonEmptyString,
76 cwd: TrimmedNonEmptyStringSchema,
77 branch: TrimmedNonEmptyStringSchema,
7778});
7879export type GitCreateBranchInput = typeof GitCreateBranchInput.Type;
7980
8081export const GitCheckoutInput = Schema.Struct({
81 cwd: TrimmedNonEmptyString,
82 branch: TrimmedNonEmptyString,
82 cwd: TrimmedNonEmptyStringSchema,
83 branch: TrimmedNonEmptyStringSchema,
8384});
8485export type GitCheckoutInput = typeof GitCheckoutInput.Type;
8586
8687export const GitInitInput = Schema.Struct({
87 cwd: TrimmedNonEmptyString,
88 cwd: TrimmedNonEmptyStringSchema,
8889});
8990export type GitInitInput = typeof GitInitInput.Type;
9091
9192// RPC Results
9293
9394export const GitStatusResult = Schema.Struct({
94 branch: TrimmedNonEmptyString.pipe(Schema.NullOr),
95 branch: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr),
9596 hasWorkingTreeChanges: Schema.Boolean,
9697 workingTree: Schema.Struct({
9798 files: Schema.Array(
9899 Schema.Struct({
99 path: TrimmedNonEmptyString,
100 insertions: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
101 deletions: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
100 path: TrimmedNonEmptyStringSchema,
101 insertions: NonNegativeInt,
102 deletions: NonNegativeInt,
102103 }),
103104 ),
104 insertions: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
105 deletions: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
105 insertions: NonNegativeInt,
106 deletions: NonNegativeInt,
106107 }),
107108 hasUpstream: Schema.Boolean,
108 aheadCount: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
109 behindCount: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),
110 openPr: Schema.Struct({
111 number: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)),
112 title: TrimmedNonEmptyString,
113 url: Schema.String,
114 baseBranch: TrimmedNonEmptyString,
115 headBranch: TrimmedNonEmptyString,
116 }).pipe(Schema.NullOr),
109 aheadCount: NonNegativeInt,
110 behindCount: NonNegativeInt,
111 openPr: Schema.NullOr(
112 Schema.Struct({
113 number: PositiveInt,
114 title: TrimmedNonEmptyStringSchema,
115 url: Schema.String,
116 baseBranch: TrimmedNonEmptyStringSchema,
117 headBranch: TrimmedNonEmptyStringSchema,
118 }),
119 ),
117120});
118121export type GitStatusResult = typeof GitStatusResult.Type;
119122
......@@ -132,29 +135,29 @@ export const GitRunStackedActionResult = Schema.Struct({
132135 action: GitStackedAction,
133136 commit: Schema.Struct({
134137 status: GitCommitStepStatus,
135 commitSha: Schema.optional(TrimmedNonEmptyString),
136 subject: Schema.optional(TrimmedNonEmptyString),
138 commitSha: Schema.optional(TrimmedNonEmptyStringSchema),
139 subject: Schema.optional(TrimmedNonEmptyStringSchema),
137140 }),
138141 push: Schema.Struct({
139142 status: GitPushStepStatus,
140 branch: Schema.optional(TrimmedNonEmptyString),
141 upstreamBranch: Schema.optional(TrimmedNonEmptyString),
143 branch: Schema.optional(TrimmedNonEmptyStringSchema),
144 upstreamBranch: Schema.optional(TrimmedNonEmptyStringSchema),
142145 setUpstream: Schema.optional(Schema.Boolean),
143146 }),
144147 pr: Schema.Struct({
145148 status: GitPrStepStatus,
146149 url: Schema.optional(Schema.String),
147 number: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))),
148 baseBranch: Schema.optional(TrimmedNonEmptyString),
149 headBranch: Schema.optional(TrimmedNonEmptyString),
150 title: Schema.optional(TrimmedNonEmptyString),
150 number: Schema.optional(PositiveInt),
151 baseBranch: Schema.optional(TrimmedNonEmptyStringSchema),
152 headBranch: Schema.optional(TrimmedNonEmptyStringSchema),
153 title: Schema.optional(TrimmedNonEmptyStringSchema),
151154 }),
152155});
153156export type GitRunStackedActionResult = typeof GitRunStackedActionResult.Type;
154157
155158export const GitPullResult = Schema.Struct({
156159 status: Schema.Literals(["pulled", "skipped_up_to_date"]),
157 branch: TrimmedNonEmptyString,
158 upstreamBranch: TrimmedNonEmptyString.pipe(Schema.NullOr),
160 branch: TrimmedNonEmptyStringSchema,
161 upstreamBranch: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr),
159162});
160163export type GitPullResult = typeof GitPullResult.Type;
packages/contracts/src/index.ts+1
......@@ -1,3 +1,4 @@
1export * from "./baseSchemas";
12export * from "./ipc";
23export * from "./terminal";
34export * from "./provider";
packages/contracts/src/keybindings.ts+3-2
......@@ -1,4 +1,5 @@
11import { Schema } from "effect";
2import { TrimmedString } from "./baseSchemas";
23
34export const MAX_KEYBINDING_COMMAND_LENGTH = 96;
45export const MAX_KEYBINDING_VALUE_LENGTH = 64;
......@@ -33,13 +34,13 @@ export const KeybindingCommand = Schema.Union([
3334]);
3435export type KeybindingCommand = typeof KeybindingCommand.Type;
3536
36export const KeybindingValue = Schema.Trim.check(
37export const KeybindingValue = TrimmedString.check(
3738 Schema.isMinLength(1),
3839 Schema.isMaxLength(MAX_KEYBINDING_VALUE_LENGTH),
3940);
4041export type KeybindingValue = typeof KeybindingValue.Type;
4142
42export const KeybindingWhen = Schema.Trim.check(
43export const KeybindingWhen = TrimmedString.check(
4344 Schema.isMinLength(1),
4445 Schema.isMaxLength(MAX_KEYBINDING_WHEN_LENGTH),
4546);
packages/contracts/src/orchestration.test.ts+41-1
......@@ -1,10 +1,15 @@
11import { assert, it } from "@effect/vitest";
22import { Effect, Schema } from "effect";
33
4import { OrchestrationGetTurnDiffInput, ThreadTurnDiff } from "./orchestration";
4import {
5 OrchestrationGetTurnDiffInput,
6 ProjectCreateCommand,
7 ThreadTurnDiff,
8} from "./orchestration";
59
610const decodeTurnDiffInput = Schema.decodeUnknownEffect(OrchestrationGetTurnDiffInput);
711const decodeThreadTurnDiff = Schema.decodeUnknownEffect(ThreadTurnDiff);
12const decodeProjectCreateCommand = Schema.decodeUnknownEffect(ProjectCreateCommand);
813
914it.effect("parses turn diff input when fromTurnCount <= toTurnCount", () =>
1015 Effect.gen(function* () {
......@@ -44,3 +49,38 @@ it.effect("rejects thread turn diff when fromTurnCount > toTurnCount", () =>
4449 assert.strictEqual(result._tag, "Failure");
4550 }),
4651);
52
53it.effect("trims branded ids and command string fields at decode boundaries", () =>
54 Effect.gen(function* () {
55 const parsed = yield* decodeProjectCreateCommand({
56 type: "project.create",
57 commandId: " cmd-1 ",
58 projectId: " project-1 ",
59 title: " Project Title ",
60 workspaceRoot: " /tmp/workspace ",
61 defaultModel: " gpt-5.2 ",
62 createdAt: "2026-01-01T00:00:00.000Z",
63 });
64 assert.strictEqual(parsed.commandId, "cmd-1");
65 assert.strictEqual(parsed.projectId, "project-1");
66 assert.strictEqual(parsed.title, "Project Title");
67 assert.strictEqual(parsed.workspaceRoot, "/tmp/workspace");
68 assert.strictEqual(parsed.defaultModel, "gpt-5.2");
69 }),
70);
71
72it.effect("rejects command fields that become empty after trim", () =>
73 Effect.gen(function* () {
74 const result = yield* Effect.exit(
75 decodeProjectCreateCommand({
76 type: "project.create",
77 commandId: "cmd-1",
78 projectId: "project-1",
79 title: " ",
80 workspaceRoot: "/tmp/workspace",
81 createdAt: "2026-01-01T00:00:00.000Z",
82 }),
83 );
84 assert.strictEqual(result._tag, "Failure");
85 }),
86);
packages/contracts/src/orchestration.ts+69-90
......@@ -1,4 +1,21 @@
11import { Option, Schema, SchemaIssue, Struct } from "effect";
2import {
3 ApprovalRequestId,
4 CheckpointRef,
5 CommandId,
6 EventId,
7 IsoDateTime,
8 MessageId,
9 NonNegativeInt,
10 ProjectId,
11 ProviderItemId,
12 ProviderSessionId,
13 ProviderThreadId,
14 ProviderTurnId,
15 ThreadId,
16 TrimmedNonEmptyString,
17 TurnId,
18} from "./baseSchemas";
219
320export const ORCHESTRATION_WS_METHODS = {
421 getSnapshot: "orchestration.getSnapshot",
......@@ -12,35 +29,6 @@ export const ORCHESTRATION_WS_CHANNELS = {
1229 domainEvent: "orchestration.domainEvent",
1330} as const;
1431
15export const IsoDateTime = Schema.String;
16export type IsoDateTime = typeof IsoDateTime.Type;
17
18export const ThreadId = Schema.String.pipe(Schema.brand("ThreadId"));
19export type ThreadId = typeof ThreadId.Type;
20export const ProjectId = Schema.String.pipe(Schema.brand("ProjectId"));
21export type ProjectId = typeof ProjectId.Type;
22export const CommandId = Schema.String.pipe(Schema.brand("CommandId"));
23export type CommandId = typeof CommandId.Type;
24export const EventId = Schema.String.pipe(Schema.brand("EventId"));
25export type EventId = typeof EventId.Type;
26export const MessageId = Schema.String.pipe(Schema.brand("MessageId"));
27export type MessageId = typeof MessageId.Type;
28export const TurnId = Schema.String.pipe(Schema.brand("TurnId"));
29export type TurnId = typeof TurnId.Type;
30
31export const ProviderSessionId = Schema.String.pipe(Schema.brand("ProviderSessionId"));
32export type ProviderSessionId = typeof ProviderSessionId.Type;
33export const ProviderThreadId = Schema.String.pipe(Schema.brand("ProviderThreadId"));
34export type ProviderThreadId = typeof ProviderThreadId.Type;
35export const ProviderTurnId = Schema.String.pipe(Schema.brand("ProviderTurnId"));
36export type ProviderTurnId = typeof ProviderTurnId.Type;
37export const ProviderItemId = Schema.String.pipe(Schema.brand("ProviderItemId"));
38export type ProviderItemId = typeof ProviderItemId.Type;
39export const ApprovalRequestId = Schema.String.pipe(Schema.brand("ApprovalRequestId"));
40export type ApprovalRequestId = typeof ApprovalRequestId.Type;
41export const CheckpointRef = Schema.String.pipe(Schema.brand("CheckpointRef"));
42export type CheckpointRef = typeof CheckpointRef.Type;
43
4432export const ProviderKind = Schema.Literals(["codex", "claudeCode"]);
4533export type ProviderKind = typeof ProviderKind.Type;
4634export const ProviderApprovalPolicy = Schema.Literals([
......@@ -75,21 +63,12 @@ export const PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS = 14_000_000;
7563export const CorrelationId = CommandId;
7664export type CorrelationId = typeof CorrelationId.Type;
7765
78export const NonNegativeInt = Schema.Number.check(Schema.isInt()).check(
79 Schema.isGreaterThanOrEqualTo(0),
80);
81
82const TrimmedNonEmptyStringSchema = Schema.Trimmed.check(Schema.isNonEmpty());
83
8466export const ChatImageAttachment = Schema.Struct({
8567 type: Schema.Literal("image"),
86 name: TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(255)),
87 mimeType: TrimmedNonEmptyStringSchema.check(
88 Schema.isMaxLength(100),
89 Schema.isPattern(/^image\//i),
90 ),
68 name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)),
69 mimeType: TrimmedNonEmptyString.check(Schema.isMaxLength(100), Schema.isPattern(/^image\//i)),
9170 sizeBytes: NonNegativeInt.check(Schema.isLessThanOrEqualTo(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES)),
92 dataUrl: TrimmedNonEmptyStringSchema.check(
71 dataUrl: TrimmedNonEmptyString.check(
9372 Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS),
9473 ),
9574});
......@@ -109,9 +88,9 @@ export const ProjectScriptIcon = Schema.Literals([
10988export type ProjectScriptIcon = typeof ProjectScriptIcon.Type;
11089
11190export const ProjectScript = Schema.Struct({
112 id: Schema.String,
113 name: Schema.String,
114 command: Schema.String,
91 id: TrimmedNonEmptyString,
92 name: TrimmedNonEmptyString,
93 command: TrimmedNonEmptyString,
11594 icon: ProjectScriptIcon,
11695 runOnWorktreeCreate: Schema.Boolean,
11796});
......@@ -119,9 +98,9 @@ export type ProjectScript = typeof ProjectScript.Type;
11998
12099export const OrchestrationProject = Schema.Struct({
121100 id: ProjectId,
122 title: Schema.String,
123 workspaceRoot: Schema.String,
124 defaultModel: Schema.NullOr(Schema.String),
101 title: TrimmedNonEmptyString,
102 workspaceRoot: TrimmedNonEmptyString,
103 defaultModel: Schema.NullOr(TrimmedNonEmptyString),
125104 scripts: Schema.Array(ProjectScript),
126105 createdAt: IsoDateTime,
127106 updatedAt: IsoDateTime,
......@@ -158,18 +137,18 @@ export type OrchestrationSessionStatus = typeof OrchestrationSessionStatus.Type;
158137export const OrchestrationSession = Schema.Struct({
159138 threadId: ThreadId,
160139 status: OrchestrationSessionStatus,
161 providerName: Schema.NullOr(Schema.String),
140 providerName: Schema.NullOr(TrimmedNonEmptyString),
162141 providerSessionId: Schema.NullOr(ProviderSessionId),
163142 providerThreadId: Schema.NullOr(ProviderThreadId),
164143 activeTurnId: Schema.NullOr(TurnId),
165 lastError: Schema.NullOr(Schema.String),
144 lastError: Schema.NullOr(TrimmedNonEmptyString),
166145 updatedAt: IsoDateTime,
167146});
168147export type OrchestrationSession = typeof OrchestrationSession.Type;
169148
170149export const OrchestrationCheckpointFile = Schema.Struct({
171 path: Schema.String,
172 kind: Schema.String,
150 path: TrimmedNonEmptyString,
151 kind: TrimmedNonEmptyString,
173152 additions: NonNegativeInt,
174153 deletions: NonNegativeInt,
175154});
......@@ -200,8 +179,8 @@ export type OrchestrationThreadActivityTone = typeof OrchestrationThreadActivity
200179export const OrchestrationThreadActivity = Schema.Struct({
201180 id: EventId,
202181 tone: OrchestrationThreadActivityTone,
203 kind: Schema.String,
204 summary: Schema.String,
182 kind: TrimmedNonEmptyString,
183 summary: TrimmedNonEmptyString,
205184 payload: Schema.Unknown,
206185 turnId: Schema.NullOr(TurnId),
207186 createdAt: IsoDateTime,
......@@ -211,10 +190,10 @@ export type OrchestrationThreadActivity = typeof OrchestrationThreadActivity.Typ
211190export const OrchestrationThread = Schema.Struct({
212191 id: ThreadId,
213192 projectId: ProjectId,
214 title: Schema.String,
215 model: Schema.String,
216 branch: Schema.NullOr(Schema.String),
217 worktreePath: Schema.NullOr(Schema.String),
193 title: TrimmedNonEmptyString,
194 model: TrimmedNonEmptyString,
195 branch: Schema.NullOr(TrimmedNonEmptyString),
196 worktreePath: Schema.NullOr(TrimmedNonEmptyString),
218197 latestTurnId: Schema.NullOr(TurnId),
219198 createdAt: IsoDateTime,
220199 updatedAt: IsoDateTime,
......@@ -238,9 +217,9 @@ export const ProjectCreateCommand = Schema.Struct({
238217 type: Schema.Literal("project.create"),
239218 commandId: CommandId,
240219 projectId: ProjectId,
241 title: Schema.String,
242 workspaceRoot: Schema.String,
243 defaultModel: Schema.optional(Schema.String),
220 title: TrimmedNonEmptyString,
221 workspaceRoot: TrimmedNonEmptyString,
222 defaultModel: Schema.optional(TrimmedNonEmptyString),
244223 createdAt: IsoDateTime,
245224});
246225
......@@ -248,9 +227,9 @@ export const ProjectMetaUpdateCommand = Schema.Struct({
248227 type: Schema.Literal("project.meta.update"),
249228 commandId: CommandId,
250229 projectId: ProjectId,
251 title: Schema.optional(Schema.String),
252 workspaceRoot: Schema.optional(Schema.String),
253 defaultModel: Schema.optional(Schema.String),
230 title: Schema.optional(TrimmedNonEmptyString),
231 workspaceRoot: Schema.optional(TrimmedNonEmptyString),
232 defaultModel: Schema.optional(TrimmedNonEmptyString),
254233 scripts: Schema.optional(Schema.Array(ProjectScript)),
255234});
256235
......@@ -265,10 +244,10 @@ export const ThreadCreateCommand = Schema.Struct({
265244 commandId: CommandId,
266245 threadId: ThreadId,
267246 projectId: ProjectId,
268 title: Schema.String,
269 model: Schema.String,
270 branch: Schema.NullOr(Schema.String),
271 worktreePath: Schema.NullOr(Schema.String),
247 title: TrimmedNonEmptyString,
248 model: TrimmedNonEmptyString,
249 branch: Schema.NullOr(TrimmedNonEmptyString),
250 worktreePath: Schema.NullOr(TrimmedNonEmptyString),
272251 createdAt: IsoDateTime,
273252});
274253
......@@ -282,10 +261,10 @@ export const ThreadMetaUpdateCommand = Schema.Struct({
282261 type: Schema.Literal("thread.meta.update"),
283262 commandId: CommandId,
284263 threadId: ThreadId,
285 title: Schema.optional(Schema.String),
286 model: Schema.optional(Schema.String),
287 branch: Schema.optional(Schema.NullOr(Schema.String)),
288 worktreePath: Schema.optional(Schema.NullOr(Schema.String)),
264 title: Schema.optional(TrimmedNonEmptyString),
265 model: Schema.optional(TrimmedNonEmptyString),
266 branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
267 worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
289268});
290269
291270export const ThreadTurnStartCommand = Schema.Struct({
......@@ -298,8 +277,8 @@ export const ThreadTurnStartCommand = Schema.Struct({
298277 text: Schema.String,
299278 attachments: Schema.Array(ChatAttachment),
300279 }),
301 model: Schema.optional(Schema.String),
302 effort: Schema.optional(Schema.String),
280 model: Schema.optional(TrimmedNonEmptyString),
281 effort: Schema.optional(TrimmedNonEmptyString),
303282 createdAt: IsoDateTime,
304283});
305284
......@@ -449,9 +428,9 @@ export const OrchestrationActorKind = Schema.Literals(["client", "server", "prov
449428
450429export const ProjectCreatedPayload = Schema.Struct({
451430 projectId: ProjectId,
452 title: Schema.String,
453 workspaceRoot: Schema.String,
454 defaultModel: Schema.NullOr(Schema.String),
431 title: TrimmedNonEmptyString,
432 workspaceRoot: TrimmedNonEmptyString,
433 defaultModel: Schema.NullOr(TrimmedNonEmptyString),
455434 scripts: Schema.Array(ProjectScript),
456435 createdAt: IsoDateTime,
457436 updatedAt: IsoDateTime,
......@@ -459,9 +438,9 @@ export const ProjectCreatedPayload = Schema.Struct({
459438
460439export const ProjectMetaUpdatedPayload = Schema.Struct({
461440 projectId: ProjectId,
462 title: Schema.optional(Schema.String),
463 workspaceRoot: Schema.optional(Schema.String),
464 defaultModel: Schema.optional(Schema.NullOr(Schema.String)),
441 title: Schema.optional(TrimmedNonEmptyString),
442 workspaceRoot: Schema.optional(TrimmedNonEmptyString),
443 defaultModel: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
465444 scripts: Schema.optional(Schema.Array(ProjectScript)),
466445 updatedAt: IsoDateTime,
467446});
......@@ -474,10 +453,10 @@ export const ProjectDeletedPayload = Schema.Struct({
474453export const ThreadCreatedPayload = Schema.Struct({
475454 threadId: ThreadId,
476455 projectId: ProjectId,
477 title: Schema.String,
478 model: Schema.String,
479 branch: Schema.NullOr(Schema.String),
480 worktreePath: Schema.NullOr(Schema.String),
456 title: TrimmedNonEmptyString,
457 model: TrimmedNonEmptyString,
458 branch: Schema.NullOr(TrimmedNonEmptyString),
459 worktreePath: Schema.NullOr(TrimmedNonEmptyString),
481460 createdAt: IsoDateTime,
482461 updatedAt: IsoDateTime,
483462});
......@@ -489,10 +468,10 @@ export const ThreadDeletedPayload = Schema.Struct({
489468
490469export const ThreadMetaUpdatedPayload = Schema.Struct({
491470 threadId: ThreadId,
492 title: Schema.optional(Schema.String),
493 model: Schema.optional(Schema.String),
494 branch: Schema.optional(Schema.NullOr(Schema.String)),
495 worktreePath: Schema.optional(Schema.NullOr(Schema.String)),
471 title: Schema.optional(TrimmedNonEmptyString),
472 model: Schema.optional(TrimmedNonEmptyString),
473 branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
474 worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
496475 updatedAt: IsoDateTime,
497476});
498477
......@@ -511,8 +490,8 @@ export const ThreadMessageSentPayload = Schema.Struct({
511490export const ThreadTurnStartRequestedPayload = Schema.Struct({
512491 threadId: ThreadId,
513492 messageId: MessageId,
514 model: Schema.optional(Schema.String),
515 effort: Schema.optional(Schema.String),
493 model: Schema.optional(TrimmedNonEmptyString),
494 effort: Schema.optional(TrimmedNonEmptyString),
516495 createdAt: IsoDateTime,
517496});
518497
......@@ -571,7 +550,7 @@ export const OrchestrationEventMetadata = Schema.Struct({
571550 providerThreadId: Schema.optional(ProviderThreadId),
572551 providerTurnId: Schema.optional(ProviderTurnId),
573552 providerItemId: Schema.optional(ProviderItemId),
574 adapterKey: Schema.optional(Schema.String),
553 adapterKey: Schema.optional(TrimmedNonEmptyString),
575554 requestId: Schema.optional(ApprovalRequestId),
576555 ingestedAt: Schema.optional(IsoDateTime),
577556});
packages/contracts/src/project.ts+3-6
......@@ -1,15 +1,12 @@
11import { Schema } from "effect";
2import { PositiveInt, TrimmedNonEmptyString } from "./baseSchemas";
23
34export const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200;
4const TrimmedNonEmptyString = Schema.Trimmed.check(Schema.isNonEmpty());
55
66export const ProjectSearchEntriesInput = Schema.Struct({
77 cwd: TrimmedNonEmptyString,
8 query: Schema.NonEmptyString.check(Schema.isMaxLength(256)),
9 limit: Schema.Int.check(
10 Schema.isGreaterThanOrEqualTo(1),
11 Schema.isLessThanOrEqualTo(PROJECT_SEARCH_ENTRIES_MAX_LIMIT),
12 ),
8 query: TrimmedNonEmptyString.check(Schema.isMaxLength(256)),
9 limit: PositiveInt.check(Schema.isLessThanOrEqualTo(PROJECT_SEARCH_ENTRIES_MAX_LIMIT)),
1310});
1411export type ProjectSearchEntriesInput = typeof ProjectSearchEntriesInput.Type;
1512
packages/contracts/src/provider.ts+10-8
......@@ -1,27 +1,29 @@
11import { Schema } from "effect";
2
2import { TrimmedNonEmptyString } from "./baseSchemas";
33import {
44 ApprovalRequestId,
5 ChatAttachment,
65 EventId,
76 IsoDateTime,
87 NonNegativeInt,
8 ProviderItemId,
9 ProviderSessionId,
10 ProviderThreadId,
11 ProviderTurnId,
12 ThreadId,
13} from "./baseSchemas";
14import {
15 ChatAttachment,
916 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
1017 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
1118 ProviderApprovalDecision,
1219 ProviderApprovalPolicy,
1320 ProviderKind,
14 ProviderItemId,
1521 ProviderRequestKind,
1622 ProviderSandboxMode,
17 ProviderSessionId,
18 ProviderThreadId,
19 ProviderTurnId,
20 ThreadId,
2123 TurnCountRange,
2224} from "./orchestration";
2325
24const TrimmedNonEmptyStringSchema = Schema.Trimmed.check(Schema.isNonEmpty());
26const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;
2527
2628export const ProviderSessionStatus = Schema.Literals([
2729 "connecting",
packages/contracts/src/providerRuntime.ts+14-14
......@@ -1,21 +1,21 @@
11import { Schema } from "effect";
2import { TrimmedNonEmptyString } from "./baseSchemas";
23
34import {
45 ApprovalRequestId,
56 EventId,
6 IsoDateTime,
77 NonNegativeInt,
8 ProviderApprovalDecision,
98 ProviderItemId,
10 ProviderKind,
11 ProviderRequestKind,
129 ProviderSessionId,
1310 ProviderThreadId,
1411 ProviderTurnId,
1512 ThreadId,
1613 TurnId,
17} from "./orchestration";
14 IsoDateTime,
15} from "./baseSchemas";
16import { ProviderApprovalDecision, ProviderKind, ProviderRequestKind } from "./orchestration";
1817
18const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;
1919const RuntimeThreadIdSchema = Schema.Union([ThreadId, ProviderThreadId]);
2020const RuntimeTurnIdSchema = Schema.Union([TurnId, ProviderTurnId]);
2121
......@@ -37,7 +37,7 @@ export const ProviderRuntimeSessionStartedEvent = Schema.Struct({
3737 sessionId: ProviderSessionId,
3838 createdAt: IsoDateTime,
3939 threadId: Schema.optional(RuntimeThreadIdSchema),
40 message: Schema.optional(Schema.String),
40 message: Schema.optional(TrimmedNonEmptyStringSchema),
4141});
4242export type ProviderRuntimeSessionStartedEvent = typeof ProviderRuntimeSessionStartedEvent.Type;
4343
......@@ -48,7 +48,7 @@ export const ProviderRuntimeSessionExitedEvent = Schema.Struct({
4848 sessionId: ProviderSessionId,
4949 createdAt: IsoDateTime,
5050 threadId: Schema.optional(RuntimeThreadIdSchema),
51 message: Schema.optional(Schema.String),
51 message: Schema.optional(TrimmedNonEmptyStringSchema),
5252});
5353export type ProviderRuntimeSessionExitedEvent = typeof ProviderRuntimeSessionExitedEvent.Type;
5454
......@@ -82,7 +82,7 @@ export const ProviderRuntimeTurnCompletedEvent = Schema.Struct({
8282 threadId: Schema.optional(RuntimeThreadIdSchema),
8383 turnId: Schema.optional(RuntimeTurnIdSchema),
8484 status: Schema.optional(ProviderRuntimeTurnStatus),
85 errorMessage: Schema.optional(Schema.String),
85 errorMessage: Schema.optional(TrimmedNonEmptyStringSchema),
8686});
8787export type ProviderRuntimeTurnCompletedEvent = typeof ProviderRuntimeTurnCompletedEvent.Type;
8888
......@@ -121,8 +121,8 @@ export const ProviderRuntimeToolStartedEvent = Schema.Struct({
121121 turnId: Schema.optional(RuntimeTurnIdSchema),
122122 itemId: Schema.optional(ProviderItemId),
123123 toolKind: ProviderRuntimeToolKind,
124 title: Schema.String,
125 detail: Schema.optional(Schema.String),
124 title: TrimmedNonEmptyStringSchema,
125 detail: Schema.optional(TrimmedNonEmptyStringSchema),
126126});
127127export type ProviderRuntimeToolStartedEvent = typeof ProviderRuntimeToolStartedEvent.Type;
128128
......@@ -136,8 +136,8 @@ export const ProviderRuntimeToolCompletedEvent = Schema.Struct({
136136 turnId: Schema.optional(RuntimeTurnIdSchema),
137137 itemId: Schema.optional(ProviderItemId),
138138 toolKind: ProviderRuntimeToolKind,
139 title: Schema.String,
140 detail: Schema.optional(Schema.String),
139 title: TrimmedNonEmptyStringSchema,
140 detail: Schema.optional(TrimmedNonEmptyStringSchema),
141141});
142142export type ProviderRuntimeToolCompletedEvent = typeof ProviderRuntimeToolCompletedEvent.Type;
143143
......@@ -152,7 +152,7 @@ export const ProviderRuntimeApprovalRequestedEvent = Schema.Struct({
152152 itemId: Schema.optional(ProviderItemId),
153153 requestId: ApprovalRequestId,
154154 requestKind: ProviderRequestKind,
155 detail: Schema.optional(Schema.String),
155 detail: Schema.optional(TrimmedNonEmptyStringSchema),
156156});
157157export type ProviderRuntimeApprovalRequestedEvent =
158158 typeof ProviderRuntimeApprovalRequestedEvent.Type;
......@@ -195,7 +195,7 @@ export const ProviderRuntimeErrorEvent = Schema.Struct({
195195 threadId: Schema.optional(RuntimeThreadIdSchema),
196196 turnId: Schema.optional(RuntimeTurnIdSchema),
197197 itemId: Schema.optional(ProviderItemId),
198 message: Schema.String,
198 message: TrimmedNonEmptyStringSchema,
199199});
200200export type ProviderRuntimeErrorEvent = typeof ProviderRuntimeErrorEvent.Type;
201201
packages/contracts/src/server.ts+2-1
......@@ -1,8 +1,9 @@
11import { Schema } from "effect";
2import { TrimmedNonEmptyString } from "./baseSchemas";
23import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings";
34
45export const ServerConfig = Schema.Struct({
5 cwd: Schema.NonEmptyString,
6 cwd: TrimmedNonEmptyString,
67 keybindings: ResolvedKeybindingsConfig,
78});
89export type ServerConfig = typeof ServerConfig.Type;
packages/contracts/src/terminal.ts+4-8
......@@ -1,26 +1,22 @@
11import { Schema } from "effect";
2import { TrimmedNonEmptyString } from "./baseSchemas";
23
34export const DEFAULT_TERMINAL_ID = "default";
45
5const TrimmedNonEmptyStringSchema = Schema.Trim.check(Schema.isNonEmpty());
6const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;
67const TerminalColsSchema = Schema.Int.check(Schema.isGreaterThanOrEqualTo(20)).check(
78 Schema.isLessThanOrEqualTo(400),
89);
910const TerminalRowsSchema = Schema.Int.check(Schema.isGreaterThanOrEqualTo(5)).check(
1011 Schema.isLessThanOrEqualTo(200),
1112);
12const TerminalIdSchema = Schema.Trim.check(Schema.isNonEmpty()).check(Schema.isMaxLength(128));
13const TerminalIdSchema = TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(128));
1314const TerminalEnvKeySchema = Schema.String.check(
1415 Schema.isPattern(/^[A-Za-z_][A-Za-z0-9_]*$/),
1516).check(Schema.isMaxLength(128));
1617const TerminalEnvValueSchema = Schema.String.check(Schema.isMaxLength(8_192));
1718const TerminalEnvSchema = Schema.Record(TerminalEnvKeySchema, TerminalEnvValueSchema).check(
18 Schema.makeFilter<Record<string, string>>((env) => {
19 if (Object.keys(env).length > 128) {
20 return { path: [], message: "Too many terminal env vars" };
21 }
22 return true;
23 }),
19 Schema.isMaxProperties(128),
2420);
2521
2622const TerminalIdWithDefaultSchema = TerminalIdSchema.pipe(
packages/contracts/src/ws.test.ts+19
......@@ -37,3 +37,22 @@ it.effect("rejects getTurnDiff requests when fromTurnCount > toTurnCount", () =>
3737 assert.strictEqual(result._tag, "Failure");
3838 }),
3939);
40
41it.effect("trims websocket request id and nested orchestration ids", () =>
42 Effect.gen(function* () {
43 const parsed = yield* decodeWebSocketRequest({
44 id: " req-1 ",
45 body: {
46 _tag: ORCHESTRATION_WS_METHODS.getTurnDiff,
47 threadId: " thread-1 ",
48 fromTurnCount: 0,
49 toTurnCount: 0,
50 },
51 });
52 assert.strictEqual(parsed.id, "req-1");
53 assert.strictEqual(parsed.body._tag, ORCHESTRATION_WS_METHODS.getTurnDiff);
54 if (parsed.body._tag === ORCHESTRATION_WS_METHODS.getTurnDiff) {
55 assert.strictEqual(parsed.body.threadId, "thread-1");
56 }
57 }),
58);
packages/contracts/src/ws.ts+6-5
......@@ -1,4 +1,5 @@
11import { Schema, Struct } from "effect";
2import { TrimmedNonEmptyString } from "./baseSchemas";
23
34import {
45 ClientOrchestrationCommand,
......@@ -128,13 +129,13 @@ export const WebSocketRequestBody = Schema.Union([
128129]);
129130
130131export const WebSocketRequest = Schema.Struct({
131 id: Schema.String,
132 id: TrimmedNonEmptyString,
132133 body: WebSocketRequestBody,
133134});
134135export type WebSocketRequest = typeof WebSocketRequest.Type;
135136
136137export const WebSocketResponse = Schema.Struct({
137 id: Schema.String,
138 id: TrimmedNonEmptyString,
138139 result: Schema.optional(Schema.Unknown),
139140 error: Schema.optional(
140141 Schema.Struct({
......@@ -146,7 +147,7 @@ export type WebSocketResponse = typeof WebSocketResponse.Type;
146147
147148export const WsPush = Schema.Struct({
148149 type: Schema.Literal("push"),
149 channel: Schema.String,
150 channel: TrimmedNonEmptyString,
150151 data: Schema.Unknown,
151152});
152153export type WsPush = typeof WsPush.Type;
......@@ -159,7 +160,7 @@ export type WsResponse = typeof WsResponse.Type;
159160// ── Server welcome payload ───────────────────────────────────────────
160161
161162export const WsWelcomePayload = Schema.Struct({
162 cwd: Schema.String,
163 projectName: Schema.String,
163 cwd: TrimmedNonEmptyString,
164 projectName: TrimmedNonEmptyString,
164165});
165166export type WsWelcomePayload = typeof WsWelcomePayload.Type;