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