| author | |
| committer | |
| log | 6266af3f1f688497ef9717b87b82eafc5240418c |
| tree | 5baf2b101d0ab2ee3ef286d99ee263bce5de6136 |
| parent | c77a7b7eebd6ea8a3a63bbcaaa7c1707fe65c86e |
| signature | Signed by PGP key B5690EEEBB952194 |
6 files changed, 309 insertions(+), 1 deletions(-)
apps/server/src/git/GitWorkflowService.test.ts+48| ... | @@ -6,6 +6,11 @@ import * as Option from "effect/Option"; | ... | @@ -6,6 +6,11 @@ import * as Option from "effect/Option"; |
| 6 | 6 | ||
| 7 | import { VcsRepositoryDetectionError } from "@t3tools/contracts"; | 7 | import { VcsRepositoryDetectionError } from "@t3tools/contracts"; |
| 8 | 8 | ||
| 9 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 10 | import * as FileSystem from "effect/FileSystem"; | ||
| 11 | |||
| 12 | import * as ServerConfig from "../config.ts"; | ||
| 13 | import * as VcsProcess from "../vcs/VcsProcess.ts"; | ||
| 9 | import * as GitManager from "./GitManager.ts"; | 14 | import * as GitManager from "./GitManager.ts"; |
| 10 | import * as GitWorkflowService from "./GitWorkflowService.ts"; | 15 | import * as GitWorkflowService from "./GitWorkflowService.ts"; |
| 11 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; | 16 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; |
| ... | @@ -220,4 +225,47 @@ describe("GitWorkflowService", () => { | ... | @@ -220,4 +225,47 @@ describe("GitWorkflowService", () => { |
| 220 | ), | 225 | ), |
| 221 | ); | 226 | ); |
| 222 | }); | 227 | }); |
| 228 | |||
| 229 | it.effect("finds a name among the refs of every namespace", () => | ||
| 230 | Effect.gen(function* () { | ||
| 231 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 232 | const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-ref-named-" }); | ||
| 233 | const workflow = yield* GitWorkflowService.GitWorkflowService; | ||
| 234 | const driver = yield* GitVcsDriver.GitVcsDriver; | ||
| 235 | for (const args of [ | ||
| 236 | ["init", "-b", "main"], | ||
| 237 | ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "--allow-empty", "-m", "init"], | ||
| 238 | ["update-ref", "refs/remotes/upstream/feature/topic", "HEAD"], | ||
| 239 | ["symbolic-ref", "refs/remotes/origin/alias", "refs/heads/main"], | ||
| 240 | ["update-ref", "refs/mirror/origin/mirrored", "HEAD"], | ||
| 241 | ]) { | ||
| 242 | yield* driver.execute({ operation: "test.setupRepo", cwd, args }); | ||
| 243 | } | ||
| 244 | const found = yield* Effect.forEach( | ||
| 245 | ["main", "feature/topic", "alias", "mirrored", "t3/renamed", "feature/*"], | ||
| 246 | (refName) => workflow.hasRefNamed({ cwd, refName }), | ||
| 247 | ); | ||
| 248 | assert.deepEqual(found, [true, true, true, true, false, false]); | ||
| 249 | }).pipe( | ||
| 250 | Effect.scoped, | ||
| 251 | Effect.provide( | ||
| 252 | GitWorkflowService.layer.pipe( | ||
| 253 | Layer.provide( | ||
| 254 | Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({ | ||
| 255 | resolve: () => Effect.succeed({ kind: "git" } as VcsDriverRegistry.VcsDriverHandle), | ||
| 256 | }), | ||
| 257 | ), | ||
| 258 | Layer.provide(Layer.mock(GitManager.GitManager)({})), | ||
| 259 | Layer.provideMerge(GitVcsDriver.layer), | ||
| 260 | Layer.provideMerge(VcsProcess.layer), | ||
| 261 | Layer.provideMerge( | ||
| 262 | ServerConfig.layerTest(process.cwd(), { prefix: "t3-ref-named-" }).pipe( | ||
| 263 | Layer.provide(NodeServices.layer), | ||
| 264 | ), | ||
| 265 | ), | ||
| 266 | Layer.provideMerge(NodeServices.layer), | ||
| 267 | ), | ||
| 268 | ), | ||
| 269 | ), | ||
| 270 | ); | ||
| 223 | }); | 271 | }); |
apps/server/src/git/GitWorkflowService.ts+28| ... | @@ -40,6 +40,15 @@ export class GitWorkflowService extends Context.Service< | ... | @@ -40,6 +40,15 @@ export class GitWorkflowService extends Context.Service< |
| 40 | readonly cwd: string; | 40 | readonly cwd: string; |
| 41 | readonly refName: string; | 41 | readonly refName: string; |
| 42 | }) => Effect.Effect<boolean, GitCommandError>; | 42 | }) => Effect.Effect<boolean, GitCommandError>; |
| 43 | /** | ||
| 44 | * Whether a ref in any namespace ends in this name: a local branch, a | ||
| 45 | * remote-tracking branch of any remote, a tag. Git falls back to these | ||
| 46 | * when a branch name is not a revision by itself. | ||
| 47 | */ | ||
| 48 | readonly hasRefNamed: (input: { | ||
| 49 | readonly cwd: string; | ||
| 50 | readonly refName: string; | ||
| 51 | }) => Effect.Effect<boolean, GitCommandError>; | ||
| 43 | readonly status: ( | 52 | readonly status: ( |
| 44 | input: VcsStatusInput, | 53 | input: VcsStatusInput, |
| 45 | ) => Effect.Effect<VcsStatusResult, GitManagerServiceError>; | 54 | ) => Effect.Effect<VcsStatusResult, GitManagerServiceError>; |
| ... | @@ -300,6 +309,25 @@ export const make = Effect.gen(function* () { | ... | @@ -300,6 +309,25 @@ export const make = Effect.gen(function* () { |
| 300 | ), | 309 | ), |
| 301 | Effect.map((result) => result.exitCode === 0), | 310 | Effect.map((result) => result.exitCode === 0), |
| 302 | ), | 311 | ), |
| 312 | hasRefNamed: (input) => | ||
| 313 | // No ref name holds a pattern character, and one here would widen the match. | ||
| 314 | /[*?[\\]/.test(input.refName) | ||
| 315 | ? Effect.succeed(false) | ||
| 316 | : ensureGitCommand("GitWorkflowService.hasRefNamed", input.cwd).pipe( | ||
| 317 | Effect.andThen( | ||
| 318 | git.execute({ | ||
| 319 | operation: "GitWorkflowService.hasRefNamed", | ||
| 320 | cwd: input.cwd, | ||
| 321 | args: [ | ||
| 322 | "for-each-ref", | ||
| 323 | "--count=1", | ||
| 324 | "--format=%(refname)", | ||
| 325 | `refs/**/${input.refName}`, | ||
| 326 | ], | ||
| 327 | }), | ||
| 328 | ), | ||
| 329 | Effect.map((result) => result.stdout.trim().length > 0), | ||
| 330 | ), | ||
| 303 | status: (input) => | 331 | status: (input) => |
| 304 | detectGitRepositoryForStatus("GitWorkflowService.status", input.cwd).pipe( | 332 | detectGitRepositoryForStatus("GitWorkflowService.status", input.cwd).pipe( |
| 305 | Effect.flatMap((isGitRepository) => | 333 | Effect.flatMap((isGitRepository) => |
apps/server/src/mcp/toolkits/project/handlers.test.ts+46| ... | @@ -301,6 +301,7 @@ const clientLaunchHarness = (input: { | ... | @@ -301,6 +301,7 @@ const clientLaunchHarness = (input: { |
| 301 | readonly runtimeModeCeiling: "approval-required" | "auto-accept-edits" | "auto" | "full-access"; | 301 | readonly runtimeModeCeiling: "approval-required" | "auto-accept-edits" | "auto" | "full-access"; |
| 302 | readonly launched: Array<ThreadLaunch.ThreadLaunchInput>; | 302 | readonly launched: Array<ThreadLaunch.ThreadLaunchInput>; |
| 303 | readonly workspaceRoot?: string; | 303 | readonly workspaceRoot?: string; |
| 304 | readonly checkWorktreeBase?: ThreadLaunch.ThreadLaunchService["Service"]["checkWorktreeBase"]; | ||
| 304 | }) => { | 305 | }) => { |
| 305 | const projectId = ProjectId.make("project:client-target"); | 306 | const projectId = ProjectId.make("project:client-target"); |
| 306 | const modelSelection = { instanceId: ProviderInstanceId.make("claude"), model: "claude-opus" }; | 307 | const modelSelection = { instanceId: ProviderInstanceId.make("claude"), model: "claude-opus" }; |
| ... | @@ -320,6 +321,7 @@ const clientLaunchHarness = (input: { | ... | @@ -320,6 +321,7 @@ const clientLaunchHarness = (input: { |
| 320 | }), | 321 | }), |
| 321 | Layer.mock(ThreadManagement.ThreadManagementService)({}), | 322 | Layer.mock(ThreadManagement.ThreadManagementService)({}), |
| 322 | Layer.mock(ThreadLaunch.ThreadLaunchService)({ | 323 | Layer.mock(ThreadLaunch.ThreadLaunchService)({ |
| 324 | checkWorktreeBase: input.checkWorktreeBase ?? (() => Effect.void), | ||
| 323 | launch: (launch) => { | 325 | launch: (launch) => { |
| 324 | input.launched.push(launch); | 326 | input.launched.push(launch); |
| 325 | return Effect.succeed({ | 327 | return Effect.succeed({ |
| ... | @@ -454,3 +456,47 @@ it.effect("a launch binds only an existing checkout that is one of the project's | ... | @@ -454,3 +456,47 @@ it.effect("a launch binds only an existing checkout that is one of the project's |
| 454 | }); | 456 | }); |
| 455 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), | 457 | }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), |
| 456 | ); | 458 | ); |
| 459 | |||
| 460 | it.effect("a launch into a new worktree creates nothing when its base ref has no commit", () => | ||
| 461 | Effect.gen(function* () { | ||
| 462 | const launched: Array<ThreadLaunch.ThreadLaunchInput> = []; | ||
| 463 | const { projectId, dependencies } = clientLaunchHarness({ | ||
| 464 | runtimeModeCeiling: "auto", | ||
| 465 | launched, | ||
| 466 | checkWorktreeBase: ({ projectId, workspaceStrategy }) => | ||
| 467 | workspaceStrategy.type === "worktree" && workspaceStrategy.baseRef === "t3/renamed" | ||
| 468 | ? Effect.fail( | ||
| 469 | new ThreadLaunch.ThreadLaunchBaseRefError({ | ||
| 470 | projectId, | ||
| 471 | projectTitle: "Client target", | ||
| 472 | workspaceRoot: "/projects/client-target", | ||
| 473 | baseRef: workspaceStrategy.baseRef, | ||
| 474 | }), | ||
| 475 | ) | ||
| 476 | : Effect.void, | ||
| 477 | }); | ||
| 478 | const toolkit = yield* ProjectToolkit.pipe( | ||
| 479 | Effect.provide( | ||
| 480 | McpToolAccess.HandlersLayer.layer(ProjectHandlers.layer).pipe(Layer.provide(dependencies)), | ||
| 481 | ), | ||
| 482 | ); | ||
| 483 | const launchFrom = (baseRef: string) => | ||
| 484 | toolkit | ||
| 485 | .handle("t3_thread_launch", { | ||
| 486 | title: "Fix", | ||
| 487 | projectId, | ||
| 488 | workspaceStrategy: { type: "worktree", baseRef, startFromOrigin: false }, | ||
| 489 | }) | ||
| 490 | .pipe(Stream.unwrap, Stream.runCollect, Effect.provide(dependencies)); | ||
| 491 | |||
| 492 | const refused = (yield* launchFrom("t3/renamed")).at(-1)?.result; | ||
| 493 | expect(refused).toMatchObject({ code: "invalid_request" }); | ||
| 494 | expect((refused as { message: string }).message).toContain( | ||
| 495 | 'Base ref "t3/renamed" does not resolve to a commit in project "Client target" (/projects/client-target). No thread was created.', | ||
| 496 | ); | ||
| 497 | expect(launched).toHaveLength(0); | ||
| 498 | |||
| 499 | expect((yield* launchFrom("main")).at(-1)?.result).toMatchObject({ projectId }); | ||
| 500 | expect(launched).toHaveLength(1); | ||
| 501 | }), | ||
| 502 | ); |
apps/server/src/mcp/toolkits/project/handlers.ts+21| ... | @@ -2,6 +2,7 @@ import { MessageId, ThreadId, OrchestratorMcpFailure, ProjectId } from "@t3tools | ... | @@ -2,6 +2,7 @@ import { MessageId, ThreadId, OrchestratorMcpFailure, ProjectId } from "@t3tools |
| 2 | import * as Effect from "effect/Effect"; | 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as FileSystem from "effect/FileSystem"; | 3 | import * as FileSystem from "effect/FileSystem"; |
| 4 | import * as Option from "effect/Option"; | 4 | import * as Option from "effect/Option"; |
| 5 | import * as ThreadLaunch from "../../../orchestration-v2/ThreadLaunchService.ts"; | ||
| 5 | import * as ThreadMessageIntake from "../../../orchestration-v2/ThreadMessageIntake.ts"; | 6 | import * as ThreadMessageIntake from "../../../orchestration-v2/ThreadMessageIntake.ts"; |
| 6 | import * as Claims from "../../../orchestration-v2/AttachmentClaims.ts"; | 7 | import * as Claims from "../../../orchestration-v2/AttachmentClaims.ts"; |
| 7 | import * as Project from "../../../project/ProjectService.ts"; | 8 | import * as Project from "../../../project/ProjectService.ts"; |
| ... | @@ -103,6 +104,26 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { | ... | @@ -103,6 +104,26 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, { |
| 103 | }); | 104 | }); |
| 104 | yield* assertProjectWorktree(project.workspaceRoot, input.workspaceStrategy.worktreePath); | 105 | yield* assertProjectWorktree(project.workspaceRoot, input.workspaceStrategy.worktreePath); |
| 105 | } | 106 | } |
| 107 | // A launch is accepted before its worktree is provisioned, so a base | ||
| 108 | // ref that cannot resolve is refused while the agent can still act on it. | ||
| 109 | const strategy = input.workspaceStrategy; | ||
| 110 | if (strategy?.type === "worktree") | ||
| 111 | yield* ThreadLaunch.ThreadLaunchService.pipe( | ||
| 112 | Effect.flatMap((launches) => | ||
| 113 | launches.checkWorktreeBase({ projectId, workspaceStrategy: strategy }), | ||
| 114 | ), | ||
| 115 | Effect.mapError( | ||
| 116 | (error) => | ||
| 117 | new OrchestratorMcpFailure({ | ||
| 118 | code: "invalid_request", | ||
| 119 | message: `${error.message} No thread was created. Pass a branch, tag, or commit that exists there${ | ||
| 120 | strategy.startFromOrigin === true | ||
| 121 | ? "; the project has no origin remote to fetch it from." | ||
| 122 | : ", or set startFromOrigin:true if it exists only on origin." | ||
| 123 | }`, | ||
| 124 | }), | ||
| 125 | ), | ||
| 126 | ); | ||
| 106 | const modelSelection = | 127 | const modelSelection = |
| 107 | input.modelSelection ?? | 128 | input.modelSelection ?? |
| 108 | caller?.modelSelection ?? | 129 | caller?.modelSelection ?? |
apps/server/src/orchestration-v2/ThreadLaunchService.test.ts+105| ... | @@ -107,6 +107,7 @@ interface HarnessOptions { | ... | @@ -107,6 +107,7 @@ interface HarnessOptions { |
| 107 | readonly generateBranchName?: TextGeneration.TextGeneration["Service"]["generateBranchName"]; | 107 | readonly generateBranchName?: TextGeneration.TextGeneration["Service"]["generateBranchName"]; |
| 108 | readonly serverSettings?: Parameters<typeof ServerSettings.layerTest>[0]; | 108 | readonly serverSettings?: Parameters<typeof ServerSettings.layerTest>[0]; |
| 109 | readonly providers?: ReadonlyArray<ServerProvider>; | 109 | readonly providers?: ReadonlyArray<ServerProvider>; |
| 110 | readonly git?: Partial<GitWorkflow.GitWorkflowService["Service"]>; | ||
| 110 | } | 111 | } |
| 111 | 112 | ||
| 112 | function makeHarness(options: HarnessOptions = {}) { | 113 | function makeHarness(options: HarnessOptions = {}) { |
| ... | @@ -175,6 +176,7 @@ function makeHarness(options: HarnessOptions = {}) { | ... | @@ -175,6 +176,7 @@ function makeHarness(options: HarnessOptions = {}) { |
| 175 | removeWorktree, | 176 | removeWorktree, |
| 176 | resolveRemoteTrackingCommit: () => | 177 | resolveRemoteTrackingCommit: () => |
| 177 | Effect.succeed({ commitSha: "remote-main-sha", remoteRefName: "origin/main" }), | 178 | Effect.succeed({ commitSha: "remote-main-sha", remoteRefName: "origin/main" }), |
| 179 | ...options.git, | ||
| 178 | }), | 180 | }), |
| 179 | Layer.succeed(ProjectSetupScriptRunner.ProjectSetupScriptRunner, { | 181 | Layer.succeed(ProjectSetupScriptRunner.ProjectSetupScriptRunner, { |
| 180 | runForThread: runSetup, | 182 | runForThread: runSetup, |
| ... | @@ -2024,6 +2026,7 @@ it.effect("shared intake preserves durable attachment bytes after a lost launch | ... | @@ -2024,6 +2026,7 @@ it.effect("shared intake preserves durable attachment bytes after a lost launch |
| 2024 | }), | 2026 | }), |
| 2025 | ), | 2027 | ), |
| 2026 | ), | 2028 | ), |
| 2029 | checkWorktreeBase: launches.checkWorktreeBase, | ||
| 2027 | retryPreparation: launches.retryPreparation, | 2030 | retryPreparation: launches.retryPreparation, |
| 2028 | }), | 2031 | }), |
| 2029 | Effect.flip, | 2032 | Effect.flip, |
| ... | @@ -2269,3 +2272,105 @@ it.effect.each([0, 1])("releases an async setup before its completion with exit | ... | @@ -2269,3 +2272,105 @@ it.effect.each([0, 1])("releases an async setup before its completion with exit |
| 2269 | }).pipe(Effect.provide(harness.layer)); | 2272 | }).pipe(Effect.provide(harness.layer)); |
| 2270 | }), | 2273 | }), |
| 2271 | ); | 2274 | ); |
| 2275 | |||
| 2276 | it.effect.each([ | ||
| 2277 | { name: "is missing", baseRef: "t3/renamed", startFromOrigin: false, origin: true }, | ||
| 2278 | { | ||
| 2279 | name: "has no origin to come from", | ||
| 2280 | baseRef: "t3/renamed", | ||
| 2281 | startFromOrigin: true, | ||
| 2282 | origin: false, | ||
| 2283 | }, | ||
| 2284 | { | ||
| 2285 | name: "is a previous checkout there never was", | ||
| 2286 | baseRef: "-", | ||
| 2287 | startFromOrigin: false, | ||
| 2288 | origin: true, | ||
| 2289 | }, | ||
| 2290 | ])("refuses a new worktree whose base ref $name", (testCase) => | ||
| 2291 | Effect.gen(function* () { | ||
| 2292 | const harness = makeHarness({ | ||
| 2293 | hasCommit: ({ refName }) => Effect.succeed(refName === "HEAD"), | ||
| 2294 | git: { | ||
| 2295 | remoteExists: () => Effect.succeed(testCase.origin), | ||
| 2296 | hasRefNamed: () => Effect.succeed(false), | ||
| 2297 | }, | ||
| 2298 | }); | ||
| 2299 | yield* Effect.gen(function* () { | ||
| 2300 | const launches = yield* ThreadLaunch.ThreadLaunchService; | ||
| 2301 | const error = yield* launches | ||
| 2302 | .checkWorktreeBase({ | ||
| 2303 | projectId, | ||
| 2304 | workspaceStrategy: { | ||
| 2305 | type: "worktree", | ||
| 2306 | baseRef: testCase.baseRef, | ||
| 2307 | startFromOrigin: testCase.startFromOrigin, | ||
| 2308 | }, | ||
| 2309 | }) | ||
| 2310 | .pipe(Effect.flip); | ||
| 2311 | assert.deepInclude(error, { | ||
| 2312 | _tag: "ThreadLaunchBaseRefError", | ||
| 2313 | projectId, | ||
| 2314 | baseRef: testCase.baseRef, | ||
| 2315 | }); | ||
| 2316 | assert.equal( | ||
| 2317 | error.message, | ||
| 2318 | `Base ref "${testCase.baseRef}" does not resolve to a commit in project "Project" (/repo).`, | ||
| 2319 | ); | ||
| 2320 | }).pipe(Effect.provide(harness.layer)); | ||
| 2321 | }), | ||
| 2322 | ); | ||
| 2323 | |||
| 2324 | it.effect.each([ | ||
| 2325 | { name: "a base ref that resolves", baseRef: "main", commits: ["main", "HEAD"] }, | ||
| 2326 | // Provisioning fetches it; the ref may exist nowhere else yet. | ||
| 2327 | { | ||
| 2328 | name: "a base ref only origin has", | ||
| 2329 | baseRef: "remote-only", | ||
| 2330 | startFromOrigin: true, | ||
| 2331 | commits: ["HEAD"], | ||
| 2332 | }, | ||
| 2333 | // `git worktree add` starts from a remote-tracking branch of that name. | ||
| 2334 | { | ||
| 2335 | name: "a base ref a remote branch carries", | ||
| 2336 | baseRef: "remote-only", | ||
| 2337 | commits: ["HEAD"], | ||
| 2338 | remoteBranch: true, | ||
| 2339 | }, | ||
| 2340 | // Provisioning runs such a repository without a worktree. | ||
| 2341 | { name: "a repository with no commits yet", baseRef: "main", commits: [] }, | ||
| 2342 | { name: "the previous checkout", baseRef: "-", commits: ["@{-1}", "HEAD"] }, | ||
| 2343 | { name: "a commit search", baseRef: ":/fix", commits: ["HEAD"] }, | ||
| 2344 | { name: "a folder that is not a repository", baseRef: "main", commits: null }, | ||
| 2345 | ])("leaves $name to the launch", (testCase) => | ||
| 2346 | Effect.gen(function* () { | ||
| 2347 | const { commits } = testCase; | ||
| 2348 | const harness = makeHarness({ | ||
| 2349 | git: { hasRefNamed: () => Effect.succeed(testCase.remoteBranch === true) }, | ||
| 2350 | hasCommit: ({ refName }) => | ||
| 2351 | commits === null | ||
| 2352 | ? Effect.fail( | ||
| 2353 | new GitCommandError({ | ||
| 2354 | operation: "GitWorkflowService.hasCommit", | ||
| 2355 | command: "git rev-parse", | ||
| 2356 | cwd: "/repo", | ||
| 2357 | detail: "Not a Git repository.", | ||
| 2358 | }), | ||
| 2359 | ) | ||
| 2360 | : Effect.succeed(commits.includes(refName)), | ||
| 2361 | }); | ||
| 2362 | yield* Effect.gen(function* () { | ||
| 2363 | const launches = yield* ThreadLaunch.ThreadLaunchService; | ||
| 2364 | yield* launches.checkWorktreeBase({ | ||
| 2365 | projectId, | ||
| 2366 | workspaceStrategy: { | ||
| 2367 | type: "worktree", | ||
| 2368 | baseRef: testCase.baseRef, | ||
| 2369 | ...(testCase.startFromOrigin === undefined | ||
| 2370 | ? {} | ||
| 2371 | : { startFromOrigin: testCase.startFromOrigin }), | ||
| 2372 | }, | ||
| 2373 | }); | ||
| 2374 | }).pipe(Effect.provide(harness.layer)); | ||
| 2375 | }), | ||
| 2376 | ); |
apps/server/src/orchestration-v2/ThreadLaunchService.ts+61-1| ... | @@ -147,12 +147,35 @@ export class ThreadLaunchError extends Schema.TaggedError<ThreadLaunchError>()( | ... | @@ -147,12 +147,35 @@ export class ThreadLaunchError extends Schema.TaggedError<ThreadLaunchError>()( |
| 147 | } | 147 | } |
| 148 | } | 148 | } |
| 149 | 149 | ||
| 150 | /** A new worktree was asked to start from a ref that gives it no commit. */ | ||
| 151 | export class ThreadLaunchBaseRefError extends Schema.TaggedError<ThreadLaunchBaseRefError>()( | ||
| 152 | "ThreadLaunchBaseRefError", | ||
| 153 | { | ||
| 154 | projectId: ProjectId, | ||
| 155 | projectTitle: Schema.String, | ||
| 156 | workspaceRoot: Schema.String, | ||
| 157 | baseRef: Schema.String, | ||
| 158 | }, | ||
| 159 | ) { | ||
| 160 | override get message(): string { | ||
| 161 | return `Base ref "${this.baseRef}" does not resolve to a commit in project "${this.projectTitle}" (${this.workspaceRoot}).`; | ||
| 162 | } | ||
| 163 | } | ||
| 164 | |||
| 150 | export class ThreadLaunchService extends Context.Service< | 165 | export class ThreadLaunchService extends Context.Service< |
| 151 | ThreadLaunchService, | 166 | ThreadLaunchService, |
| 152 | { | 167 | { |
| 153 | readonly launch: ( | 168 | readonly launch: ( |
| 154 | input: ThreadLaunchInput, | 169 | input: ThreadLaunchInput, |
| 155 | ) => Effect.Effect<ThreadLaunchResult, ThreadLaunchError>; | 170 | ) => Effect.Effect<ThreadLaunchResult, ThreadLaunchError>; |
| 171 | /** | ||
| 172 | * Fails when a new worktree's base ref cannot give provisioning a commit. | ||
| 173 | * `launch` accepts such a launch and reports the failure inside the | ||
| 174 | * thread; a caller that needs it before a thread exists checks first. | ||
| 175 | */ | ||
| 176 | readonly checkWorktreeBase: ( | ||
| 177 | input: Pick<ThreadLaunchInput, "projectId" | "workspaceStrategy">, | ||
| 178 | ) => Effect.Effect<void, ThreadLaunchBaseRefError>; | ||
| 156 | /** Dispatches prepared-run.retry and prepares the run's workspace again. */ | 179 | /** Dispatches prepared-run.retry and prepares the run's workspace again. */ |
| 157 | readonly retryPreparation: ( | 180 | readonly retryPreparation: ( |
| 158 | input: ThreadLaunchRetryInput, | 181 | input: ThreadLaunchRetryInput, |
| ... | @@ -229,6 +252,43 @@ const make = Effect.gen(function* () { | ... | @@ -229,6 +252,43 @@ const make = Effect.gen(function* () { |
| 229 | } | 252 | } |
| 230 | }); | 253 | }); |
| 231 | 254 | ||
| 255 | const checkWorktreeBase: ThreadLaunchService["Service"]["checkWorktreeBase"] = Effect.fn( | ||
| 256 | "ThreadLaunchService.checkWorktreeBase", | ||
| 257 | )(function* (input) { | ||
| 258 | const strategy = input.workspaceStrategy; | ||
| 259 | // `:/text` is a commit search, which cannot be asked for a commit type. | ||
| 260 | if (strategy.type !== "worktree" || strategy.baseRef.startsWith(":")) return; | ||
| 261 | const { baseRef } = strategy; | ||
| 262 | // Anything this cannot establish is left for the launch to report: a | ||
| 263 | // missing project, a folder that is not a repository, a failing Git. | ||
| 264 | const unresolved = yield* Effect.gen(function* () { | ||
| 265 | const project = Option.getOrUndefined(yield* projects.getById(input.projectId)); | ||
| 266 | if (project === undefined) return undefined; | ||
| 267 | const cwd = project.workspaceRoot; | ||
| 268 | // `git worktree add` reads `-` as the previous checkout. | ||
| 269 | if (yield* git.hasCommit({ cwd, refName: baseRef === "-" ? "@{-1}" : baseRef })) | ||
| 270 | return undefined; | ||
| 271 | // Provisioning fetches the base from origin when it can, and runs a | ||
| 272 | // repository with no commits yet without a worktree. | ||
| 273 | if ( | ||
| 274 | strategy.startFromOrigin === true && | ||
| 275 | (yield* git.remoteExists({ cwd, remoteName: "origin" })) | ||
| 276 | ) | ||
| 277 | return undefined; | ||
| 278 | // `git worktree add` also starts from a remote-tracking branch of that | ||
| 279 | // name; any ref carrying it is reason enough to let Git decide. | ||
| 280 | if (yield* git.hasRefNamed({ cwd, refName: baseRef })) return undefined; | ||
| 281 | return (yield* git.hasCommit({ cwd, refName: "HEAD" })) ? project : undefined; | ||
| 282 | }).pipe(Effect.orElseSucceed(() => undefined)); | ||
| 283 | if (unresolved !== undefined) | ||
| 284 | return yield* new ThreadLaunchBaseRefError({ | ||
| 285 | projectId: unresolved.id, | ||
| 286 | projectTitle: unresolved.title, | ||
| 287 | workspaceRoot: unresolved.workspaceRoot, | ||
| 288 | baseRef, | ||
| 289 | }); | ||
| 290 | }); | ||
| 291 | |||
| 232 | const prepareInBackground = Effect.fn("ThreadLaunchService.prepareInBackground")(function* ( | 292 | const prepareInBackground = Effect.fn("ThreadLaunchService.prepareInBackground")(function* ( |
| 233 | input: PreparationInput, | 293 | input: PreparationInput, |
| 234 | threadId: ThreadId, | 294 | threadId: ThreadId, |
| ... | @@ -977,7 +1037,7 @@ const make = Effect.gen(function* () { | ... | @@ -977,7 +1037,7 @@ const make = Effect.gen(function* () { |
| 977 | ); | 1037 | ); |
| 978 | }; | 1038 | }; |
| 979 | 1039 | ||
| 980 | return ThreadLaunchService.of({ launch, retryPreparation }); | 1040 | return ThreadLaunchService.of({ launch, checkWorktreeBase, retryPreparation }); |
| 981 | }); | 1041 | }); |
| 982 | 1042 | ||
| 983 | export const layer = Layer.effect(ThreadLaunchService, make); | 1043 | export const layer = Layer.effect(ThreadLaunchService, make); |