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* () {...@@ -78,7 +78,7 @@ const makeCheckpointStore = Effect.gen(function* () {
78 git78 git
79 .execute({79 .execute({
80 operation: "CheckpointStore.isGitRepository",80 operation: "CheckpointStore.isGitRepository",
81 cwd: cwd.trim(),81 cwd,
82 args: ["rev-parse", "--is-inside-work-tree"],82 args: ["rev-parse", "--is-inside-work-tree"],
83 allowNonZeroExit: true,83 allowNonZeroExit: true,
84 })84 })
...@@ -90,8 +90,6 @@ const makeCheckpointStore = Effect.gen(function* () {...@@ -90,8 +90,6 @@ const makeCheckpointStore = Effect.gen(function* () {
90 const captureCheckpoint: CheckpointStoreShape["captureCheckpoint"] = (input) =>90 const captureCheckpoint: CheckpointStoreShape["captureCheckpoint"] = (input) =>
91 Effect.gen(function* () {91 Effect.gen(function* () {
92 const operation = "CheckpointStore.captureCheckpoint";92 const operation = "CheckpointStore.captureCheckpoint";
93 const normalizedCwd = input.cwd.trim();
94 const checkpointRef = input.checkpointRef.trim();
9593
96 yield* Effect.acquireUseRelease(94 yield* Effect.acquireUseRelease(
97 fs.makeTempDirectory({ prefix: "t3-fs-checkpoint-" }),95 fs.makeTempDirectory({ prefix: "t3-fs-checkpoint-" }),
...@@ -107,11 +105,11 @@ const makeCheckpointStore = Effect.gen(function* () {...@@ -107,11 +105,11 @@ const makeCheckpointStore = Effect.gen(function* () {
107 GIT_COMMITTER_EMAIL: "t3code@users.noreply.github.com",105 GIT_COMMITTER_EMAIL: "t3code@users.noreply.github.com",
108 };106 };
109107
110 const headExists = yield* hasHeadCommit(normalizedCwd);108 const headExists = yield* hasHeadCommit(input.cwd);
111 if (headExists) {109 if (headExists) {
112 yield* git.execute({110 yield* git.execute({
113 operation,111 operation,
114 cwd: normalizedCwd,112 cwd: input.cwd,
115 args: ["read-tree", "HEAD"],113 args: ["read-tree", "HEAD"],
116 env: commitEnv,114 env: commitEnv,
117 });115 });
...@@ -119,14 +117,14 @@ const makeCheckpointStore = Effect.gen(function* () {...@@ -119,14 +117,14 @@ const makeCheckpointStore = Effect.gen(function* () {
119117
120 yield* git.execute({118 yield* git.execute({
121 operation,119 operation,
122 cwd: normalizedCwd,120 cwd: input.cwd,
123 args: ["add", "-A", "--", "."],121 args: ["add", "-A", "--", "."],
124 env: commitEnv,122 env: commitEnv,
125 });123 });
126124
127 const writeTreeResult = yield* git.execute({125 const writeTreeResult = yield* git.execute({
128 operation,126 operation,
129 cwd: normalizedCwd,127 cwd: input.cwd,
130 args: ["write-tree"],128 args: ["write-tree"],
131 env: commitEnv,129 env: commitEnv,
132 });130 });
...@@ -135,15 +133,15 @@ const makeCheckpointStore = Effect.gen(function* () {...@@ -135,15 +133,15 @@ const makeCheckpointStore = Effect.gen(function* () {
135 return yield* new GitCommandError({133 return yield* new GitCommandError({
136 operation,134 operation,
137 command: "git write-tree",135 command: "git write-tree",
138 cwd: normalizedCwd,136 cwd: input.cwd,
139 detail: "git write-tree returned an empty tree oid.",137 detail: "git write-tree returned an empty tree oid.",
140 });138 });
141 }139 }
142140
143 const message = `t3 checkpoint ref=${checkpointRef}`;141 const message = `t3 checkpoint ref=${input.checkpointRef}`;
144 const commitTreeResult = yield* git.execute({142 const commitTreeResult = yield* git.execute({
145 operation,143 operation,
146 cwd: normalizedCwd,144 cwd: input.cwd,
147 args: ["commit-tree", treeOid, "-m", message],145 args: ["commit-tree", treeOid, "-m", message],
148 env: commitEnv,146 env: commitEnv,
149 });147 });
...@@ -152,15 +150,15 @@ const makeCheckpointStore = Effect.gen(function* () {...@@ -152,15 +150,15 @@ const makeCheckpointStore = Effect.gen(function* () {
152 return yield* new GitCommandError({150 return yield* new GitCommandError({
153 operation,151 operation,
154 command: "git commit-tree",152 command: "git commit-tree",
155 cwd: normalizedCwd,153 cwd: input.cwd,
156 detail: "git commit-tree returned an empty commit oid.",154 detail: "git commit-tree returned an empty commit oid.",
157 });155 });
158 }156 }
159157
160 yield* git.execute({158 yield* git.execute({
161 operation,159 operation,
162 cwd: normalizedCwd,160 cwd: input.cwd,
163 args: ["update-ref", checkpointRef, commitOid],161 args: ["update-ref", input.checkpointRef, commitOid],
164 });162 });
165 }),163 }),
166 (tempDir) => fs.remove(tempDir, { recursive: true }),164 (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...@@ -9,11 +9,6 @@ export function checkpointRefForThreadTurn(threadId: ThreadId, turnCount: number
9 );9 );
10}10}
1111
12function trimToNonEmpty(value: string | null | undefined): string | undefined {
13 const normalized = value?.trim();
14 return normalized && normalized.length > 0 ? normalized : undefined;
15}
16
17export function resolveThreadWorkspaceCwd(input: {12export function resolveThreadWorkspaceCwd(input: {
18 readonly thread: {13 readonly thread: {
19 readonly projectId: ProjectId;14 readonly projectId: ProjectId;
...@@ -24,13 +19,10 @@ export function resolveThreadWorkspaceCwd(input: {...@@ -24,13 +19,10 @@ export function resolveThreadWorkspaceCwd(input: {
24 readonly workspaceRoot: string;19 readonly workspaceRoot: string;
25 }>;20 }>;
26}): string | undefined {21}): string | undefined {
27 const worktreeCwd = trimToNonEmpty(input.thread.worktreePath);22 const worktreeCwd = input.thread.worktreePath ?? undefined;
28 if (worktreeCwd) {23 if (worktreeCwd) {
29 return worktreeCwd;24 return worktreeCwd;
30 }25 }
3126
32 const workspaceRoot = input.projects.find(27 return input.projects.find((project) => project.id === input.thread.projectId)?.workspaceRoot;
33 (project) => project.id === input.thread.projectId,
34 )?.workspaceRoot;
35 return trimToNonEmpty(workspaceRoot);
36}28}
apps/server/src/codexAppServerManager.ts+2-2
...@@ -169,8 +169,8 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve...@@ -169,8 +169,8 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve
169 updatedAt: now,169 updatedAt: now,
170 };170 };
171171
172 const codexBinaryPath = input.codexBinaryPath?.trim() || "codex";172 const codexBinaryPath = input.codexBinaryPath ?? "codex";
173 const codexHomePath = input.codexHomePath?.trim();173 const codexHomePath = input.codexHomePath;
174 const child = spawn(codexBinaryPath, ["app-server"], {174 const child = spawn(codexBinaryPath, ["app-server"], {
175 cwd: resolvedCwd,175 cwd: resolvedCwd,
176 env: {176 env: {
apps/server/src/git/Layers/GitService.ts-2
...@@ -71,10 +71,8 @@ const makeGitService = Effect.gen(function* () {...@@ -71,10 +71,8 @@ const makeGitService = Effect.gen(function* () {
71 const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;71 const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
7272
73 const execute: GitServiceShape["execute"] = Effect.fnUntraced(function* (input) {73 const execute: GitServiceShape["execute"] = Effect.fnUntraced(function* (input) {
74 const normalizedCwd = input.cwd.trim();
75 const commandInput = {74 const commandInput = {
76 ...input,75 ...input,
77 cwd: normalizedCwd,
78 args: [...input.args],76 args: [...input.args],
79 } as const;77 } as const;
80 const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;78 const timeoutMs = input.timeoutMs ?? DEFAULT_TIMEOUT_MS;
apps/server/src/keybindings.ts+3-6
...@@ -265,14 +265,11 @@ function parseKeybindingWhenExpression(expression: string): KeybindingWhenNode |...@@ -265,14 +265,11 @@ function parseKeybindingWhenExpression(expression: string): KeybindingWhenNode |
265265
266/** @internal - Exported for testing */266/** @internal - Exported for testing */
267export function compileResolvedKeybindingRule(rule: KeybindingRule): ResolvedKeybindingRule | null {267export function compileResolvedKeybindingRule(rule: KeybindingRule): ResolvedKeybindingRule | null {
268 const key = rule.key.trim();268 const shortcut = parseKeybindingShortcut(rule.key);
269 if (key.length === 0) return null;
270 const shortcut = parseKeybindingShortcut(key);
271 if (!shortcut) return null;269 if (!shortcut) return null;
272270
273 const when = rule.when?.trim();271 if (rule.when !== undefined) {
274 if (when && when.length > 0) {272 const whenAst = parseKeybindingWhenExpression(rule.when);
275 const whenAst = parseKeybindingWhenExpression(when);
276 if (!whenAst) return null;273 if (!whenAst) return null;
277 return {274 return {
278 command: rule.command,275 command: rule.command,
apps/server/src/orchestration/Layers/CheckpointReactor.ts+5-12
...@@ -33,13 +33,7 @@ type ReactorInput =...@@ -33,13 +33,7 @@ type ReactorInput =
33 };33 };
3434
35function toTurnId(value: string | undefined): TurnId | null {35function toTurnId(value: string | undefined): TurnId | null {
36 const normalized = value?.trim();36 return value === undefined ? null : TurnId.makeUnsafe(value);
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;
43}37}
4438
45function checkpointStatusFromRuntime(status: string | undefined): "ready" | "missing" | "error" {39function checkpointStatusFromRuntime(status: string | undefined): "ready" | "missing" | "error" {
...@@ -129,18 +123,17 @@ const make = Effect.gen(function* () {...@@ -129,18 +123,17 @@ const make = Effect.gen(function* () {
129 const readModel = yield* orchestrationEngine.getReadModel();123 const readModel = yield* orchestrationEngine.getReadModel();
130 const thread = readModel.threads.find((entry) => entry.id === threadId);124 const thread = readModel.threads.find((entry) => entry.id === threadId);
131 const projectedSessionId = thread?.session?.providerSessionId ?? null;125 const projectedSessionId = thread?.session?.providerSessionId ?? null;
132 const projectedProviderThreadId = trimToNonEmpty(thread?.session?.providerThreadId);126 const projectedProviderThreadId = thread?.session?.providerThreadId ?? undefined;
133127
134 const sessions = yield* providerService.listSessions();128 const sessions = yield* providerService.listSessions();
135129
136 const findSessionWithCwd = (130 const findSessionWithCwd = (
137 session: (typeof sessions)[number] | undefined,131 session: (typeof sessions)[number] | undefined,
138 ): Option.Option<{ readonly sessionId: ProviderSessionId; readonly cwd: string }> => {132 ): Option.Option<{ readonly sessionId: ProviderSessionId; readonly cwd: string }> => {
139 const cwd = session?.cwd?.trim();133 if (!session?.cwd) {
140 if (!session || !cwd) {
141 return Option.none();134 return Option.none();
142 }135 }
143 return Option.some({ sessionId: session.sessionId, cwd });136 return Option.some({ sessionId: session.sessionId, cwd: session.cwd });
144 };137 };
145138
146 if (projectedSessionId !== null) {139 if (projectedSessionId !== null) {
...@@ -153,7 +146,7 @@ const make = Effect.gen(function* () {...@@ -153,7 +146,7 @@ const make = Effect.gen(function* () {
153146
154 if (projectedProviderThreadId) {147 if (projectedProviderThreadId) {
155 const matchedSession = sessions.find(148 const matchedSession = sessions.find(
156 (session) => session.threadId?.trim() === projectedProviderThreadId,149 (session) => session.threadId === projectedProviderThreadId,
157 );150 );
158 const fromProviderThread = findSessionWithCwd(matchedSession);151 const fromProviderThread = findSessionWithCwd(matchedSession);
159 if (Option.isSome(fromProviderThread)) {152 if (Option.isSome(fromProviderThread)) {
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+7-10
...@@ -28,7 +28,7 @@ type ProviderIntentEvent = Extract<...@@ -28,7 +28,7 @@ type ProviderIntentEvent = Extract<
28 }28 }
29>;29>;
3030
31function trimToNonEmptyOrUndefined(value: string | undefined): string | undefined {31function toNonEmptyProviderInput(value: string | undefined): string | undefined {
32 const normalized = value?.trim();32 const normalized = value?.trim();
33 return normalized && normalized.length > 0 ? normalized : undefined;33 return normalized && normalized.length > 0 ? normalized : undefined;
34}34}
...@@ -43,15 +43,12 @@ function resolveEffectiveThreadCwd(input: {...@@ -43,15 +43,12 @@ function resolveEffectiveThreadCwd(input: {
43 readonly workspaceRoot: string;43 readonly workspaceRoot: string;
44 }>;44 }>;
45}): string | undefined {45}): string | undefined {
46 const worktreeCwd = trimToNonEmptyOrUndefined(input.thread.worktreePath ?? undefined);46 const worktreeCwd = input.thread.worktreePath ?? undefined;
47 if (worktreeCwd) {47 if (worktreeCwd) {
48 return worktreeCwd;48 return worktreeCwd;
49 }49 }
5050
51 const workspaceRoot = input.projects.find(51 return input.projects.find((project) => project.id === input.thread.projectId)?.workspaceRoot;
52 (project) => project.id === input.thread.projectId,
53 )?.workspaceRoot;
54 return trimToNonEmptyOrUndefined(workspaceRoot);
55}52}
5653
57function mapProviderSessionStatusToOrchestrationStatus(54function mapProviderSessionStatusToOrchestrationStatus(
...@@ -158,7 +155,7 @@ const make = Effect.gen(function* () {...@@ -158,7 +155,7 @@ const make = Effect.gen(function* () {
158 const startedSession = yield* providerService.startSession({155 const startedSession = yield* providerService.startSession({
159 ...(preferredProvider ? { provider: preferredProvider } : {}),156 ...(preferredProvider ? { provider: preferredProvider } : {}),
160 ...(effectiveCwd ? { cwd: effectiveCwd } : {}),157 ...(effectiveCwd ? { cwd: effectiveCwd } : {}),
161 ...(trimToNonEmptyOrUndefined(thread.model) ? { model: thread.model } : {}),158 ...(thread.model ? { model: thread.model } : {}),
162 });159 });
163160
164 yield* setThreadSession({161 yield* setThreadSession({
...@@ -193,15 +190,15 @@ const make = Effect.gen(function* () {...@@ -193,15 +190,15 @@ const make = Effect.gen(function* () {
193 return;190 return;
194 }191 }
195 const sessionId = yield* ensureSessionForThread(input.threadId, input.createdAt);192 const sessionId = yield* ensureSessionForThread(input.threadId, input.createdAt);
196 const normalizedInput = trimToNonEmptyOrUndefined(input.messageText);193 const normalizedInput = toNonEmptyProviderInput(input.messageText);
197 const normalizedAttachments = input.attachments ?? [];194 const normalizedAttachments = input.attachments ?? [];
198195
199 yield* providerService.sendTurn({196 yield* providerService.sendTurn({
200 sessionId,197 sessionId,
201 ...(normalizedInput ? { input: normalizedInput } : {}),198 ...(normalizedInput ? { input: normalizedInput } : {}),
202 ...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}),199 ...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}),
203 ...(trimToNonEmptyOrUndefined(input.model) ? { model: input.model } : {}),200 ...(input.model !== undefined ? { model: input.model } : {}),
204 ...(trimToNonEmptyOrUndefined(input.effort) ? { effort: input.effort } : {}),201 ...(input.effort !== undefined ? { effort: input.effort } : {}),
205 });202 });
206 });203 });
207204
apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+1-2
...@@ -21,8 +21,7 @@ const providerCommandId = (event: ProviderRuntimeEvent, tag: string): CommandId...@@ -21,8 +21,7 @@ const providerCommandId = (event: ProviderRuntimeEvent, tag: string): CommandId
21 CommandId.makeUnsafe(`provider:${event.eventId}:${tag}:${crypto.randomUUID()}`);21 CommandId.makeUnsafe(`provider:${event.eventId}:${tag}:${crypto.randomUUID()}`);
2222
23function toTurnId(value: string | undefined): TurnId | undefined {23function toTurnId(value: string | undefined): TurnId | undefined {
24 const normalized = value?.trim();24 return value === undefined ? undefined : TurnId.makeUnsafe(value);
25 return normalized?.length ? TurnId.makeUnsafe(normalized) : undefined;
26}25}
2726
28function truncateDetail(value: string, limit = 180): string {27function truncateDetail(value: string, limit = 180): string {
apps/server/src/provider/Layers/ProviderService.ts+2-1
...@@ -242,8 +242,9 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>...@@ -242,8 +242,9 @@ const makeProviderService = (options?: ProviderServiceLiveOptions) =>
242 Effect.gen(function* () {242 Effect.gen(function* () {
243 const adapter = yield* registry.getByProvider(input.provider);243 const adapter = yield* registry.getByProvider(input.provider);
244 const activeSessions = yield* adapter.listSessions();244 const activeSessions = yield* adapter.listSessions();
245 const expectedProviderThreadId = asProviderThreadId(input.threadId);
245 const existing = activeSessions.find(246 const existing = activeSessions.find(
246 (session) => session.threadId?.trim() === input.threadId.trim(),247 (session) => session.threadId === expectedProviderThreadId,
247 );248 );
248 if (existing) {249 if (existing) {
249 const existingThreadId = yield* upsertSessionBinding(250 const existingThreadId = yield* upsertSessionBinding(
apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts+3-3
...@@ -79,12 +79,12 @@ layer("ProviderSessionDirectoryLive", (it) => {...@@ -79,12 +79,12 @@ layer("ProviderSessionDirectoryLive", (it) => {
79 assertFailure(missingProvider, new ProviderSessionNotFoundError({ sessionId: "sess-1" }));79 assertFailure(missingProvider, new ProviderSessionNotFoundError({ sessionId: "sess-1" }));
80 }));80 }));
8181
82 it("fails upsert for empty session id", () =>82 it("fails upsert when thread id is unavailable", () =>
83 Effect.gen(function* () {83 Effect.gen(function* () {
84 const directory = yield* ProviderSessionDirectory;84 const directory = yield* ProviderSessionDirectory;
85 const result = yield* Effect.result(85 const result = yield* Effect.result(
86 directory.upsert({86 directory.upsert({
87 sessionId: sessionId(" "),87 sessionId: sessionId("sess-no-thread"),
88 provider: "codex",88 provider: "codex",
89 }),89 }),
90 );90 );
...@@ -92,7 +92,7 @@ layer("ProviderSessionDirectoryLive", (it) => {...@@ -92,7 +92,7 @@ layer("ProviderSessionDirectoryLive", (it) => {
92 result,92 result,
93 new ProviderValidationError({93 new ProviderValidationError({
94 operation: "ProviderSessionDirectory.upsert",94 operation: "ProviderSessionDirectory.upsert",
95 issue: "sessionId must be a non-empty string.",95 issue: "threadId must be a non-empty string.",
96 }),96 }),
97 );97 );
98 }));98 }));
apps/server/src/provider/Layers/ProviderSessionDirectory.ts+60-111
...@@ -1,9 +1,4 @@...@@ -1,9 +1,4 @@
1import {1import { ProviderSessionId, type ProviderKind } from "@t3tools/contracts";
2 ProviderSessionId,
3 ProviderThreadId,
4 ThreadId,
5 type ProviderKind,
6} from "@t3tools/contracts";
7import { Effect, Layer, Option } from "effect";2import { Effect, Layer, Option } from "effect";
83
9import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts";4import { ProviderSessionRuntimeRepository } from "../../persistence/Services/ProviderSessionRuntime.ts";
...@@ -27,31 +22,6 @@ function toPersistenceError(operation: string) {...@@ -27,31 +22,6 @@ function toPersistenceError(operation: string) {
27 });22 });
28}23}
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
55function decodeProviderKind(25function decodeProviderKind(
56 providerName: string,26 providerName: string,
57 operation: string,27 operation: string,
...@@ -90,54 +60,6 @@ function mergeRuntimePayload(...@@ -90,54 +60,6 @@ function mergeRuntimePayload(
90const makeProviderSessionDirectory = Effect.gen(function* () {60const makeProviderSessionDirectory = Effect.gen(function* () {
91 const repository = yield* ProviderSessionRuntimeRepository;61 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
141 const getBinding = (sessionId: ProviderSessionId) =>63 const getBinding = (sessionId: ProviderSessionId) =>
142 repository.getBySessionId({ providerSessionId: sessionId }).pipe(64 repository.getBySessionId({ providerSessionId: sessionId }).pipe(
143 Effect.mapError(toPersistenceError("ProviderSessionDirectory.getBinding:getBySessionId")),65 Effect.mapError(toPersistenceError("ProviderSessionDirectory.getBinding:getBySessionId")),
...@@ -158,38 +80,43 @@ const makeProviderSessionDirectory = Effect.gen(function* () {...@@ -158,38 +80,43 @@ const makeProviderSessionDirectory = Effect.gen(function* () {
158 ),80 ),
159 );81 );
16082
161 const listBindings = () =>83 const upsert: ProviderSessionDirectoryShape["upsert"] = Effect.fn(function* (binding) {
162 repository.list().pipe(84 const existing = yield* repository
163 Effect.mapError(toPersistenceError("ProviderSessionDirectory.listSessionIds:list")),85 .getBySessionId({
164 Effect.flatMap((rows) =>86 providerSessionId: binding.sessionId,
165 Effect.forEach(87 })
166 rows,88 .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:getBySessionId")));
167 (row) =>89
168 decodeProviderKind(row.providerName, "ProviderSessionDirectory.listSessionIds").pipe(90 const existingRuntime = Option.getOrUndefined(existing);
169 Effect.map((provider) => ({91 const resolvedThreadId = binding.threadId ?? existingRuntime?.threadId;
170 sessionId: row.providerSessionId,92 if (!resolvedThreadId) {
171 provider,93 return yield* new ProviderValidationError({
172 threadId: row.threadId,94 operation: "ProviderSessionDirectory.upsert",
173 })),95 issue: "threadId must be a non-empty string.",
174 ),96 });
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 );
189 }97 }
19098
191 return persistUpsert(normalized);99 const now = new Date().toISOString();
192 };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
194 const getProvider: ProviderSessionDirectoryShape["getProvider"] = (sessionId) =>121 const getProvider: ProviderSessionDirectoryShape["getProvider"] = (sessionId) =>
195 getBinding(sessionId).pipe(122 getBinding(sessionId).pipe(
...@@ -211,10 +138,32 @@ const makeProviderSessionDirectory = Effect.gen(function* () {...@@ -211,10 +138,32 @@ const makeProviderSessionDirectory = Effect.gen(function* () {
211 ),138 ),
212 );139 );
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
216 const listSessionIds: ProviderSessionDirectoryShape["listSessionIds"] = () =>148 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
219 return {168 return {
220 upsert,169 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 @@...@@ -1,4 +1,5 @@
1import { Schema } from "effect";1import { Schema } from "effect";
2import { TrimmedNonEmptyString } from "./baseSchemas";
23
3export const EDITORS = [4export const EDITORS = [
4 { id: "cursor", label: "Cursor", command: "cursor" },5 { id: "cursor", label: "Cursor", command: "cursor" },
...@@ -9,7 +10,7 @@ export const EditorId = Schema.Literals(EDITORS.map((e) => e.id));...@@ -9,7 +10,7 @@ export const EditorId = Schema.Literals(EDITORS.map((e) => e.id));
9export type EditorId = typeof EditorId.Type;10export type EditorId = typeof EditorId.Type;
1011
11export const OpenInEditorInput = Schema.Struct({12export const OpenInEditorInput = Schema.Struct({
12 cwd: Schema.String,13 cwd: TrimmedNonEmptyString,
13 editor: EditorId,14 editor: EditorId,
14});15});
15export type OpenInEditorInput = typeof OpenInEditorInput.Type;16export type OpenInEditorInput = typeof OpenInEditorInput.Type;
packages/contracts/src/git.ts+49-46
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { Schema } from "effect";1import { Schema } from "effect";
2import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas";
23
3const TrimmedNonEmptyString = Schema.Trimmed.check(Schema.isNonEmpty());4const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;
45
5// Domain Types6// Domain Types
67
...@@ -19,101 +20,103 @@ export const GitPrStepStatus = Schema.Literals([...@@ -19,101 +20,103 @@ export const GitPrStepStatus = Schema.Literals([
19]);20]);
2021
21export const GitBranch = Schema.Struct({22export const GitBranch = Schema.Struct({
22 name: TrimmedNonEmptyString,23 name: TrimmedNonEmptyStringSchema,
23 current: Schema.Boolean,24 current: Schema.Boolean,
24 isDefault: Schema.Boolean,25 isDefault: Schema.Boolean,
25 worktreePath: TrimmedNonEmptyString.pipe(Schema.NullOr),26 worktreePath: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr),
26});27});
27export type GitBranch = typeof GitBranch.Type;28export type GitBranch = typeof GitBranch.Type;
2829
29export const GitWorktree = Schema.Struct({30export const GitWorktree = Schema.Struct({
30 path: TrimmedNonEmptyString,31 path: TrimmedNonEmptyStringSchema,
31 branch: TrimmedNonEmptyString,32 branch: TrimmedNonEmptyStringSchema,
32});33});
33export type GitWorktree = typeof GitWorktree.Type;34export type GitWorktree = typeof GitWorktree.Type;
3435
35// RPC Inputs36// RPC Inputs
3637
37export const GitStatusInput = Schema.Struct({38export const GitStatusInput = Schema.Struct({
38 cwd: TrimmedNonEmptyString,39 cwd: TrimmedNonEmptyStringSchema,
39});40});
40export type GitStatusInput = typeof GitStatusInput.Type;41export type GitStatusInput = typeof GitStatusInput.Type;
4142
42export const GitPullInput = Schema.Struct({43export const GitPullInput = Schema.Struct({
43 cwd: TrimmedNonEmptyString,44 cwd: TrimmedNonEmptyStringSchema,
44});45});
45export type GitPullInput = typeof GitPullInput.Type;46export type GitPullInput = typeof GitPullInput.Type;
4647
47export const GitRunStackedActionInput = Schema.Struct({48export const GitRunStackedActionInput = Schema.Struct({
48 cwd: TrimmedNonEmptyString,49 cwd: TrimmedNonEmptyStringSchema,
49 action: GitStackedAction,50 action: GitStackedAction,
50 commitMessage: Schema.optional(TrimmedNonEmptyString.check(Schema.isMaxLength(10_000))),51 commitMessage: Schema.optional(TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(10_000))),
51});52});
52export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type;53export type GitRunStackedActionInput = typeof GitRunStackedActionInput.Type;
5354
54export const GitListBranchesInput = Schema.Struct({55export const GitListBranchesInput = Schema.Struct({
55 cwd: TrimmedNonEmptyString,56 cwd: TrimmedNonEmptyStringSchema,
56});57});
57export type GitListBranchesInput = typeof GitListBranchesInput.Type;58export type GitListBranchesInput = typeof GitListBranchesInput.Type;
5859
59export const GitCreateWorktreeInput = Schema.Struct({60export const GitCreateWorktreeInput = Schema.Struct({
60 cwd: TrimmedNonEmptyString,61 cwd: TrimmedNonEmptyStringSchema,
61 branch: TrimmedNonEmptyString,62 branch: TrimmedNonEmptyStringSchema,
62 newBranch: TrimmedNonEmptyString,63 newBranch: TrimmedNonEmptyStringSchema,
63 path: TrimmedNonEmptyString.pipe(Schema.NullOr),64 path: Schema.NullOr(TrimmedNonEmptyStringSchema),
64});65});
65export type GitCreateWorktreeInput = typeof GitCreateWorktreeInput.Type;66export type GitCreateWorktreeInput = typeof GitCreateWorktreeInput.Type;
6667
67export const GitRemoveWorktreeInput = Schema.Struct({68export const GitRemoveWorktreeInput = Schema.Struct({
68 cwd: TrimmedNonEmptyString,69 cwd: TrimmedNonEmptyStringSchema,
69 path: TrimmedNonEmptyString,70 path: TrimmedNonEmptyStringSchema,
70 force: Schema.optional(Schema.Boolean),71 force: Schema.optional(Schema.Boolean),
71});72});
72export type GitRemoveWorktreeInput = typeof GitRemoveWorktreeInput.Type;73export type GitRemoveWorktreeInput = typeof GitRemoveWorktreeInput.Type;
7374
74export const GitCreateBranchInput = Schema.Struct({75export const GitCreateBranchInput = Schema.Struct({
75 cwd: TrimmedNonEmptyString,76 cwd: TrimmedNonEmptyStringSchema,
76 branch: TrimmedNonEmptyString,77 branch: TrimmedNonEmptyStringSchema,
77});78});
78export type GitCreateBranchInput = typeof GitCreateBranchInput.Type;79export type GitCreateBranchInput = typeof GitCreateBranchInput.Type;
7980
80export const GitCheckoutInput = Schema.Struct({81export const GitCheckoutInput = Schema.Struct({
81 cwd: TrimmedNonEmptyString,82 cwd: TrimmedNonEmptyStringSchema,
82 branch: TrimmedNonEmptyString,83 branch: TrimmedNonEmptyStringSchema,
83});84});
84export type GitCheckoutInput = typeof GitCheckoutInput.Type;85export type GitCheckoutInput = typeof GitCheckoutInput.Type;
8586
86export const GitInitInput = Schema.Struct({87export const GitInitInput = Schema.Struct({
87 cwd: TrimmedNonEmptyString,88 cwd: TrimmedNonEmptyStringSchema,
88});89});
89export type GitInitInput = typeof GitInitInput.Type;90export type GitInitInput = typeof GitInitInput.Type;
9091
91// RPC Results92// RPC Results
9293
93export const GitStatusResult = Schema.Struct({94export const GitStatusResult = Schema.Struct({
94 branch: TrimmedNonEmptyString.pipe(Schema.NullOr),95 branch: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr),
95 hasWorkingTreeChanges: Schema.Boolean,96 hasWorkingTreeChanges: Schema.Boolean,
96 workingTree: Schema.Struct({97 workingTree: Schema.Struct({
97 files: Schema.Array(98 files: Schema.Array(
98 Schema.Struct({99 Schema.Struct({
99 path: TrimmedNonEmptyString,100 path: TrimmedNonEmptyStringSchema,
100 insertions: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),101 insertions: NonNegativeInt,
101 deletions: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),102 deletions: NonNegativeInt,
102 }),103 }),
103 ),104 ),
104 insertions: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),105 insertions: NonNegativeInt,
105 deletions: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),106 deletions: NonNegativeInt,
106 }),107 }),
107 hasUpstream: Schema.Boolean,108 hasUpstream: Schema.Boolean,
108 aheadCount: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),109 aheadCount: NonNegativeInt,
109 behindCount: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)),110 behindCount: NonNegativeInt,
110 openPr: Schema.Struct({111 openPr: Schema.NullOr(
111 number: Schema.Int.check(Schema.isGreaterThanOrEqualTo(1)),112 Schema.Struct({
112 title: TrimmedNonEmptyString,113 number: PositiveInt,
113 url: Schema.String,114 title: TrimmedNonEmptyStringSchema,
114 baseBranch: TrimmedNonEmptyString,115 url: Schema.String,
115 headBranch: TrimmedNonEmptyString,116 baseBranch: TrimmedNonEmptyStringSchema,
116 }).pipe(Schema.NullOr),117 headBranch: TrimmedNonEmptyStringSchema,
118 }),
119 ),
117});120});
118export type GitStatusResult = typeof GitStatusResult.Type;121export type GitStatusResult = typeof GitStatusResult.Type;
119122
...@@ -132,29 +135,29 @@ export const GitRunStackedActionResult = Schema.Struct({...@@ -132,29 +135,29 @@ export const GitRunStackedActionResult = Schema.Struct({
132 action: GitStackedAction,135 action: GitStackedAction,
133 commit: Schema.Struct({136 commit: Schema.Struct({
134 status: GitCommitStepStatus,137 status: GitCommitStepStatus,
135 commitSha: Schema.optional(TrimmedNonEmptyString),138 commitSha: Schema.optional(TrimmedNonEmptyStringSchema),
136 subject: Schema.optional(TrimmedNonEmptyString),139 subject: Schema.optional(TrimmedNonEmptyStringSchema),
137 }),140 }),
138 push: Schema.Struct({141 push: Schema.Struct({
139 status: GitPushStepStatus,142 status: GitPushStepStatus,
140 branch: Schema.optional(TrimmedNonEmptyString),143 branch: Schema.optional(TrimmedNonEmptyStringSchema),
141 upstreamBranch: Schema.optional(TrimmedNonEmptyString),144 upstreamBranch: Schema.optional(TrimmedNonEmptyStringSchema),
142 setUpstream: Schema.optional(Schema.Boolean),145 setUpstream: Schema.optional(Schema.Boolean),
143 }),146 }),
144 pr: Schema.Struct({147 pr: Schema.Struct({
145 status: GitPrStepStatus,148 status: GitPrStepStatus,
146 url: Schema.optional(Schema.String),149 url: Schema.optional(Schema.String),
147 number: Schema.optional(Schema.Int.check(Schema.isGreaterThanOrEqualTo(1))),150 number: Schema.optional(PositiveInt),
148 baseBranch: Schema.optional(TrimmedNonEmptyString),151 baseBranch: Schema.optional(TrimmedNonEmptyStringSchema),
149 headBranch: Schema.optional(TrimmedNonEmptyString),152 headBranch: Schema.optional(TrimmedNonEmptyStringSchema),
150 title: Schema.optional(TrimmedNonEmptyString),153 title: Schema.optional(TrimmedNonEmptyStringSchema),
151 }),154 }),
152});155});
153export type GitRunStackedActionResult = typeof GitRunStackedActionResult.Type;156export type GitRunStackedActionResult = typeof GitRunStackedActionResult.Type;
154157
155export const GitPullResult = Schema.Struct({158export const GitPullResult = Schema.Struct({
156 status: Schema.Literals(["pulled", "skipped_up_to_date"]),159 status: Schema.Literals(["pulled", "skipped_up_to_date"]),
157 branch: TrimmedNonEmptyString,160 branch: TrimmedNonEmptyStringSchema,
158 upstreamBranch: TrimmedNonEmptyString.pipe(Schema.NullOr),161 upstreamBranch: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr),
159});162});
160export type GitPullResult = typeof GitPullResult.Type;163export type GitPullResult = typeof GitPullResult.Type;
packages/contracts/src/index.ts+1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1export * from "./baseSchemas";
1export * from "./ipc";2export * from "./ipc";
2export * from "./terminal";3export * from "./terminal";
3export * from "./provider";4export * from "./provider";
packages/contracts/src/keybindings.ts+3-2
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import { Schema } from "effect";1import { Schema } from "effect";
2import { TrimmedString } from "./baseSchemas";
23
3export const MAX_KEYBINDING_COMMAND_LENGTH = 96;4export const MAX_KEYBINDING_COMMAND_LENGTH = 96;
4export const MAX_KEYBINDING_VALUE_LENGTH = 64;5export const MAX_KEYBINDING_VALUE_LENGTH = 64;
...@@ -33,13 +34,13 @@ export const KeybindingCommand = Schema.Union([...@@ -33,13 +34,13 @@ export const KeybindingCommand = Schema.Union([
33]);34]);
34export type KeybindingCommand = typeof KeybindingCommand.Type;35export type KeybindingCommand = typeof KeybindingCommand.Type;
3536
36export const KeybindingValue = Schema.Trim.check(37export const KeybindingValue = TrimmedString.check(
37 Schema.isMinLength(1),38 Schema.isMinLength(1),
38 Schema.isMaxLength(MAX_KEYBINDING_VALUE_LENGTH),39 Schema.isMaxLength(MAX_KEYBINDING_VALUE_LENGTH),
39);40);
40export type KeybindingValue = typeof KeybindingValue.Type;41export type KeybindingValue = typeof KeybindingValue.Type;
4142
42export const KeybindingWhen = Schema.Trim.check(43export const KeybindingWhen = TrimmedString.check(
43 Schema.isMinLength(1),44 Schema.isMinLength(1),
44 Schema.isMaxLength(MAX_KEYBINDING_WHEN_LENGTH),45 Schema.isMaxLength(MAX_KEYBINDING_WHEN_LENGTH),
45);46);
packages/contracts/src/orchestration.test.ts+41-1
...@@ -1,10 +1,15 @@...@@ -1,10 +1,15 @@
1import { assert, it } from "@effect/vitest";1import { assert, it } from "@effect/vitest";
2import { Effect, Schema } from "effect";2import { Effect, Schema } from "effect";
33
4import { OrchestrationGetTurnDiffInput, ThreadTurnDiff } from "./orchestration";4import {
5 OrchestrationGetTurnDiffInput,
6 ProjectCreateCommand,
7 ThreadTurnDiff,
8} from "./orchestration";
59
6const decodeTurnDiffInput = Schema.decodeUnknownEffect(OrchestrationGetTurnDiffInput);10const decodeTurnDiffInput = Schema.decodeUnknownEffect(OrchestrationGetTurnDiffInput);
7const decodeThreadTurnDiff = Schema.decodeUnknownEffect(ThreadTurnDiff);11const decodeThreadTurnDiff = Schema.decodeUnknownEffect(ThreadTurnDiff);
12const decodeProjectCreateCommand = Schema.decodeUnknownEffect(ProjectCreateCommand);
813
9it.effect("parses turn diff input when fromTurnCount <= toTurnCount", () =>14it.effect("parses turn diff input when fromTurnCount <= toTurnCount", () =>
10 Effect.gen(function* () {15 Effect.gen(function* () {
...@@ -44,3 +49,38 @@ it.effect("rejects thread turn diff when fromTurnCount > toTurnCount", () =>...@@ -44,3 +49,38 @@ it.effect("rejects thread turn diff when fromTurnCount > toTurnCount", () =>
44 assert.strictEqual(result._tag, "Failure");49 assert.strictEqual(result._tag, "Failure");
45 }),50 }),
46);51);
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 @@...@@ -1,4 +1,21 @@
1import { Option, Schema, SchemaIssue, Struct } from "effect";1import { 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
3export const ORCHESTRATION_WS_METHODS = {20export const ORCHESTRATION_WS_METHODS = {
4 getSnapshot: "orchestration.getSnapshot",21 getSnapshot: "orchestration.getSnapshot",
...@@ -12,35 +29,6 @@ export const ORCHESTRATION_WS_CHANNELS = {...@@ -12,35 +29,6 @@ export const ORCHESTRATION_WS_CHANNELS = {
12 domainEvent: "orchestration.domainEvent",29 domainEvent: "orchestration.domainEvent",
13} as const;30} 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
44export const ProviderKind = Schema.Literals(["codex", "claudeCode"]);32export const ProviderKind = Schema.Literals(["codex", "claudeCode"]);
45export type ProviderKind = typeof ProviderKind.Type;33export type ProviderKind = typeof ProviderKind.Type;
46export const ProviderApprovalPolicy = Schema.Literals([34export const ProviderApprovalPolicy = Schema.Literals([
...@@ -75,21 +63,12 @@ export const PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS = 14_000_000;...@@ -75,21 +63,12 @@ export const PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS = 14_000_000;
75export const CorrelationId = CommandId;63export const CorrelationId = CommandId;
76export type CorrelationId = typeof CorrelationId.Type;64export 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
84export const ChatImageAttachment = Schema.Struct({66export const ChatImageAttachment = Schema.Struct({
85 type: Schema.Literal("image"),67 type: Schema.Literal("image"),
86 name: TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(255)),68 name: TrimmedNonEmptyString.check(Schema.isMaxLength(255)),
87 mimeType: TrimmedNonEmptyStringSchema.check(69 mimeType: TrimmedNonEmptyString.check(Schema.isMaxLength(100), Schema.isPattern(/^image\//i)),
88 Schema.isMaxLength(100),
89 Schema.isPattern(/^image\//i),
90 ),
91 sizeBytes: NonNegativeInt.check(Schema.isLessThanOrEqualTo(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES)),70 sizeBytes: NonNegativeInt.check(Schema.isLessThanOrEqualTo(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES)),
92 dataUrl: TrimmedNonEmptyStringSchema.check(71 dataUrl: TrimmedNonEmptyString.check(
93 Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS),72 Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_IMAGE_DATA_URL_CHARS),
94 ),73 ),
95});74});
...@@ -109,9 +88,9 @@ export const ProjectScriptIcon = Schema.Literals([...@@ -109,9 +88,9 @@ export const ProjectScriptIcon = Schema.Literals([
109export type ProjectScriptIcon = typeof ProjectScriptIcon.Type;88export type ProjectScriptIcon = typeof ProjectScriptIcon.Type;
11089
111export const ProjectScript = Schema.Struct({90export const ProjectScript = Schema.Struct({
112 id: Schema.String,91 id: TrimmedNonEmptyString,
113 name: Schema.String,92 name: TrimmedNonEmptyString,
114 command: Schema.String,93 command: TrimmedNonEmptyString,
115 icon: ProjectScriptIcon,94 icon: ProjectScriptIcon,
116 runOnWorktreeCreate: Schema.Boolean,95 runOnWorktreeCreate: Schema.Boolean,
117});96});
...@@ -119,9 +98,9 @@ export type ProjectScript = typeof ProjectScript.Type;...@@ -119,9 +98,9 @@ export type ProjectScript = typeof ProjectScript.Type;
11998
120export const OrchestrationProject = Schema.Struct({99export const OrchestrationProject = Schema.Struct({
121 id: ProjectId,100 id: ProjectId,
122 title: Schema.String,101 title: TrimmedNonEmptyString,
123 workspaceRoot: Schema.String,102 workspaceRoot: TrimmedNonEmptyString,
124 defaultModel: Schema.NullOr(Schema.String),103 defaultModel: Schema.NullOr(TrimmedNonEmptyString),
125 scripts: Schema.Array(ProjectScript),104 scripts: Schema.Array(ProjectScript),
126 createdAt: IsoDateTime,105 createdAt: IsoDateTime,
127 updatedAt: IsoDateTime,106 updatedAt: IsoDateTime,
...@@ -158,18 +137,18 @@ export type OrchestrationSessionStatus = typeof OrchestrationSessionStatus.Type;...@@ -158,18 +137,18 @@ export type OrchestrationSessionStatus = typeof OrchestrationSessionStatus.Type;
158export const OrchestrationSession = Schema.Struct({137export const OrchestrationSession = Schema.Struct({
159 threadId: ThreadId,138 threadId: ThreadId,
160 status: OrchestrationSessionStatus,139 status: OrchestrationSessionStatus,
161 providerName: Schema.NullOr(Schema.String),140 providerName: Schema.NullOr(TrimmedNonEmptyString),
162 providerSessionId: Schema.NullOr(ProviderSessionId),141 providerSessionId: Schema.NullOr(ProviderSessionId),
163 providerThreadId: Schema.NullOr(ProviderThreadId),142 providerThreadId: Schema.NullOr(ProviderThreadId),
164 activeTurnId: Schema.NullOr(TurnId),143 activeTurnId: Schema.NullOr(TurnId),
165 lastError: Schema.NullOr(Schema.String),144 lastError: Schema.NullOr(TrimmedNonEmptyString),
166 updatedAt: IsoDateTime,145 updatedAt: IsoDateTime,
167});146});
168export type OrchestrationSession = typeof OrchestrationSession.Type;147export type OrchestrationSession = typeof OrchestrationSession.Type;
169148
170export const OrchestrationCheckpointFile = Schema.Struct({149export const OrchestrationCheckpointFile = Schema.Struct({
171 path: Schema.String,150 path: TrimmedNonEmptyString,
172 kind: Schema.String,151 kind: TrimmedNonEmptyString,
173 additions: NonNegativeInt,152 additions: NonNegativeInt,
174 deletions: NonNegativeInt,153 deletions: NonNegativeInt,
175});154});
...@@ -200,8 +179,8 @@ export type OrchestrationThreadActivityTone = typeof OrchestrationThreadActivity...@@ -200,8 +179,8 @@ export type OrchestrationThreadActivityTone = typeof OrchestrationThreadActivity
200export const OrchestrationThreadActivity = Schema.Struct({179export const OrchestrationThreadActivity = Schema.Struct({
201 id: EventId,180 id: EventId,
202 tone: OrchestrationThreadActivityTone,181 tone: OrchestrationThreadActivityTone,
203 kind: Schema.String,182 kind: TrimmedNonEmptyString,
204 summary: Schema.String,183 summary: TrimmedNonEmptyString,
205 payload: Schema.Unknown,184 payload: Schema.Unknown,
206 turnId: Schema.NullOr(TurnId),185 turnId: Schema.NullOr(TurnId),
207 createdAt: IsoDateTime,186 createdAt: IsoDateTime,
...@@ -211,10 +190,10 @@ export type OrchestrationThreadActivity = typeof OrchestrationThreadActivity.Typ...@@ -211,10 +190,10 @@ export type OrchestrationThreadActivity = typeof OrchestrationThreadActivity.Typ
211export const OrchestrationThread = Schema.Struct({190export const OrchestrationThread = Schema.Struct({
212 id: ThreadId,191 id: ThreadId,
213 projectId: ProjectId,192 projectId: ProjectId,
214 title: Schema.String,193 title: TrimmedNonEmptyString,
215 model: Schema.String,194 model: TrimmedNonEmptyString,
216 branch: Schema.NullOr(Schema.String),195 branch: Schema.NullOr(TrimmedNonEmptyString),
217 worktreePath: Schema.NullOr(Schema.String),196 worktreePath: Schema.NullOr(TrimmedNonEmptyString),
218 latestTurnId: Schema.NullOr(TurnId),197 latestTurnId: Schema.NullOr(TurnId),
219 createdAt: IsoDateTime,198 createdAt: IsoDateTime,
220 updatedAt: IsoDateTime,199 updatedAt: IsoDateTime,
...@@ -238,9 +217,9 @@ export const ProjectCreateCommand = Schema.Struct({...@@ -238,9 +217,9 @@ export const ProjectCreateCommand = Schema.Struct({
238 type: Schema.Literal("project.create"),217 type: Schema.Literal("project.create"),
239 commandId: CommandId,218 commandId: CommandId,
240 projectId: ProjectId,219 projectId: ProjectId,
241 title: Schema.String,220 title: TrimmedNonEmptyString,
242 workspaceRoot: Schema.String,221 workspaceRoot: TrimmedNonEmptyString,
243 defaultModel: Schema.optional(Schema.String),222 defaultModel: Schema.optional(TrimmedNonEmptyString),
244 createdAt: IsoDateTime,223 createdAt: IsoDateTime,
245});224});
246225
...@@ -248,9 +227,9 @@ export const ProjectMetaUpdateCommand = Schema.Struct({...@@ -248,9 +227,9 @@ export const ProjectMetaUpdateCommand = Schema.Struct({
248 type: Schema.Literal("project.meta.update"),227 type: Schema.Literal("project.meta.update"),
249 commandId: CommandId,228 commandId: CommandId,
250 projectId: ProjectId,229 projectId: ProjectId,
251 title: Schema.optional(Schema.String),230 title: Schema.optional(TrimmedNonEmptyString),
252 workspaceRoot: Schema.optional(Schema.String),231 workspaceRoot: Schema.optional(TrimmedNonEmptyString),
253 defaultModel: Schema.optional(Schema.String),232 defaultModel: Schema.optional(TrimmedNonEmptyString),
254 scripts: Schema.optional(Schema.Array(ProjectScript)),233 scripts: Schema.optional(Schema.Array(ProjectScript)),
255});234});
256235
...@@ -265,10 +244,10 @@ export const ThreadCreateCommand = Schema.Struct({...@@ -265,10 +244,10 @@ export const ThreadCreateCommand = Schema.Struct({
265 commandId: CommandId,244 commandId: CommandId,
266 threadId: ThreadId,245 threadId: ThreadId,
267 projectId: ProjectId,246 projectId: ProjectId,
268 title: Schema.String,247 title: TrimmedNonEmptyString,
269 model: Schema.String,248 model: TrimmedNonEmptyString,
270 branch: Schema.NullOr(Schema.String),249 branch: Schema.NullOr(TrimmedNonEmptyString),
271 worktreePath: Schema.NullOr(Schema.String),250 worktreePath: Schema.NullOr(TrimmedNonEmptyString),
272 createdAt: IsoDateTime,251 createdAt: IsoDateTime,
273});252});
274253
...@@ -282,10 +261,10 @@ export const ThreadMetaUpdateCommand = Schema.Struct({...@@ -282,10 +261,10 @@ export const ThreadMetaUpdateCommand = Schema.Struct({
282 type: Schema.Literal("thread.meta.update"),261 type: Schema.Literal("thread.meta.update"),
283 commandId: CommandId,262 commandId: CommandId,
284 threadId: ThreadId,263 threadId: ThreadId,
285 title: Schema.optional(Schema.String),264 title: Schema.optional(TrimmedNonEmptyString),
286 model: Schema.optional(Schema.String),265 model: Schema.optional(TrimmedNonEmptyString),
287 branch: Schema.optional(Schema.NullOr(Schema.String)),266 branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
288 worktreePath: Schema.optional(Schema.NullOr(Schema.String)),267 worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
289});268});
290269
291export const ThreadTurnStartCommand = Schema.Struct({270export const ThreadTurnStartCommand = Schema.Struct({
...@@ -298,8 +277,8 @@ export const ThreadTurnStartCommand = Schema.Struct({...@@ -298,8 +277,8 @@ export const ThreadTurnStartCommand = Schema.Struct({
298 text: Schema.String,277 text: Schema.String,
299 attachments: Schema.Array(ChatAttachment),278 attachments: Schema.Array(ChatAttachment),
300 }),279 }),
301 model: Schema.optional(Schema.String),280 model: Schema.optional(TrimmedNonEmptyString),
302 effort: Schema.optional(Schema.String),281 effort: Schema.optional(TrimmedNonEmptyString),
303 createdAt: IsoDateTime,282 createdAt: IsoDateTime,
304});283});
305284
...@@ -449,9 +428,9 @@ export const OrchestrationActorKind = Schema.Literals(["client", "server", "prov...@@ -449,9 +428,9 @@ export const OrchestrationActorKind = Schema.Literals(["client", "server", "prov
449428
450export const ProjectCreatedPayload = Schema.Struct({429export const ProjectCreatedPayload = Schema.Struct({
451 projectId: ProjectId,430 projectId: ProjectId,
452 title: Schema.String,431 title: TrimmedNonEmptyString,
453 workspaceRoot: Schema.String,432 workspaceRoot: TrimmedNonEmptyString,
454 defaultModel: Schema.NullOr(Schema.String),433 defaultModel: Schema.NullOr(TrimmedNonEmptyString),
455 scripts: Schema.Array(ProjectScript),434 scripts: Schema.Array(ProjectScript),
456 createdAt: IsoDateTime,435 createdAt: IsoDateTime,
457 updatedAt: IsoDateTime,436 updatedAt: IsoDateTime,
...@@ -459,9 +438,9 @@ export const ProjectCreatedPayload = Schema.Struct({...@@ -459,9 +438,9 @@ export const ProjectCreatedPayload = Schema.Struct({
459438
460export const ProjectMetaUpdatedPayload = Schema.Struct({439export const ProjectMetaUpdatedPayload = Schema.Struct({
461 projectId: ProjectId,440 projectId: ProjectId,
462 title: Schema.optional(Schema.String),441 title: Schema.optional(TrimmedNonEmptyString),
463 workspaceRoot: Schema.optional(Schema.String),442 workspaceRoot: Schema.optional(TrimmedNonEmptyString),
464 defaultModel: Schema.optional(Schema.NullOr(Schema.String)),443 defaultModel: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
465 scripts: Schema.optional(Schema.Array(ProjectScript)),444 scripts: Schema.optional(Schema.Array(ProjectScript)),
466 updatedAt: IsoDateTime,445 updatedAt: IsoDateTime,
467});446});
...@@ -474,10 +453,10 @@ export const ProjectDeletedPayload = Schema.Struct({...@@ -474,10 +453,10 @@ export const ProjectDeletedPayload = Schema.Struct({
474export const ThreadCreatedPayload = Schema.Struct({453export const ThreadCreatedPayload = Schema.Struct({
475 threadId: ThreadId,454 threadId: ThreadId,
476 projectId: ProjectId,455 projectId: ProjectId,
477 title: Schema.String,456 title: TrimmedNonEmptyString,
478 model: Schema.String,457 model: TrimmedNonEmptyString,
479 branch: Schema.NullOr(Schema.String),458 branch: Schema.NullOr(TrimmedNonEmptyString),
480 worktreePath: Schema.NullOr(Schema.String),459 worktreePath: Schema.NullOr(TrimmedNonEmptyString),
481 createdAt: IsoDateTime,460 createdAt: IsoDateTime,
482 updatedAt: IsoDateTime,461 updatedAt: IsoDateTime,
483});462});
...@@ -489,10 +468,10 @@ export const ThreadDeletedPayload = Schema.Struct({...@@ -489,10 +468,10 @@ export const ThreadDeletedPayload = Schema.Struct({
489468
490export const ThreadMetaUpdatedPayload = Schema.Struct({469export const ThreadMetaUpdatedPayload = Schema.Struct({
491 threadId: ThreadId,470 threadId: ThreadId,
492 title: Schema.optional(Schema.String),471 title: Schema.optional(TrimmedNonEmptyString),
493 model: Schema.optional(Schema.String),472 model: Schema.optional(TrimmedNonEmptyString),
494 branch: Schema.optional(Schema.NullOr(Schema.String)),473 branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
495 worktreePath: Schema.optional(Schema.NullOr(Schema.String)),474 worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
496 updatedAt: IsoDateTime,475 updatedAt: IsoDateTime,
497});476});
498477
...@@ -511,8 +490,8 @@ export const ThreadMessageSentPayload = Schema.Struct({...@@ -511,8 +490,8 @@ export const ThreadMessageSentPayload = Schema.Struct({
511export const ThreadTurnStartRequestedPayload = Schema.Struct({490export const ThreadTurnStartRequestedPayload = Schema.Struct({
512 threadId: ThreadId,491 threadId: ThreadId,
513 messageId: MessageId,492 messageId: MessageId,
514 model: Schema.optional(Schema.String),493 model: Schema.optional(TrimmedNonEmptyString),
515 effort: Schema.optional(Schema.String),494 effort: Schema.optional(TrimmedNonEmptyString),
516 createdAt: IsoDateTime,495 createdAt: IsoDateTime,
517});496});
518497
...@@ -571,7 +550,7 @@ export const OrchestrationEventMetadata = Schema.Struct({...@@ -571,7 +550,7 @@ export const OrchestrationEventMetadata = Schema.Struct({
571 providerThreadId: Schema.optional(ProviderThreadId),550 providerThreadId: Schema.optional(ProviderThreadId),
572 providerTurnId: Schema.optional(ProviderTurnId),551 providerTurnId: Schema.optional(ProviderTurnId),
573 providerItemId: Schema.optional(ProviderItemId),552 providerItemId: Schema.optional(ProviderItemId),
574 adapterKey: Schema.optional(Schema.String),553 adapterKey: Schema.optional(TrimmedNonEmptyString),
575 requestId: Schema.optional(ApprovalRequestId),554 requestId: Schema.optional(ApprovalRequestId),
576 ingestedAt: Schema.optional(IsoDateTime),555 ingestedAt: Schema.optional(IsoDateTime),
577});556});
packages/contracts/src/project.ts+3-6
...@@ -1,15 +1,12 @@...@@ -1,15 +1,12 @@
1import { Schema } from "effect";1import { Schema } from "effect";
2import { PositiveInt, TrimmedNonEmptyString } from "./baseSchemas";
23
3export const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200;4export const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200;
4const TrimmedNonEmptyString = Schema.Trimmed.check(Schema.isNonEmpty());
55
6export const ProjectSearchEntriesInput = Schema.Struct({6export const ProjectSearchEntriesInput = Schema.Struct({
7 cwd: TrimmedNonEmptyString,7 cwd: TrimmedNonEmptyString,
8 query: Schema.NonEmptyString.check(Schema.isMaxLength(256)),8 query: TrimmedNonEmptyString.check(Schema.isMaxLength(256)),
9 limit: Schema.Int.check(9 limit: PositiveInt.check(Schema.isLessThanOrEqualTo(PROJECT_SEARCH_ENTRIES_MAX_LIMIT)),
10 Schema.isGreaterThanOrEqualTo(1),
11 Schema.isLessThanOrEqualTo(PROJECT_SEARCH_ENTRIES_MAX_LIMIT),
12 ),
13});10});
14export type ProjectSearchEntriesInput = typeof ProjectSearchEntriesInput.Type;11export type ProjectSearchEntriesInput = typeof ProjectSearchEntriesInput.Type;
1512
packages/contracts/src/provider.ts+10-8
...@@ -1,27 +1,29 @@...@@ -1,27 +1,29 @@
1import { Schema } from "effect";1import { Schema } from "effect";
22import { TrimmedNonEmptyString } from "./baseSchemas";
3import {3import {
4 ApprovalRequestId,4 ApprovalRequestId,
5 ChatAttachment,
6 EventId,5 EventId,
7 IsoDateTime,6 IsoDateTime,
8 NonNegativeInt,7 NonNegativeInt,
8 ProviderItemId,
9 ProviderSessionId,
10 ProviderThreadId,
11 ProviderTurnId,
12 ThreadId,
13} from "./baseSchemas";
14import {
15 ChatAttachment,
9 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,16 PROVIDER_SEND_TURN_MAX_ATTACHMENTS,
10 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,17 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
11 ProviderApprovalDecision,18 ProviderApprovalDecision,
12 ProviderApprovalPolicy,19 ProviderApprovalPolicy,
13 ProviderKind,20 ProviderKind,
14 ProviderItemId,
15 ProviderRequestKind,21 ProviderRequestKind,
16 ProviderSandboxMode,22 ProviderSandboxMode,
17 ProviderSessionId,
18 ProviderThreadId,
19 ProviderTurnId,
20 ThreadId,
21 TurnCountRange,23 TurnCountRange,
22} from "./orchestration";24} from "./orchestration";
2325
24const TrimmedNonEmptyStringSchema = Schema.Trimmed.check(Schema.isNonEmpty());26const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;
2527
26export const ProviderSessionStatus = Schema.Literals([28export const ProviderSessionStatus = Schema.Literals([
27 "connecting",29 "connecting",
packages/contracts/src/providerRuntime.ts+14-14
...@@ -1,21 +1,21 @@...@@ -1,21 +1,21 @@
1import { Schema } from "effect";1import { Schema } from "effect";
2import { TrimmedNonEmptyString } from "./baseSchemas";
23
3import {4import {
4 ApprovalRequestId,5 ApprovalRequestId,
5 EventId,6 EventId,
6 IsoDateTime,
7 NonNegativeInt,7 NonNegativeInt,
8 ProviderApprovalDecision,
9 ProviderItemId,8 ProviderItemId,
10 ProviderKind,
11 ProviderRequestKind,
12 ProviderSessionId,9 ProviderSessionId,
13 ProviderThreadId,10 ProviderThreadId,
14 ProviderTurnId,11 ProviderTurnId,
15 ThreadId,12 ThreadId,
16 TurnId,13 TurnId,
17} from "./orchestration";14 IsoDateTime,
15} from "./baseSchemas";
16import { ProviderApprovalDecision, ProviderKind, ProviderRequestKind } from "./orchestration";
1817
18const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;
19const RuntimeThreadIdSchema = Schema.Union([ThreadId, ProviderThreadId]);19const RuntimeThreadIdSchema = Schema.Union([ThreadId, ProviderThreadId]);
20const RuntimeTurnIdSchema = Schema.Union([TurnId, ProviderTurnId]);20const RuntimeTurnIdSchema = Schema.Union([TurnId, ProviderTurnId]);
2121
...@@ -37,7 +37,7 @@ export const ProviderRuntimeSessionStartedEvent = Schema.Struct({...@@ -37,7 +37,7 @@ export const ProviderRuntimeSessionStartedEvent = Schema.Struct({
37 sessionId: ProviderSessionId,37 sessionId: ProviderSessionId,
38 createdAt: IsoDateTime,38 createdAt: IsoDateTime,
39 threadId: Schema.optional(RuntimeThreadIdSchema),39 threadId: Schema.optional(RuntimeThreadIdSchema),
40 message: Schema.optional(Schema.String),40 message: Schema.optional(TrimmedNonEmptyStringSchema),
41});41});
42export type ProviderRuntimeSessionStartedEvent = typeof ProviderRuntimeSessionStartedEvent.Type;42export type ProviderRuntimeSessionStartedEvent = typeof ProviderRuntimeSessionStartedEvent.Type;
4343
...@@ -48,7 +48,7 @@ export const ProviderRuntimeSessionExitedEvent = Schema.Struct({...@@ -48,7 +48,7 @@ export const ProviderRuntimeSessionExitedEvent = Schema.Struct({
48 sessionId: ProviderSessionId,48 sessionId: ProviderSessionId,
49 createdAt: IsoDateTime,49 createdAt: IsoDateTime,
50 threadId: Schema.optional(RuntimeThreadIdSchema),50 threadId: Schema.optional(RuntimeThreadIdSchema),
51 message: Schema.optional(Schema.String),51 message: Schema.optional(TrimmedNonEmptyStringSchema),
52});52});
53export type ProviderRuntimeSessionExitedEvent = typeof ProviderRuntimeSessionExitedEvent.Type;53export type ProviderRuntimeSessionExitedEvent = typeof ProviderRuntimeSessionExitedEvent.Type;
5454
...@@ -82,7 +82,7 @@ export const ProviderRuntimeTurnCompletedEvent = Schema.Struct({...@@ -82,7 +82,7 @@ export const ProviderRuntimeTurnCompletedEvent = Schema.Struct({
82 threadId: Schema.optional(RuntimeThreadIdSchema),82 threadId: Schema.optional(RuntimeThreadIdSchema),
83 turnId: Schema.optional(RuntimeTurnIdSchema),83 turnId: Schema.optional(RuntimeTurnIdSchema),
84 status: Schema.optional(ProviderRuntimeTurnStatus),84 status: Schema.optional(ProviderRuntimeTurnStatus),
85 errorMessage: Schema.optional(Schema.String),85 errorMessage: Schema.optional(TrimmedNonEmptyStringSchema),
86});86});
87export type ProviderRuntimeTurnCompletedEvent = typeof ProviderRuntimeTurnCompletedEvent.Type;87export type ProviderRuntimeTurnCompletedEvent = typeof ProviderRuntimeTurnCompletedEvent.Type;
8888
...@@ -121,8 +121,8 @@ export const ProviderRuntimeToolStartedEvent = Schema.Struct({...@@ -121,8 +121,8 @@ export const ProviderRuntimeToolStartedEvent = Schema.Struct({
121 turnId: Schema.optional(RuntimeTurnIdSchema),121 turnId: Schema.optional(RuntimeTurnIdSchema),
122 itemId: Schema.optional(ProviderItemId),122 itemId: Schema.optional(ProviderItemId),
123 toolKind: ProviderRuntimeToolKind,123 toolKind: ProviderRuntimeToolKind,
124 title: Schema.String,124 title: TrimmedNonEmptyStringSchema,
125 detail: Schema.optional(Schema.String),125 detail: Schema.optional(TrimmedNonEmptyStringSchema),
126});126});
127export type ProviderRuntimeToolStartedEvent = typeof ProviderRuntimeToolStartedEvent.Type;127export type ProviderRuntimeToolStartedEvent = typeof ProviderRuntimeToolStartedEvent.Type;
128128
...@@ -136,8 +136,8 @@ export const ProviderRuntimeToolCompletedEvent = Schema.Struct({...@@ -136,8 +136,8 @@ export const ProviderRuntimeToolCompletedEvent = Schema.Struct({
136 turnId: Schema.optional(RuntimeTurnIdSchema),136 turnId: Schema.optional(RuntimeTurnIdSchema),
137 itemId: Schema.optional(ProviderItemId),137 itemId: Schema.optional(ProviderItemId),
138 toolKind: ProviderRuntimeToolKind,138 toolKind: ProviderRuntimeToolKind,
139 title: Schema.String,139 title: TrimmedNonEmptyStringSchema,
140 detail: Schema.optional(Schema.String),140 detail: Schema.optional(TrimmedNonEmptyStringSchema),
141});141});
142export type ProviderRuntimeToolCompletedEvent = typeof ProviderRuntimeToolCompletedEvent.Type;142export type ProviderRuntimeToolCompletedEvent = typeof ProviderRuntimeToolCompletedEvent.Type;
143143
...@@ -152,7 +152,7 @@ export const ProviderRuntimeApprovalRequestedEvent = Schema.Struct({...@@ -152,7 +152,7 @@ export const ProviderRuntimeApprovalRequestedEvent = Schema.Struct({
152 itemId: Schema.optional(ProviderItemId),152 itemId: Schema.optional(ProviderItemId),
153 requestId: ApprovalRequestId,153 requestId: ApprovalRequestId,
154 requestKind: ProviderRequestKind,154 requestKind: ProviderRequestKind,
155 detail: Schema.optional(Schema.String),155 detail: Schema.optional(TrimmedNonEmptyStringSchema),
156});156});
157export type ProviderRuntimeApprovalRequestedEvent =157export type ProviderRuntimeApprovalRequestedEvent =
158 typeof ProviderRuntimeApprovalRequestedEvent.Type;158 typeof ProviderRuntimeApprovalRequestedEvent.Type;
...@@ -195,7 +195,7 @@ export const ProviderRuntimeErrorEvent = Schema.Struct({...@@ -195,7 +195,7 @@ export const ProviderRuntimeErrorEvent = Schema.Struct({
195 threadId: Schema.optional(RuntimeThreadIdSchema),195 threadId: Schema.optional(RuntimeThreadIdSchema),
196 turnId: Schema.optional(RuntimeTurnIdSchema),196 turnId: Schema.optional(RuntimeTurnIdSchema),
197 itemId: Schema.optional(ProviderItemId),197 itemId: Schema.optional(ProviderItemId),
198 message: Schema.String,198 message: TrimmedNonEmptyStringSchema,
199});199});
200export type ProviderRuntimeErrorEvent = typeof ProviderRuntimeErrorEvent.Type;200export type ProviderRuntimeErrorEvent = typeof ProviderRuntimeErrorEvent.Type;
201201
packages/contracts/src/server.ts+2-1
...@@ -1,8 +1,9 @@...@@ -1,8 +1,9 @@
1import { Schema } from "effect";1import { Schema } from "effect";
2import { TrimmedNonEmptyString } from "./baseSchemas";
2import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings";3import { KeybindingRule, ResolvedKeybindingsConfig } from "./keybindings";
34
4export const ServerConfig = Schema.Struct({5export const ServerConfig = Schema.Struct({
5 cwd: Schema.NonEmptyString,6 cwd: TrimmedNonEmptyString,
6 keybindings: ResolvedKeybindingsConfig,7 keybindings: ResolvedKeybindingsConfig,
7});8});
8export type ServerConfig = typeof ServerConfig.Type;9export type ServerConfig = typeof ServerConfig.Type;
packages/contracts/src/terminal.ts+4-8
...@@ -1,26 +1,22 @@...@@ -1,26 +1,22 @@
1import { Schema } from "effect";1import { Schema } from "effect";
2import { TrimmedNonEmptyString } from "./baseSchemas";
23
3export const DEFAULT_TERMINAL_ID = "default";4export const DEFAULT_TERMINAL_ID = "default";
45
5const TrimmedNonEmptyStringSchema = Schema.Trim.check(Schema.isNonEmpty());6const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;
6const TerminalColsSchema = Schema.Int.check(Schema.isGreaterThanOrEqualTo(20)).check(7const TerminalColsSchema = Schema.Int.check(Schema.isGreaterThanOrEqualTo(20)).check(
7 Schema.isLessThanOrEqualTo(400),8 Schema.isLessThanOrEqualTo(400),
8);9);
9const TerminalRowsSchema = Schema.Int.check(Schema.isGreaterThanOrEqualTo(5)).check(10const TerminalRowsSchema = Schema.Int.check(Schema.isGreaterThanOrEqualTo(5)).check(
10 Schema.isLessThanOrEqualTo(200),11 Schema.isLessThanOrEqualTo(200),
11);12);
12const TerminalIdSchema = Schema.Trim.check(Schema.isNonEmpty()).check(Schema.isMaxLength(128));13const TerminalIdSchema = TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(128));
13const TerminalEnvKeySchema = Schema.String.check(14const TerminalEnvKeySchema = Schema.String.check(
14 Schema.isPattern(/^[A-Za-z_][A-Za-z0-9_]*$/),15 Schema.isPattern(/^[A-Za-z_][A-Za-z0-9_]*$/),
15).check(Schema.isMaxLength(128));16).check(Schema.isMaxLength(128));
16const TerminalEnvValueSchema = Schema.String.check(Schema.isMaxLength(8_192));17const TerminalEnvValueSchema = Schema.String.check(Schema.isMaxLength(8_192));
17const TerminalEnvSchema = Schema.Record(TerminalEnvKeySchema, TerminalEnvValueSchema).check(18const TerminalEnvSchema = Schema.Record(TerminalEnvKeySchema, TerminalEnvValueSchema).check(
18 Schema.makeFilter<Record<string, string>>((env) => {19 Schema.isMaxProperties(128),
19 if (Object.keys(env).length > 128) {
20 return { path: [], message: "Too many terminal env vars" };
21 }
22 return true;
23 }),
24);20);
2521
26const TerminalIdWithDefaultSchema = TerminalIdSchema.pipe(22const TerminalIdWithDefaultSchema = TerminalIdSchema.pipe(
packages/contracts/src/ws.test.ts+19
...@@ -37,3 +37,22 @@ it.effect("rejects getTurnDiff requests when fromTurnCount > toTurnCount", () =>...@@ -37,3 +37,22 @@ it.effect("rejects getTurnDiff requests when fromTurnCount > toTurnCount", () =>
37 assert.strictEqual(result._tag, "Failure");37 assert.strictEqual(result._tag, "Failure");
38 }),38 }),
39);39);
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 @@...@@ -1,4 +1,5 @@
1import { Schema, Struct } from "effect";1import { Schema, Struct } from "effect";
2import { TrimmedNonEmptyString } from "./baseSchemas";
23
3import {4import {
4 ClientOrchestrationCommand,5 ClientOrchestrationCommand,
...@@ -128,13 +129,13 @@ export const WebSocketRequestBody = Schema.Union([...@@ -128,13 +129,13 @@ export const WebSocketRequestBody = Schema.Union([
128]);129]);
129130
130export const WebSocketRequest = Schema.Struct({131export const WebSocketRequest = Schema.Struct({
131 id: Schema.String,132 id: TrimmedNonEmptyString,
132 body: WebSocketRequestBody,133 body: WebSocketRequestBody,
133});134});
134export type WebSocketRequest = typeof WebSocketRequest.Type;135export type WebSocketRequest = typeof WebSocketRequest.Type;
135136
136export const WebSocketResponse = Schema.Struct({137export const WebSocketResponse = Schema.Struct({
137 id: Schema.String,138 id: TrimmedNonEmptyString,
138 result: Schema.optional(Schema.Unknown),139 result: Schema.optional(Schema.Unknown),
139 error: Schema.optional(140 error: Schema.optional(
140 Schema.Struct({141 Schema.Struct({
...@@ -146,7 +147,7 @@ export type WebSocketResponse = typeof WebSocketResponse.Type;...@@ -146,7 +147,7 @@ export type WebSocketResponse = typeof WebSocketResponse.Type;
146147
147export const WsPush = Schema.Struct({148export const WsPush = Schema.Struct({
148 type: Schema.Literal("push"),149 type: Schema.Literal("push"),
149 channel: Schema.String,150 channel: TrimmedNonEmptyString,
150 data: Schema.Unknown,151 data: Schema.Unknown,
151});152});
152export type WsPush = typeof WsPush.Type;153export type WsPush = typeof WsPush.Type;
...@@ -159,7 +160,7 @@ export type WsResponse = typeof WsResponse.Type;...@@ -159,7 +160,7 @@ export type WsResponse = typeof WsResponse.Type;
159// ── Server welcome payload ───────────────────────────────────────────160// ── Server welcome payload ───────────────────────────────────────────
160161
161export const WsWelcomePayload = Schema.Struct({162export const WsWelcomePayload = Schema.Struct({
162 cwd: Schema.String,163 cwd: TrimmedNonEmptyString,
163 projectName: Schema.String,164 projectName: TrimmedNonEmptyString,
164});165});
165export type WsWelcomePayload = typeof WsWelcomePayload.Type;166export type WsWelcomePayload = typeof WsWelcomePayload.Type;