| author | |
| committer | |
| log | 8d7c700c1788c4f42932cd3be541b3c630bd664d |
| tree | 720b483d810558340af36f33f05610c4aea78eb0 |
| parent | 112a7088da63ea4fa48fee63dc8f29557c545ed7 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>28 files changed, 2206 insertions(+), 25 deletions(-)
apps/server/src/auth/RpcAuthorization.ts+4| ... | ... | @@ -97,6 +97,10 @@ export const RPC_REQUIRED_SCOPES = { |
| 97 | 97 | [WS_METHODS.sourceControlLookupRepository]: AuthOrchestrationReadScope, |
| 98 | 98 | [WS_METHODS.sourceControlCloneRepository]: AuthOrchestrationOperateScope, |
| 99 | 99 | [WS_METHODS.sourceControlPublishRepository]: AuthOrchestrationOperateScope, |
| 100 | [WS_METHODS.projectCloneStart]: AuthOrchestrationOperateScope, | |
| 101 | [WS_METHODS.projectCloneCancel]: AuthOrchestrationOperateScope, | |
| 102 | [WS_METHODS.projectCloneRetry]: AuthOrchestrationOperateScope, | |
| 103 | [WS_METHODS.subscribeProjectClones]: AuthOrchestrationReadScope, | |
| 100 | 104 | [WS_METHODS.projectsListEntries]: AuthOrchestrationReadScope, |
| 101 | 105 | [WS_METHODS.projectsReadFile]: AuthOrchestrationReadScope, |
| 102 | 106 | [WS_METHODS.projectsSearchContents]: AuthOrchestrationReadScope, |
apps/server/src/bin.test.ts+11-1| ... | ... | @@ -39,6 +39,7 @@ import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSna |
| 39 | 39 | import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; |
| 40 | 40 | import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; |
| 41 | 41 | import { orchestrationHttpApiLayer } from "./orchestration/http.ts"; |
| 42 | import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts"; | |
| 42 | 43 | import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; |
| 43 | 44 | import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; |
| 44 | 45 | import { |
| ... | ... | @@ -363,7 +364,16 @@ const withLiveProjectCliServer = <A, E, R>(baseDir: string, run: () => Effect.Ef |
| 363 | 364 | Effect.gen(function* () { |
| 364 | 365 | const config = yield* makeCliTestServerConfig(baseDir); |
| 365 | 366 | const routesLayer = HttpApiBuilder.layer(ProjectCliHttpApi).pipe( |
| 366 | Layer.provide(orchestrationHttpApiLayer), | |
| 367 | Layer.provide( | |
| 368 | orchestrationHttpApiLayer.pipe( | |
| 369 | Layer.provide( | |
| 370 | Layer.mock(ProjectCloneTracker.ProjectCloneTracker)({ | |
| 371 | get: () => Effect.succeed(null), | |
| 372 | discard: () => Effect.void, | |
| 373 | }), | |
| 374 | ), | |
| 375 | ), | |
| 376 | ), | |
| 367 | 377 | Layer.provide(environmentAuthenticatedAuthLayer), |
| 368 | 378 | ); |
| 369 | 379 | const appLayer = HttpRouter.serve(routesLayer, { |
apps/server/src/environment/ServerEnvironment.ts+1| ... | ... | @@ -236,6 +236,7 @@ export const make = Effect.gen(function* () { |
| 236 | 236 | pullRequestStackActions: true, |
| 237 | 237 | threadPullRequestLinking: true, |
| 238 | 238 | environmentIcon: true, |
| 239 | projectCloneTracking: true, | |
| 239 | 240 | ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), |
| 240 | 241 | ...(serverSelfUpdate === "boot-service" || desktopAppUpdate |
| 241 | 242 | ? { |
apps/server/src/orchestration/http.ts+16-1| ... | ... | @@ -16,6 +16,7 @@ import { |
| 16 | 16 | failEnvironmentNotFound, |
| 17 | 17 | requireEnvironmentScope, |
| 18 | 18 | } from "../auth/http.ts"; |
| 19 | import * as ProjectCloneTracker from "../project/ProjectCloneTracker.ts"; | |
| 19 | 20 | import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts"; |
| 20 | 21 | import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts"; |
| 21 | 22 | |
| ... | ... | @@ -25,6 +26,7 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group( |
| 25 | 26 | Effect.fnUntraced(function* (handlers) { |
| 26 | 27 | const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; |
| 27 | 28 | const orchestrationEngine = yield* OrchestrationEngineService; |
| 29 | const projectCloneTracker = yield* ProjectCloneTracker.ProjectCloneTracker; | |
| 28 | 30 | |
| 29 | 31 | return handlers |
| 30 | 32 | .handle( |
| ... | ... | @@ -93,10 +95,18 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group( |
| 93 | 95 | Effect.fn("environment.orchestration.dispatch")(function* (args) { |
| 94 | 96 | yield* annotateEnvironmentRequest(args.endpoint.name); |
| 95 | 97 | yield* requireEnvironmentScope(AuthOrchestrationOperateScope); |
| 98 | yield* ProjectCloneTracker.rejectCommandsDuringClone( | |
| 99 | projectCloneTracker, | |
| 100 | args.payload, | |
| 101 | ).pipe( | |
| 102 | Effect.catch((cause) => | |
| 103 | failEnvironmentInternal("orchestration_dispatch_failed", cause), | |
| 104 | ), | |
| 105 | ); | |
| 96 | 106 | const normalizedCommand = yield* normalizeDispatchCommand(args.payload).pipe( |
| 97 | 107 | Effect.catch(() => failEnvironmentInvalidRequest("invalid_command")), |
| 98 | 108 | ); |
| 99 | return yield* orchestrationEngine.dispatch(normalizedCommand).pipe( | |
| 109 | const result = yield* orchestrationEngine.dispatch(normalizedCommand).pipe( | |
| 100 | 110 | Effect.tapError(() => |
| 101 | 111 | cleanupFailedUploadedAttachments(args.payload, normalizedCommand), |
| 102 | 112 | ), |
| ... | ... | @@ -104,6 +114,11 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group( |
| 104 | 114 | failEnvironmentInternal("orchestration_dispatch_failed", cause), |
| 105 | 115 | ), |
| 106 | 116 | ); |
| 117 | yield* ProjectCloneTracker.discardCloneForDeletedProject( | |
| 118 | projectCloneTracker, | |
| 119 | normalizedCommand, | |
| 120 | ); | |
| 121 | return result; | |
| 107 | 122 | }), |
| 108 | 123 | ); |
| 109 | 124 | }), |
apps/server/src/project/ProjectCloneTracker.test.ts created+303| ... | ... | @@ -0,0 +1,303 @@ |
| 1 | import { describe, expect, it } from "@effect/vitest"; | |
| 2 | import { | |
| 3 | OrchestrationDispatchCommandError, | |
| 4 | ProjectId, | |
| 5 | SourceControlRepositoryError, | |
| 6 | } from "@t3tools/contracts"; | |
| 7 | import * as Deferred from "effect/Deferred"; | |
| 8 | import * as Effect from "effect/Effect"; | |
| 9 | import * as Fiber from "effect/Fiber"; | |
| 10 | import * as Layer from "effect/Layer"; | |
| 11 | import * as Stream from "effect/Stream"; | |
| 12 | import * as TestClock from "effect/testing/TestClock"; | |
| 13 | ||
| 14 | import * as SourceControlRepositoryService from "../sourceControl/SourceControlRepositoryService.ts"; | |
| 15 | import * as ProjectCloneTracker from "./ProjectCloneTracker.ts"; | |
| 16 | import { parseGitCloneProgressLine } from "./gitCloneProgress.ts"; | |
| 17 | ||
| 18 | const projectId = ProjectId.make("project-1"); | |
| 19 | const startInput = { | |
| 20 | projectId, | |
| 21 | title: "t3code", | |
| 22 | createdAt: "2026-01-01T00:00:00.000Z", | |
| 23 | remoteUrl: "git@github.com:octocat/t3code.git", | |
| 24 | destinationPath: "/workspace/t3code", | |
| 25 | }; | |
| 26 | ||
| 27 | function makeHarness(options?: { | |
| 28 | readonly clone?: SourceControlRepositoryService.SourceControlRepositoryService["Service"]["cloneRepository"]; | |
| 29 | }) { | |
| 30 | const created: Array<{ projectId: ProjectId; workspaceRoot: string }> = []; | |
| 31 | const cloned: Array<ProjectId> = []; | |
| 32 | const discarded: Array<string> = []; | |
| 33 | const hooks: ProjectCloneTracker.ProjectCloneHooks = { | |
| 34 | createProject: (input) => | |
| 35 | Effect.sync(() => { | |
| 36 | created.push({ projectId: input.projectId, workspaceRoot: input.workspaceRoot }); | |
| 37 | }), | |
| 38 | onCloned: (input) => Effect.sync(() => void cloned.push(input.projectId)), | |
| 39 | }; | |
| 40 | const layer = ProjectCloneTracker.layer.pipe( | |
| 41 | Layer.provide( | |
| 42 | Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({ | |
| 43 | prepareClone: (input) => | |
| 44 | Effect.succeed({ | |
| 45 | destinationPath: input.destinationPath, | |
| 46 | remoteUrl: input.remoteUrl ?? "", | |
| 47 | cloneUrl: input.remoteUrl ?? "", | |
| 48 | repository: null, | |
| 49 | }), | |
| 50 | cloneRepository: | |
| 51 | options?.clone ?? | |
| 52 | ((input) => | |
| 53 | Effect.succeed({ | |
| 54 | cwd: input.destinationPath, | |
| 55 | remoteUrl: input.remoteUrl ?? "", | |
| 56 | repository: null, | |
| 57 | })), | |
| 58 | discardClone: (destination) => Effect.sync(() => void discarded.push(destination)), | |
| 59 | }), | |
| 60 | ), | |
| 61 | ); | |
| 62 | return { layer, hooks, created, cloned, discarded }; | |
| 63 | } | |
| 64 | ||
| 65 | describe("ProjectCloneTracker", () => { | |
| 66 | it.effect("creates the project first and reports the clone through the stream", () => { | |
| 67 | const release = Deferred.makeUnsafe<void>(); | |
| 68 | const harness = makeHarness({ | |
| 69 | clone: (input, options) => | |
| 70 | Effect.gen(function* () { | |
| 71 | yield* ( | |
| 72 | options?.onProgress?.({ stage: "receiving", percent: 40, detail: "1 MiB" }) ?? | |
| 73 | Effect.void | |
| 74 | ); | |
| 75 | yield* Deferred.await(release); | |
| 76 | return { cwd: input.destinationPath, remoteUrl: input.remoteUrl ?? "", repository: null }; | |
| 77 | }), | |
| 78 | }); | |
| 79 | return Effect.gen(function* () { | |
| 80 | const tracker = yield* ProjectCloneTracker.ProjectCloneTracker; | |
| 81 | const collected = yield* tracker.stream.pipe( | |
| 82 | Stream.takeUntil((clones) => clones[0]?.phase === "done"), | |
| 83 | Stream.runCollect, | |
| 84 | Effect.forkChild, | |
| 85 | ); | |
| 86 | yield* Effect.yieldNow; | |
| 87 | ||
| 88 | const result = yield* tracker.start(startInput, harness.hooks); | |
| 89 | expect(result.cwd).toBe("/workspace/t3code"); | |
| 90 | // The project exists before git runs so the draft can open immediately. | |
| 91 | expect(harness.created).toEqual([{ projectId, workspaceRoot: "/workspace/t3code" }]); | |
| 92 | ||
| 93 | yield* Effect.yieldNow; | |
| 94 | const running = yield* tracker.get(projectId); | |
| 95 | expect(running).toMatchObject({ phase: "running", stage: "receiving", percent: 40 }); | |
| 96 | ||
| 97 | yield* Deferred.succeed(release, undefined); | |
| 98 | const lists = yield* Fiber.join(collected); | |
| 99 | const final = lists.at(-1)?.[0]; | |
| 100 | expect(final).toMatchObject({ phase: "done", percent: 100 }); | |
| 101 | expect(harness.cloned).toEqual([projectId]); | |
| 102 | ||
| 103 | // Done clones drop out after the grace window so the toast can settle. | |
| 104 | yield* TestClock.adjust("31 seconds"); | |
| 105 | expect(yield* tracker.get(projectId)).toBeNull(); | |
| 106 | }).pipe(Effect.provide(harness.layer)); | |
| 107 | }); | |
| 108 | ||
| 109 | it.effect("keeps a failed clone with git's own explanation and retries it", () => { | |
| 110 | let attempts = 0; | |
| 111 | const harness = makeHarness({ | |
| 112 | clone: (input) => | |
| 113 | Effect.suspend(() => { | |
| 114 | attempts += 1; | |
| 115 | return attempts === 1 | |
| 116 | ? Effect.fail( | |
| 117 | new SourceControlRepositoryError({ | |
| 118 | operation: "cloneRepository", | |
| 119 | provider: "unknown", | |
| 120 | detail: "fatal: repository not found", | |
| 121 | }), | |
| 122 | ) | |
| 123 | : Effect.succeed({ | |
| 124 | cwd: input.destinationPath, | |
| 125 | remoteUrl: input.remoteUrl ?? "", | |
| 126 | repository: null, | |
| 127 | }); | |
| 128 | }), | |
| 129 | }); | |
| 130 | return Effect.gen(function* () { | |
| 131 | const tracker = yield* ProjectCloneTracker.ProjectCloneTracker; | |
| 132 | yield* tracker.start(startInput, harness.hooks); | |
| 133 | yield* Effect.yieldNow; | |
| 134 | const failed = yield* tracker.get(projectId); | |
| 135 | expect(failed).toMatchObject({ phase: "failed", error: "fatal: repository not found" }); | |
| 136 | ||
| 137 | expect(yield* tracker.retry(projectId)).toBe(true); | |
| 138 | // The partial checkout is cleared so git sees an empty destination. | |
| 139 | expect(harness.discarded).toEqual(["/workspace/t3code"]); | |
| 140 | yield* Effect.yieldNow; | |
| 141 | expect((yield* tracker.get(projectId))?.phase).toBe("done"); | |
| 142 | expect(attempts).toBe(2); | |
| 143 | }).pipe(Effect.provide(harness.layer)); | |
| 144 | }); | |
| 145 | ||
| 146 | it.effect("cancel interrupts the clone and removes the partial checkout", () => { | |
| 147 | const harness = makeHarness({ clone: () => Effect.never }); | |
| 148 | return Effect.gen(function* () { | |
| 149 | const tracker = yield* ProjectCloneTracker.ProjectCloneTracker; | |
| 150 | yield* tracker.start(startInput, harness.hooks); | |
| 151 | yield* Effect.yieldNow; | |
| 152 | ||
| 153 | expect(yield* tracker.cancel(projectId)).toBe(true); | |
| 154 | expect((yield* tracker.get(projectId))?.phase).toBe("cancelled"); | |
| 155 | expect(harness.discarded).toEqual(["/workspace/t3code"]); | |
| 156 | // Nothing left to cancel; retry is what brings it back. | |
| 157 | expect(yield* tracker.cancel(projectId)).toBe(false); | |
| 158 | expect(yield* tracker.retry(projectId)).toBe(true); | |
| 159 | }).pipe(Effect.provide(harness.layer)); | |
| 160 | }); | |
| 161 | ||
| 162 | it.effect("a cancel that lands after git finished keeps the checkout", () => { | |
| 163 | const gate = Deferred.makeUnsafe<void>(); | |
| 164 | const harness = makeHarness(); | |
| 165 | // The clone itself completes instantly; the post-clone hook is what hangs. | |
| 166 | const hooks: ProjectCloneTracker.ProjectCloneHooks = { | |
| 167 | ...harness.hooks, | |
| 168 | onCloned: () => Deferred.await(gate), | |
| 169 | }; | |
| 170 | return Effect.gen(function* () { | |
| 171 | const tracker = yield* ProjectCloneTracker.ProjectCloneTracker; | |
| 172 | yield* tracker.start(startInput, hooks); | |
| 173 | yield* Effect.yieldNow; | |
| 174 | expect((yield* tracker.get(projectId))?.phase).toBe("done"); | |
| 175 | expect(yield* tracker.cancel(projectId)).toBe(false); | |
| 176 | expect(harness.discarded).toEqual([]); | |
| 177 | yield* Deferred.succeed(gate, undefined); | |
| 178 | }).pipe(Effect.provide(harness.layer)); | |
| 179 | }); | |
| 180 | ||
| 181 | it.effect("hands git the credential-bearing URL while snapshots carry the redacted one", () => { | |
| 182 | const cloneUrls: Array<string> = []; | |
| 183 | const harness = makeHarness({ | |
| 184 | clone: (input) => | |
| 185 | Effect.sync(() => { | |
| 186 | cloneUrls.push(input.remoteUrl ?? ""); | |
| 187 | return { cwd: input.destinationPath, remoteUrl: "", repository: null }; | |
| 188 | }), | |
| 189 | }); | |
| 190 | const layer = ProjectCloneTracker.layer.pipe( | |
| 191 | Layer.provide( | |
| 192 | Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({ | |
| 193 | prepareClone: (input) => | |
| 194 | Effect.succeed({ | |
| 195 | destinationPath: input.destinationPath, | |
| 196 | remoteUrl: "https://github.com/octocat/t3code.git", | |
| 197 | cloneUrl: "https://user:s3cret@github.com/octocat/t3code.git", | |
| 198 | repository: null, | |
| 199 | }), | |
| 200 | cloneRepository: (input) => | |
| 201 | Effect.sync(() => { | |
| 202 | cloneUrls.push(input.remoteUrl ?? ""); | |
| 203 | return { cwd: input.destinationPath, remoteUrl: "", repository: null }; | |
| 204 | }), | |
| 205 | discardClone: () => Effect.void, | |
| 206 | }), | |
| 207 | ), | |
| 208 | ); | |
| 209 | return Effect.gen(function* () { | |
| 210 | const tracker = yield* ProjectCloneTracker.ProjectCloneTracker; | |
| 211 | const result = yield* tracker.start(startInput, harness.hooks); | |
| 212 | yield* Effect.yieldNow; | |
| 213 | expect(result.remoteUrl).toBe("https://github.com/octocat/t3code.git"); | |
| 214 | expect(cloneUrls).toEqual(["https://user:s3cret@github.com/octocat/t3code.git"]); | |
| 215 | }).pipe(Effect.provide(layer)); | |
| 216 | }); | |
| 217 | ||
| 218 | it.effect("discard forgets a project's clone when the project is deleted", () => { | |
| 219 | const harness = makeHarness({ clone: () => Effect.never }); | |
| 220 | return Effect.gen(function* () { | |
| 221 | const tracker = yield* ProjectCloneTracker.ProjectCloneTracker; | |
| 222 | yield* tracker.start(startInput, harness.hooks); | |
| 223 | yield* Effect.yieldNow; | |
| 224 | yield* tracker.discard(projectId); | |
| 225 | expect(yield* tracker.get(projectId)).toBeNull(); | |
| 226 | expect(harness.discarded).toEqual(["/workspace/t3code"]); | |
| 227 | }).pipe(Effect.provide(harness.layer)); | |
| 228 | }); | |
| 229 | ||
| 230 | it.effect("releases the claim when project creation fails", () => { | |
| 231 | const harness = makeHarness(); | |
| 232 | const hooks: ProjectCloneTracker.ProjectCloneHooks = { | |
| 233 | ...harness.hooks, | |
| 234 | createProject: () => | |
| 235 | Effect.fail(new OrchestrationDispatchCommandError({ message: "workspace root exists" })), | |
| 236 | }; | |
| 237 | return Effect.gen(function* () { | |
| 238 | const tracker = yield* ProjectCloneTracker.ProjectCloneTracker; | |
| 239 | const error = yield* Effect.flip(tracker.start(startInput, hooks)); | |
| 240 | expect(error.message).toContain("workspace root exists"); | |
| 241 | expect(yield* tracker.get(projectId)).toBeNull(); | |
| 242 | // The destination is free again for a corrected attempt. | |
| 243 | yield* tracker.start(startInput, harness.hooks); | |
| 244 | yield* Effect.yieldNow; | |
| 245 | expect((yield* tracker.get(projectId))?.phase).toBe("done"); | |
| 246 | }).pipe(Effect.provide(harness.layer)); | |
| 247 | }); | |
| 248 | ||
| 249 | it.effect("does not create a project when the clone cannot be prepared", () => { | |
| 250 | const harness = makeHarness(); | |
| 251 | const layer = ProjectCloneTracker.layer.pipe( | |
| 252 | Layer.provide( | |
| 253 | Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({ | |
| 254 | prepareClone: () => | |
| 255 | Effect.fail( | |
| 256 | new SourceControlRepositoryError({ | |
| 257 | operation: "cloneRepository", | |
| 258 | provider: "unknown", | |
| 259 | detail: "Destination path already exists and is not empty.", | |
| 260 | }), | |
| 261 | ), | |
| 262 | }), | |
| 263 | ), | |
| 264 | ); | |
| 265 | return Effect.gen(function* () { | |
| 266 | const tracker = yield* ProjectCloneTracker.ProjectCloneTracker; | |
| 267 | const error = yield* Effect.flip(tracker.start(startInput, harness.hooks)); | |
| 268 | expect(error.message).toContain("not empty"); | |
| 269 | expect(harness.created).toEqual([]); | |
| 270 | expect(yield* tracker.get(projectId)).toBeNull(); | |
| 271 | }).pipe(Effect.provide(layer)); | |
| 272 | }); | |
| 273 | }); | |
| 274 | ||
| 275 | describe("parseGitCloneProgressLine", () => { | |
| 276 | it("parses git's transfer counters and ignores other output", () => { | |
| 277 | expect( | |
| 278 | parseGitCloneProgressLine("Receiving objects: 45% (4500/10000), 12.30 MiB | 5.00 MiB/s"), | |
| 279 | ).toEqual({ stage: "receiving", percent: 45, detail: "12.30 MiB | 5.00 MiB/s" }); | |
| 280 | expect(parseGitCloneProgressLine("Resolving deltas: 100% (700/700), done.")).toEqual({ | |
| 281 | stage: "resolving", | |
| 282 | percent: 100, | |
| 283 | detail: null, | |
| 284 | }); | |
| 285 | expect(parseGitCloneProgressLine("remote: Compressing objects: 12% (3/25)")).toEqual({ | |
| 286 | stage: "counting", | |
| 287 | percent: 12, | |
| 288 | detail: null, | |
| 289 | }); | |
| 290 | expect(parseGitCloneProgressLine("Updating files: 78% (2104/2700)")).toEqual({ | |
| 291 | stage: "checkout", | |
| 292 | percent: 78, | |
| 293 | detail: null, | |
| 294 | }); | |
| 295 | expect(parseGitCloneProgressLine("remote: Enumerating objects: 10, done.")).toEqual({ | |
| 296 | stage: "counting", | |
| 297 | percent: null, | |
| 298 | detail: null, | |
| 299 | }); | |
| 300 | expect(parseGitCloneProgressLine("Cloning into 't3code'...")).toBeNull(); | |
| 301 | expect(parseGitCloneProgressLine("fatal: repository not found")).toBeNull(); | |
| 302 | }); | |
| 303 | }); |
apps/server/src/project/ProjectCloneTracker.ts created+487| ... | ... | @@ -0,0 +1,487 @@ |
| 1 | import type { | |
| 2 | OrchestrationCommand, | |
| 3 | ProjectCloneSnapshot, | |
| 4 | ProjectCloneStage, | |
| 5 | ProjectCloneStartInput, | |
| 6 | ProjectCloneStartResult, | |
| 7 | ProjectId, | |
| 8 | SourceControlRepositoryInfo, | |
| 9 | } from "@t3tools/contracts"; | |
| 10 | import { | |
| 11 | OrchestrationDispatchCommandError, | |
| 12 | PROJECT_CLONE_DETAIL_MAX_LENGTH, | |
| 13 | PROJECT_CLONE_ERROR_MAX_LENGTH, | |
| 14 | SourceControlRepositoryError, | |
| 15 | } from "@t3tools/contracts"; | |
| 16 | import * as Cause from "effect/Cause"; | |
| 17 | import * as Context from "effect/Context"; | |
| 18 | import * as DateTime from "effect/DateTime"; | |
| 19 | import * as Effect from "effect/Effect"; | |
| 20 | import * as Exit from "effect/Exit"; | |
| 21 | import * as Fiber from "effect/Fiber"; | |
| 22 | import * as Layer from "effect/Layer"; | |
| 23 | import * as PubSub from "effect/PubSub"; | |
| 24 | import * as Queue from "effect/Queue"; | |
| 25 | import * as Ref from "effect/Ref"; | |
| 26 | import * as Schema from "effect/Schema"; | |
| 27 | import * as Semaphore from "effect/Semaphore"; | |
| 28 | import * as Scope from "effect/Scope"; | |
| 29 | import * as Stream from "effect/Stream"; | |
| 30 | ||
| 31 | import * as SourceControlRepositoryService from "../sourceControl/SourceControlRepositoryService.ts"; | |
| 32 | ||
| 33 | /** | |
| 34 | * Runs repository clones that back newly added projects and tracks their | |
| 35 | * progress so clients can show it anywhere, not just in the surface that | |
| 36 | * started the clone. | |
| 37 | * | |
| 38 | * The clone is detached from the request that started it: the palette closes | |
| 39 | * immediately, the project already exists (pointing at the empty destination), | |
| 40 | * and the composer can hold a draft for it. Snapshots are memory only. A done | |
| 41 | * clone is dropped after a short grace window; a failed one stays until it is | |
| 42 | * retried or the server restarts, since the empty project is the durable | |
| 43 | * record the user can act on. | |
| 44 | */ | |
| 45 | export class ProjectCloneTracker extends Context.Service< | |
| 46 | ProjectCloneTracker, | |
| 47 | { | |
| 48 | /** | |
| 49 | * Resolves the remote, creates the project, and starts the clone in the | |
| 50 | * background. Fails before creating anything when the destination or | |
| 51 | * repository is unusable so the caller can report it inline. | |
| 52 | */ | |
| 53 | readonly start: ( | |
| 54 | input: ProjectCloneStartInput, | |
| 55 | hooks: ProjectCloneHooks, | |
| 56 | ) => Effect.Effect< | |
| 57 | ProjectCloneStartResult, | |
| 58 | SourceControlRepositoryError | OrchestrationDispatchCommandError | |
| 59 | >; | |
| 60 | /** Interrupts a running clone and deletes the partial checkout. */ | |
| 61 | readonly cancel: (projectId: ProjectId) => Effect.Effect<boolean>; | |
| 62 | /** Restarts a failed or cancelled clone into the same destination. */ | |
| 63 | readonly retry: (projectId: ProjectId) => Effect.Effect<boolean, SourceControlRepositoryError>; | |
| 64 | /** | |
| 65 | * Stops tracking a project's clone, interrupting it if it still runs and | |
| 66 | * removing an unfinished checkout. Called when the project is deleted. | |
| 67 | */ | |
| 68 | readonly discard: (projectId: ProjectId) => Effect.Effect<void>; | |
| 69 | readonly get: (projectId: ProjectId) => Effect.Effect<ProjectCloneSnapshot | null>; | |
| 70 | /** Emits every tracked clone first, then the full list after each change. */ | |
| 71 | readonly stream: Stream.Stream<ReadonlyArray<ProjectCloneSnapshot>>; | |
| 72 | } | |
| 73 | >()("t3/project/ProjectCloneTracker") {} | |
| 74 | ||
| 75 | /** | |
| 76 | * Orchestration side effects the caller owns. The tracker never depends on the | |
| 77 | * engine directly: the WebSocket handler dispatches with the client's origin, | |
| 78 | * and the tracker only needs the outcome. | |
| 79 | */ | |
| 80 | export interface ProjectCloneHooks { | |
| 81 | readonly createProject: (input: { | |
| 82 | readonly projectId: ProjectId; | |
| 83 | readonly title: string; | |
| 84 | readonly workspaceRoot: string; | |
| 85 | readonly createdAt: string; | |
| 86 | }) => Effect.Effect<void, OrchestrationDispatchCommandError>; | |
| 87 | /** Runs after a successful clone so cached repository identity and git status refresh. */ | |
| 88 | readonly onCloned: (input: { | |
| 89 | readonly projectId: ProjectId; | |
| 90 | readonly workspaceRoot: string; | |
| 91 | }) => Effect.Effect<void>; | |
| 92 | } | |
| 93 | ||
| 94 | /** Finished snapshots stay visible this long so a late subscriber sees the outcome. */ | |
| 95 | const DONE_RETENTION = "30 seconds"; | |
| 96 | ||
| 97 | function clampText(text: string, maxLength: number): string { | |
| 98 | return text.length <= maxLength ? text : `${text.slice(0, maxLength - 1)}…`; | |
| 99 | } | |
| 100 | ||
| 101 | const nowIso = Effect.map(DateTime.now, DateTime.formatIso); | |
| 102 | ||
| 103 | interface TrackedClone { | |
| 104 | readonly snapshot: ProjectCloneSnapshot; | |
| 105 | readonly fiber: Fiber.Fiber<unknown, unknown> | null; | |
| 106 | readonly hooks: ProjectCloneHooks; | |
| 107 | readonly input: { | |
| 108 | /** What git is given; may carry credentials and never leaves the server. */ | |
| 109 | readonly cloneUrl: string; | |
| 110 | readonly destinationPath: string; | |
| 111 | readonly repository: SourceControlRepositoryInfo | null; | |
| 112 | }; | |
| 113 | } | |
| 114 | ||
| 115 | /** @public Service construction is part of the canonical Effect module API. */ | |
| 116 | export const make = Effect.gen(function* () { | |
| 117 | const repositories = yield* SourceControlRepositoryService.SourceControlRepositoryService; | |
| 118 | const clones = yield* Ref.make(new Map<ProjectId, TrackedClone>()); | |
| 119 | const changes = yield* PubSub.unbounded<ReadonlyArray<ProjectCloneSnapshot>>(); | |
| 120 | const retentionFibers = new Map<ProjectId, Fiber.Fiber<unknown, never>>(); | |
| 121 | let sequence = 0; | |
| 122 | // Clone fibers outlive the RPC that started them but not the server. | |
| 123 | const cloneScope = yield* Scope.make("parallel"); | |
| 124 | yield* Effect.addFinalizer(() => Scope.close(cloneScope, Exit.void)); | |
| 125 | // start/cancel/retry/discard mutate the same entry and the same directory; | |
| 126 | // one at a time keeps a double-clicked Retry from racing two clones into it. | |
| 127 | const actionLock = yield* Semaphore.make(1); | |
| 128 | const locked = <A, E, R>(effect: Effect.Effect<A, E, R>) => actionLock.withPermits(1)(effect); | |
| 129 | ||
| 130 | const list = Ref.get(clones).pipe( | |
| 131 | Effect.map((current) => Array.from(current.values(), (tracked) => tracked.snapshot)), | |
| 132 | ); | |
| 133 | const publish = list.pipe(Effect.flatMap((snapshots) => PubSub.publish(changes, snapshots))); | |
| 134 | ||
| 135 | const modify = ( | |
| 136 | projectId: ProjectId, | |
| 137 | mutate: (tracked: TrackedClone) => TrackedClone, | |
| 138 | ): Effect.Effect<ProjectCloneSnapshot | null> => | |
| 139 | Ref.modify(clones, (current) => { | |
| 140 | const existing = current.get(projectId); | |
| 141 | if (!existing) return [null, current] as const; | |
| 142 | const nextTracked = mutate(existing); | |
| 143 | const nextSnapshot = { ...nextTracked.snapshot, sequence: ++sequence }; | |
| 144 | const next = new Map(current); | |
| 145 | next.set(projectId, { ...nextTracked, snapshot: nextSnapshot }); | |
| 146 | return [nextSnapshot, next] as const; | |
| 147 | }).pipe(Effect.tap((snapshot) => (snapshot ? publish : Effect.void))); | |
| 148 | ||
| 149 | const clearRetention = (projectId: ProjectId) => { | |
| 150 | const fiber = retentionFibers.get(projectId); | |
| 151 | retentionFibers.delete(projectId); | |
| 152 | return fiber ? Fiber.interrupt(fiber).pipe(Effect.ignore) : Effect.void; | |
| 153 | }; | |
| 154 | ||
| 155 | const remove = (projectId: ProjectId) => | |
| 156 | Ref.update(clones, (current) => { | |
| 157 | if (!current.has(projectId)) return current; | |
| 158 | const next = new Map(current); | |
| 159 | next.delete(projectId); | |
| 160 | return next; | |
| 161 | }).pipe(Effect.andThen(publish)); | |
| 162 | ||
| 163 | const scheduleRemoval = (projectId: ProjectId) => | |
| 164 | Effect.gen(function* () { | |
| 165 | yield* clearRetention(projectId); | |
| 166 | const fiber = yield* remove(projectId).pipe( | |
| 167 | Effect.delay(DONE_RETENTION), | |
| 168 | Effect.ensuring( | |
| 169 | Effect.sync(() => { | |
| 170 | if (retentionFibers.get(projectId) === fiber) retentionFibers.delete(projectId); | |
| 171 | }), | |
| 172 | ), | |
| 173 | Effect.forkDetach, | |
| 174 | ); | |
| 175 | retentionFibers.set(projectId, fiber); | |
| 176 | }); | |
| 177 | ||
| 178 | const progress = ( | |
| 179 | projectId: ProjectId, | |
| 180 | update: { | |
| 181 | readonly stage: ProjectCloneStage; | |
| 182 | readonly percent: number | null; | |
| 183 | readonly detail: string | null; | |
| 184 | }, | |
| 185 | ) => | |
| 186 | modify(projectId, (tracked) => ({ | |
| 187 | ...tracked, | |
| 188 | snapshot: { | |
| 189 | ...tracked.snapshot, | |
| 190 | stage: update.stage, | |
| 191 | percent: update.percent, | |
| 192 | detail: | |
| 193 | update.detail === null ? null : clampText(update.detail, PROJECT_CLONE_DETAIL_MAX_LENGTH), | |
| 194 | }, | |
| 195 | })).pipe(Effect.asVoid); | |
| 196 | ||
| 197 | const finish = ( | |
| 198 | projectId: ProjectId, | |
| 199 | phase: "done" | "failed" | "cancelled", | |
| 200 | error: string | null, | |
| 201 | ) => | |
| 202 | Effect.gen(function* () { | |
| 203 | const endedAt = yield* nowIso; | |
| 204 | yield* modify(projectId, (tracked) => ({ | |
| 205 | ...tracked, | |
| 206 | fiber: null, | |
| 207 | snapshot: { | |
| 208 | ...tracked.snapshot, | |
| 209 | phase, | |
| 210 | endedAt, | |
| 211 | percent: phase === "done" ? 100 : tracked.snapshot.percent, | |
| 212 | error: error === null ? null : clampText(error, PROJECT_CLONE_ERROR_MAX_LENGTH), | |
| 213 | }, | |
| 214 | })); | |
| 215 | if (phase === "done") yield* scheduleRemoval(projectId); | |
| 216 | }); | |
| 217 | ||
| 218 | /** | |
| 219 | * The clone body. Runs in its own fiber; the tracker records the outcome | |
| 220 | * through `onExit`, which still runs when the fiber is interrupted (a | |
| 221 | * `matchCause` handler would be skipped). Once git has finished the clone | |
| 222 | * is marked done before the post-clone hook runs, so a late Cancel cannot | |
| 223 | * tear down a complete checkout. | |
| 224 | */ | |
| 225 | const runClone = (projectId: ProjectId, tracked: TrackedClone) => | |
| 226 | repositories | |
| 227 | .cloneRepository( | |
| 228 | { remoteUrl: tracked.input.cloneUrl, destinationPath: tracked.input.destinationPath }, | |
| 229 | { onProgress: (update) => progress(projectId, update), timeoutMs: null }, | |
| 230 | ) | |
| 231 | .pipe( | |
| 232 | Effect.onExit((exit) => | |
| 233 | Exit.isSuccess(exit) | |
| 234 | ? finish(projectId, "done", null) | |
| 235 | : Cause.hasInterruptsOnly(exit.cause) | |
| 236 | ? finish(projectId, "cancelled", null) | |
| 237 | : finish(projectId, "failed", describeCloneFailure(exit.cause)), | |
| 238 | ), | |
| 239 | Effect.flatMap(() => | |
| 240 | tracked.hooks | |
| 241 | .onCloned({ projectId, workspaceRoot: tracked.input.destinationPath }) | |
| 242 | .pipe(Effect.ignoreCause({ log: true })), | |
| 243 | ), | |
| 244 | Effect.ignoreCause(), | |
| 245 | ); | |
| 246 | ||
| 247 | const launch = (projectId: ProjectId) => | |
| 248 | Effect.gen(function* () { | |
| 249 | const current = yield* Ref.get(clones); | |
| 250 | const tracked = current.get(projectId); | |
| 251 | if (!tracked) return; | |
| 252 | const fiber = yield* runClone(projectId, tracked).pipe(Effect.forkIn(cloneScope)); | |
| 253 | yield* Ref.update(clones, (map) => { | |
| 254 | const existing = map.get(projectId); | |
| 255 | if (!existing) return map; | |
| 256 | const next = new Map(map); | |
| 257 | next.set(projectId, { ...existing, fiber }); | |
| 258 | return next; | |
| 259 | }); | |
| 260 | }); | |
| 261 | ||
| 262 | const start: ProjectCloneTracker["Service"]["start"] = Effect.fn("ProjectCloneTracker.start")( | |
| 263 | function* (input, hooks) { | |
| 264 | const prepared = yield* repositories.prepareClone(input); | |
| 265 | const startedAt = yield* nowIso; | |
| 266 | const snapshot: ProjectCloneSnapshot = { | |
| 267 | projectId: input.projectId, | |
| 268 | remoteUrl: prepared.remoteUrl, | |
| 269 | destinationPath: prepared.destinationPath, | |
| 270 | repository: prepared.repository, | |
| 271 | phase: "running", | |
| 272 | stage: "connecting", | |
| 273 | percent: null, | |
| 274 | detail: null, | |
| 275 | error: null, | |
| 276 | startedAt, | |
| 277 | endedAt: null, | |
| 278 | sequence: ++sequence, | |
| 279 | }; | |
| 280 | const claimed = yield* Ref.modify(clones, (current) => { | |
| 281 | // A second start for the same project, or for a destination another | |
| 282 | // clone already owns, must not race two gits into one directory. | |
| 283 | const conflict = Array.from(current.values()).some( | |
| 284 | (tracked) => | |
| 285 | tracked.snapshot.projectId === input.projectId || | |
| 286 | tracked.input.destinationPath === prepared.destinationPath, | |
| 287 | ); | |
| 288 | if (conflict) return [false, current] as const; | |
| 289 | const next = new Map(current); | |
| 290 | next.set(input.projectId, { | |
| 291 | snapshot, | |
| 292 | fiber: null, | |
| 293 | hooks, | |
| 294 | input: { | |
| 295 | cloneUrl: prepared.cloneUrl, | |
| 296 | destinationPath: prepared.destinationPath, | |
| 297 | repository: prepared.repository, | |
| 298 | }, | |
| 299 | }); | |
| 300 | return [true, next] as const; | |
| 301 | }); | |
| 302 | if (!claimed) { | |
| 303 | return yield* new SourceControlRepositoryError({ | |
| 304 | operation: "cloneRepository", | |
| 305 | provider: input.provider ?? "unknown", | |
| 306 | detail: "A clone into this destination is already in progress.", | |
| 307 | }); | |
| 308 | } | |
| 309 | // Everything after the claim runs to completion even if the requesting | |
| 310 | // connection drops: a claimed entry with no fiber could neither be | |
| 311 | // cancelled nor retried. Any failure in here releases the claim. | |
| 312 | yield* Effect.uninterruptible( | |
| 313 | Effect.gen(function* () { | |
| 314 | yield* clearRetention(input.projectId); | |
| 315 | // The entry is registered before the project exists so a | |
| 316 | // thread.create or project.delete racing this call already sees it. | |
| 317 | yield* hooks.createProject({ | |
| 318 | projectId: input.projectId, | |
| 319 | title: input.title, | |
| 320 | workspaceRoot: prepared.destinationPath, | |
| 321 | createdAt: input.createdAt, | |
| 322 | }); | |
| 323 | yield* publish; | |
| 324 | yield* launch(input.projectId); | |
| 325 | }).pipe(Effect.tapError(() => remove(input.projectId))), | |
| 326 | ); | |
| 327 | return { | |
| 328 | projectId: input.projectId, | |
| 329 | cwd: prepared.destinationPath, | |
| 330 | remoteUrl: prepared.remoteUrl, | |
| 331 | repository: prepared.repository, | |
| 332 | }; | |
| 333 | }, | |
| 334 | ); | |
| 335 | ||
| 336 | const get: ProjectCloneTracker["Service"]["get"] = (projectId) => | |
| 337 | Ref.get(clones).pipe(Effect.map((current) => current.get(projectId)?.snapshot ?? null)); | |
| 338 | ||
| 339 | const cancel: ProjectCloneTracker["Service"]["cancel"] = (projectId) => | |
| 340 | Effect.gen(function* () { | |
| 341 | const current = yield* Ref.get(clones); | |
| 342 | const tracked = current.get(projectId); | |
| 343 | if (!tracked || tracked.snapshot.phase !== "running" || !tracked.fiber) return false; | |
| 344 | // Uninterruptible past this point: a client that disconnects mid-cancel | |
| 345 | // must not leave a dead fiber behind a "running" snapshot. | |
| 346 | yield* Effect.uninterruptible( | |
| 347 | Effect.gen(function* () { | |
| 348 | yield* Fiber.interrupt(tracked.fiber!); | |
| 349 | const after = yield* get(projectId); | |
| 350 | // Git finished in the window before the interrupt landed: keep it. | |
| 351 | if (after?.phase === "done") return; | |
| 352 | if (after?.phase === "running") yield* finish(projectId, "cancelled", null); | |
| 353 | // The partial checkout goes so a retry starts from an empty destination. | |
| 354 | yield* repositories.discardClone(tracked.input.destinationPath).pipe(Effect.ignore); | |
| 355 | }), | |
| 356 | ); | |
| 357 | return true; | |
| 358 | }); | |
| 359 | ||
| 360 | const retry: ProjectCloneTracker["Service"]["retry"] = (projectId) => | |
| 361 | Effect.gen(function* () { | |
| 362 | const current = yield* Ref.get(clones); | |
| 363 | const tracked = current.get(projectId); | |
| 364 | if ( | |
| 365 | !tracked || | |
| 366 | (tracked.snapshot.phase !== "failed" && tracked.snapshot.phase !== "cancelled") | |
| 367 | ) { | |
| 368 | return false; | |
| 369 | } | |
| 370 | // A retry into leftover files would fail on the non-empty destination | |
| 371 | // with a less useful message, so a cleanup failure is the error here. | |
| 372 | yield* repositories.discardClone(tracked.input.destinationPath); | |
| 373 | const startedAt = yield* nowIso; | |
| 374 | yield* modify(projectId, (entry) => ({ | |
| 375 | ...entry, | |
| 376 | snapshot: { | |
| 377 | ...entry.snapshot, | |
| 378 | phase: "running", | |
| 379 | stage: "connecting", | |
| 380 | percent: null, | |
| 381 | detail: null, | |
| 382 | error: null, | |
| 383 | startedAt, | |
| 384 | endedAt: null, | |
| 385 | }, | |
| 386 | })); | |
| 387 | yield* launch(projectId); | |
| 388 | return true; | |
| 389 | }); | |
| 390 | ||
| 391 | const discard: ProjectCloneTracker["Service"]["discard"] = (projectId) => | |
| 392 | Effect.gen(function* () { | |
| 393 | const current = yield* Ref.get(clones); | |
| 394 | const tracked = current.get(projectId); | |
| 395 | if (!tracked) return; | |
| 396 | if (tracked.fiber) yield* Fiber.interrupt(tracked.fiber); | |
| 397 | // Git may have finished while the interrupt was landing; the project | |
| 398 | // is going away either way, but a complete checkout is the user's. | |
| 399 | const after = yield* get(projectId); | |
| 400 | if (after?.phase !== "done") { | |
| 401 | yield* repositories.discardClone(tracked.input.destinationPath).pipe(Effect.ignore); | |
| 402 | } | |
| 403 | yield* clearRetention(projectId); | |
| 404 | yield* remove(projectId); | |
| 405 | }); | |
| 406 | ||
| 407 | // One-slot sliding mailbox per subscriber: a slow socket only ever holds the | |
| 408 | // newest list, and lists are whole states so skipping intermediates is safe. | |
| 409 | const stream: ProjectCloneTracker["Service"]["stream"] = Stream.callback< | |
| 410 | ReadonlyArray<ProjectCloneSnapshot> | |
| 411 | >( | |
| 412 | (mailbox) => | |
| 413 | Effect.gen(function* () { | |
| 414 | const subscription = yield* PubSub.subscribe(changes); | |
| 415 | Queue.offerUnsafe(mailbox, yield* list); | |
| 416 | yield* Stream.fromSubscription(subscription).pipe( | |
| 417 | Stream.runForEach((snapshots) => | |
| 418 | Effect.sync(() => Queue.offerUnsafe(mailbox, snapshots)), | |
| 419 | ), | |
| 420 | Effect.forkScoped, | |
| 421 | ); | |
| 422 | }), | |
| 423 | { bufferSize: 1, strategy: "sliding" }, | |
| 424 | ); | |
| 425 | ||
| 426 | return ProjectCloneTracker.of({ | |
| 427 | start: (input, hooks) => locked(start(input, hooks)), | |
| 428 | cancel: (projectId) => locked(cancel(projectId)), | |
| 429 | retry: (projectId) => locked(retry(projectId)), | |
| 430 | discard: (projectId) => locked(discard(projectId)), | |
| 431 | get, | |
| 432 | stream, | |
| 433 | }); | |
| 434 | }); | |
| 435 | ||
| 436 | const isSourceControlRepositoryError = Schema.is(SourceControlRepositoryError); | |
| 437 | ||
| 438 | /** | |
| 439 | * A project whose clone has not landed has no files to work in. Every | |
| 440 | * dispatch transport (WebSocket, HTTP) runs this before normalizing so no | |
| 441 | * client can start a thread on an empty tree, and no attachment copies are | |
| 442 | * made for a command that is about to be refused. | |
| 443 | */ | |
| 444 | export const rejectCommandsDuringClone = ( | |
| 445 | tracker: ProjectCloneTracker["Service"], | |
| 446 | command: { readonly type: string; readonly projectId?: ProjectId; readonly bootstrap?: unknown }, | |
| 447 | ): Effect.Effect<void, OrchestrationDispatchCommandError> => | |
| 448 | Effect.gen(function* () { | |
| 449 | const projectId = | |
| 450 | command.type === "thread.create" | |
| 451 | ? (command.projectId ?? null) | |
| 452 | : command.type === "thread.turn.start" | |
| 453 | ? bootstrapProjectId(command.bootstrap) | |
| 454 | : null; | |
| 455 | if (projectId === null) return; | |
| 456 | const clone = yield* tracker.get(projectId); | |
| 457 | if (clone === null || clone.phase === "done") return; | |
| 458 | return yield* new OrchestrationDispatchCommandError({ | |
| 459 | message: | |
| 460 | clone.phase === "running" | |
| 461 | ? "The repository is still being cloned." | |
| 462 | : "The repository was not cloned. Retry the clone first.", | |
| 463 | }); | |
| 464 | }); | |
| 465 | ||
| 466 | function bootstrapProjectId(bootstrap: unknown): ProjectId | null { | |
| 467 | if (typeof bootstrap !== "object" || bootstrap === null) return null; | |
| 468 | const createThread = (bootstrap as { createThread?: { projectId?: ProjectId } }).createThread; | |
| 469 | return createThread?.projectId ?? null; | |
| 470 | } | |
| 471 | ||
| 472 | /** Removing a project mid-clone stops the clone and drops its partial checkout. */ | |
| 473 | export const discardCloneForDeletedProject = ( | |
| 474 | tracker: ProjectCloneTracker["Service"], | |
| 475 | command: OrchestrationCommand, | |
| 476 | ): Effect.Effect<void> => | |
| 477 | command.type === "project.delete" ? tracker.discard(command.projectId) : Effect.void; | |
| 478 | ||
| 479 | function describeCloneFailure(cause: Cause.Cause<unknown>): string { | |
| 480 | const error = Cause.squash(cause); | |
| 481 | if (isSourceControlRepositoryError(error)) return error.detail; | |
| 482 | return error instanceof Error && error.message.trim().length > 0 | |
| 483 | ? error.message | |
| 484 | : "The repository could not be cloned."; | |
| 485 | } | |
| 486 | ||
| 487 | export const layer = Layer.effect(ProjectCloneTracker, make); |
apps/server/src/project/gitCloneProgress.ts created+44| ... | ... | @@ -0,0 +1,44 @@ |
| 1 | import type { ProjectCloneStage } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | export interface GitCloneProgressLine { | |
| 4 | readonly stage: ProjectCloneStage; | |
| 5 | readonly percent: number | null; | |
| 6 | /** Transfer detail after the count, e.g. `12.30 MiB | 5.00 MiB/s`. */ | |
| 7 | readonly detail: string | null; | |
| 8 | } | |
| 9 | ||
| 10 | const STAGE_PREFIXES: ReadonlyArray<readonly [RegExp, ProjectCloneStage]> = [ | |
| 11 | [/^remote: Enumerating objects/, "counting"], | |
| 12 | [/^remote: Counting objects/, "counting"], | |
| 13 | [/^remote: Compressing objects/, "counting"], | |
| 14 | [/^Receiving objects/, "receiving"], | |
| 15 | [/^Resolving deltas/, "resolving"], | |
| 16 | [/^Updating files/, "checkout"], | |
| 17 | [/^Checking out files/, "checkout"], | |
| 18 | ]; | |
| 19 | ||
| 20 | const PERCENT = /:\s+(\d+)%\s+\((\d+)\/(\d+)\)(?:,\s*(.*?))?\s*(?:,\s*done\.)?\s*$/; | |
| 21 | ||
| 22 | /** | |
| 23 | * Parses one line of `git clone --progress` stderr. Git redraws each counter | |
| 24 | * with a bare `\r`, so callers hand over each redraw as its own line. Lines | |
| 25 | * that are not progress counters (hints, warnings, `Cloning into ...`) return | |
| 26 | * null and are left for the error surface. | |
| 27 | */ | |
| 28 | export function parseGitCloneProgressLine(line: string): GitCloneProgressLine | null { | |
| 29 | const trimmed = line.trim(); | |
| 30 | const stageEntry = STAGE_PREFIXES.find(([pattern]) => pattern.test(trimmed)); | |
| 31 | if (!stageEntry) return null; | |
| 32 | const stage = stageEntry[1]; | |
| 33 | const match = PERCENT.exec(trimmed); | |
| 34 | if (!match) return { stage, percent: null, detail: null }; | |
| 35 | const percent = Number(match[1]); | |
| 36 | const rawDetail = match[4]?.trim() ?? ""; | |
| 37 | // The trailer of a finished line is "done." which carries no information. | |
| 38 | const detail = rawDetail.length > 0 && rawDetail !== "done." ? rawDetail : null; | |
| 39 | return { | |
| 40 | stage, | |
| 41 | percent: Number.isFinite(percent) ? Math.max(0, Math.min(100, percent)) : null, | |
| 42 | detail, | |
| 43 | }; | |
| 44 | } |
apps/server/src/server.test.ts+108| ... | ... | @@ -148,6 +148,7 @@ import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; |
| 148 | 148 | import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts"; |
| 149 | 149 | import * as ServerSettings from "./serverSettings.ts"; |
| 150 | 150 | import * as TerminalManager from "./terminal/Manager.ts"; |
| 151 | import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts"; | |
| 151 | 152 | import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts"; |
| 152 | 153 | import * as PreviewManager from "./preview/Manager.ts"; |
| 153 | 154 | import * as PortScanner from "./preview/PortScanner.ts"; |
| ... | ... | @@ -933,6 +934,13 @@ const buildAppUnderTest = (options?: { |
| 933 | 934 | ...options?.layers?.terminalManager, |
| 934 | 935 | }), |
| 935 | 936 | WorktreeSetupTracker.layer, |
| 937 | ProjectCloneTracker.layer.pipe( | |
| 938 | Layer.provide( | |
| 939 | Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({ | |
| 940 | ...options?.layers?.sourceControlRepositoryService, | |
| 941 | }), | |
| 942 | ), | |
| 943 | ), | |
| 936 | 944 | ), |
| 937 | 945 | ), |
| 938 | 946 | Layer.provide( |
| ... | ... | @@ -7238,6 +7246,106 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 7238 | 7246 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 7239 | 7247 | ); |
| 7240 | 7248 | |
| 7249 | it.effect("starts a project clone in the background and blocks threads until it lands", () => | |
| 7250 | Effect.gen(function* () { | |
| 7251 | const fs = yield* FileSystem.FileSystem; | |
| 7252 | const path = yield* Path.Path; | |
| 7253 | const parentDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ws-project-clone-" }); | |
| 7254 | const destinationPath = path.join(parentDir, "t3code"); | |
| 7255 | const projectId = ProjectId.make("project-clone-1"); | |
| 7256 | const dispatched: Array<string> = []; | |
| 7257 | const cloneGate = yield* Deferred.make<void>(); | |
| 7258 | const metaUpdateDispatched = yield* Deferred.make<void>(); | |
| 7259 | ||
| 7260 | yield* buildAppUnderTest({ | |
| 7261 | layers: { | |
| 7262 | orchestrationEngine: { | |
| 7263 | dispatch: (command) => | |
| 7264 | Effect.sync(() => { | |
| 7265 | dispatched.push(command.type); | |
| 7266 | return { sequence: dispatched.length }; | |
| 7267 | }).pipe( | |
| 7268 | Effect.tap(() => | |
| 7269 | command.type === "project.meta.update" | |
| 7270 | ? Deferred.succeed(metaUpdateDispatched, undefined) | |
| 7271 | : Effect.void, | |
| 7272 | ), | |
| 7273 | ), | |
| 7274 | }, | |
| 7275 | sourceControlRepositoryService: { | |
| 7276 | prepareClone: (input) => | |
| 7277 | Effect.succeed({ | |
| 7278 | destinationPath: input.destinationPath, | |
| 7279 | remoteUrl: input.remoteUrl ?? "", | |
| 7280 | cloneUrl: input.remoteUrl ?? "", | |
| 7281 | repository: null, | |
| 7282 | }), | |
| 7283 | cloneRepository: (input) => | |
| 7284 | Deferred.await(cloneGate).pipe( | |
| 7285 | Effect.as({ | |
| 7286 | cwd: input.destinationPath, | |
| 7287 | remoteUrl: input.remoteUrl ?? "", | |
| 7288 | repository: null, | |
| 7289 | }), | |
| 7290 | ), | |
| 7291 | }, | |
| 7292 | }, | |
| 7293 | }); | |
| 7294 | ||
| 7295 | const wsUrl = yield* getWsServerUrl("/ws"); | |
| 7296 | yield* Effect.scoped( | |
| 7297 | withWsRpcClient(wsUrl, (client) => | |
| 7298 | Effect.gen(function* () { | |
| 7299 | const started = yield* client[WS_METHODS.projectCloneStart]({ | |
| 7300 | projectId, | |
| 7301 | title: "t3code", | |
| 7302 | createdAt: "2026-01-01T00:00:00.000Z", | |
| 7303 | remoteUrl: "git@github.com:octocat/t3code.git", | |
| 7304 | destinationPath, | |
| 7305 | }); | |
| 7306 | assert.equal(started.cwd, destinationPath); | |
| 7307 | // The project exists before the clone finishes. | |
| 7308 | assert.deepEqual(dispatched, ["project.create"]); | |
| 7309 | ||
| 7310 | const blocked = yield* Effect.flip( | |
| 7311 | client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ | |
| 7312 | type: "thread.create", | |
| 7313 | commandId: CommandId.make("cmd-thread-create-while-cloning"), | |
| 7314 | threadId: ThreadId.make("thread-while-cloning"), | |
| 7315 | projectId, | |
| 7316 | title: "Draft", | |
| 7317 | modelSelection: { | |
| 7318 | instanceId: ProviderInstanceId.make("codex"), | |
| 7319 | model: "gpt-5-codex", | |
| 7320 | }, | |
| 7321 | runtimeMode: "full-access", | |
| 7322 | interactionMode: "default", | |
| 7323 | branch: null, | |
| 7324 | worktreePath: null, | |
| 7325 | createdAt: "2026-01-01T00:00:01.000Z", | |
| 7326 | }), | |
| 7327 | ); | |
| 7328 | assert.include(String(blocked.message), "still being cloned"); | |
| 7329 | ||
| 7330 | const snapshots = yield* client[WS_METHODS.subscribeProjectClones]({}).pipe( | |
| 7331 | Stream.takeUntil((clones) => clones[0]?.phase === "done"), | |
| 7332 | Stream.runCollect, | |
| 7333 | Effect.forkChild, | |
| 7334 | ); | |
| 7335 | yield* Effect.yieldNow; | |
| 7336 | yield* Deferred.succeed(cloneGate, undefined); | |
| 7337 | const lists = yield* Fiber.join(snapshots); | |
| 7338 | assert.equal(lists.at(-1)?.[0]?.phase, "done"); | |
| 7339 | // The finished clone refreshes the project so its repository | |
| 7340 | // identity updates. That hook runs after the done snapshot. | |
| 7341 | yield* Deferred.await(metaUpdateDispatched); | |
| 7342 | assert.deepEqual(dispatched, ["project.create", "project.meta.update"]); | |
| 7343 | }), | |
| 7344 | ), | |
| 7345 | ); | |
| 7346 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | |
| 7347 | ); | |
| 7348 | ||
| 7241 | 7349 | it.effect("records thread analytics only after a client command succeeds", () => |
| 7242 | 7350 | Effect.gen(function* () { |
| 7243 | 7351 | const effects: string[] = []; |
apps/server/src/server.ts+6| ... | ... | @@ -103,6 +103,7 @@ import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts"; |
| 103 | 103 | import * as VcsProcess from "./vcs/VcsProcess.ts"; |
| 104 | 104 | import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; |
| 105 | 105 | import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; |
| 106 | import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts"; | |
| 106 | 107 | import * as GitWorkflowService from "./git/GitWorkflowService.ts"; |
| 107 | 108 | import * as ReviewService from "./review/ReviewService.ts"; |
| 108 | 109 | import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; |
| ... | ... | @@ -354,6 +355,10 @@ const SourceControlRepositoryServiceLayerLive = SourceControlRepositoryService.l |
| 354 | 355 | Layer.provideMerge(SourceControlProviderRegistryLayerLive), |
| 355 | 356 | ); |
| 356 | 357 | |
| 358 | const ProjectCloneTrackerLayerLive = ProjectCloneTracker.layer.pipe( | |
| 359 | Layer.provide(SourceControlRepositoryServiceLayerLive), | |
| 360 | ); | |
| 361 | ||
| 357 | 362 | const ReviewLayerLive = ReviewService.layer.pipe( |
| 358 | 363 | Layer.provideMerge(GitVcsDriver.layer), |
| 359 | 364 | Layer.provideMerge(VcsDriverRegistryLayerLive), |
| ... | ... | @@ -366,6 +371,7 @@ const VcsLayerLive = Layer.empty.pipe( |
| 366 | 371 | Layer.provideMerge(GitWorkflowLayerLive), |
| 367 | 372 | Layer.provideMerge(ReviewLayerLive), |
| 368 | 373 | Layer.provideMerge(SourceControlRepositoryServiceLayerLive), |
| 374 | Layer.provideMerge(ProjectCloneTrackerLayerLive), | |
| 369 | 375 | Layer.provideMerge( |
| 370 | 376 | VcsStatusBroadcaster.layer.pipe( |
| 371 | 377 | Layer.provide(GitWorkflowLayerLive), |
apps/server/src/sourceControl/SourceControlRepositoryService.test.ts+149-1| ... | ... | @@ -178,7 +178,7 @@ it.effect("clones a looked-up repository into the requested destination", () => |
| 178 | 178 | assert.deepStrictEqual(cloneCalls, [ |
| 179 | 179 | { |
| 180 | 180 | cwd: parent, |
| 181 | args: ["clone", CLONE_URLS.url, "t3code"], | |
| 181 | args: ["clone", "--progress", CLONE_URLS.url, "t3code"], | |
| 182 | 182 | }, |
| 183 | 183 | ]); |
| 184 | 184 | }).pipe( |
| ... | ... | @@ -197,6 +197,154 @@ it.effect("clones a looked-up repository into the requested destination", () => |
| 197 | 197 | }).pipe(Effect.provide(NodeServices.layer)), |
| 198 | 198 | ); |
| 199 | 199 | |
| 200 | it.effect("reports clone progress from git's stderr and keeps its error text on failure", () => | |
| 201 | Effect.gen(function* () { | |
| 202 | const fs = yield* FileSystem.FileSystem; | |
| 203 | const path = yield* Path.Path; | |
| 204 | const parent = yield* fs.makeTempDirectoryScoped({ | |
| 205 | prefix: "t3-source-control-clone-progress-", | |
| 206 | }); | |
| 207 | const destinationPath = path.join(parent, "t3code"); | |
| 208 | const progress: Array<{ stage: string; percent: number | null; detail: string | null }> = []; | |
| 209 | ||
| 210 | const stderrLines = [ | |
| 211 | "Cloning into 't3code'...", | |
| 212 | "remote: Enumerating objects: 10, done.", | |
| 213 | "Receiving objects: 40% (4/10), 1.00 MiB | 2.00 MiB/s", | |
| 214 | "Receiving objects: 100% (10/10), 2.50 MiB | 2.00 MiB/s, done.", | |
| 215 | "fatal: early EOF", | |
| 216 | "fatal: unable to access 'https://user:s3c@ret@github.com/octocat/t3code.git/': could not resolve host", | |
| 217 | ]; | |
| 218 | const error = yield* Effect.gen(function* () { | |
| 219 | const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; | |
| 220 | return yield* Effect.flip( | |
| 221 | service.cloneRepository( | |
| 222 | { remoteUrl: CLONE_URLS.sshUrl, destinationPath }, | |
| 223 | { onProgress: (line) => Effect.sync(() => void progress.push(line)) }, | |
| 224 | ), | |
| 225 | ); | |
| 226 | }).pipe( | |
| 227 | Effect.provide( | |
| 228 | makeLayer({ | |
| 229 | git: { | |
| 230 | execute: (input) => | |
| 231 | Effect.gen(function* () { | |
| 232 | for (const line of stderrLines) { | |
| 233 | yield* input.progress?.onStderrLine?.(line) ?? Effect.void; | |
| 234 | } | |
| 235 | return yield* new GitCommandError({ | |
| 236 | operation: input.operation, | |
| 237 | command: "git", | |
| 238 | cwd: input.cwd, | |
| 239 | detail: "Git command exited with a non-zero status.", | |
| 240 | exitCode: 128, | |
| 241 | }); | |
| 242 | }), | |
| 243 | }, | |
| 244 | }), | |
| 245 | ), | |
| 246 | ); | |
| 247 | ||
| 248 | assert.deepStrictEqual(progress, [ | |
| 249 | { stage: "counting", percent: null, detail: null }, | |
| 250 | { stage: "receiving", percent: 40, detail: "1.00 MiB | 2.00 MiB/s" }, | |
| 251 | { stage: "receiving", percent: 100, detail: "2.50 MiB | 2.00 MiB/s" }, | |
| 252 | ]); | |
| 253 | // Git echoes the remote in some failures; the credentials must not follow. | |
| 254 | assert.strictEqual( | |
| 255 | error.detail, | |
| 256 | "fatal: early EOF fatal: unable to access 'https://github.com/octocat/t3code.git/': could not resolve host", | |
| 257 | ); | |
| 258 | }).pipe(Effect.provide(NodeServices.layer)), | |
| 259 | ); | |
| 260 | ||
| 261 | it.effect("strips embedded credentials from the remote URL it reports", () => | |
| 262 | Effect.gen(function* () { | |
| 263 | const fs = yield* FileSystem.FileSystem; | |
| 264 | const path = yield* Path.Path; | |
| 265 | const parent = yield* fs.makeTempDirectoryScoped({ prefix: "t3-source-control-redact-" }); | |
| 266 | const destinationPath = path.join(parent, "t3code"); | |
| 267 | const cloneArgs: Array<ReadonlyArray<string>> = []; | |
| 268 | const result = yield* Effect.gen(function* () { | |
| 269 | const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; | |
| 270 | return yield* service.prepareClone({ | |
| 271 | remoteUrl: "https://user:s3cret@github.com/octocat/t3code.git", | |
| 272 | destinationPath, | |
| 273 | }); | |
| 274 | }).pipe( | |
| 275 | Effect.provide( | |
| 276 | makeLayer({ | |
| 277 | git: { | |
| 278 | execute: (input) => | |
| 279 | Effect.sync(() => { | |
| 280 | cloneArgs.push(input.args); | |
| 281 | return processOutput(); | |
| 282 | }), | |
| 283 | }, | |
| 284 | }), | |
| 285 | ), | |
| 286 | ); | |
| 287 | assert.equal(result.remoteUrl, "https://github.com/octocat/t3code.git"); | |
| 288 | // Git itself still receives the credentials. | |
| 289 | assert.equal(result.cloneUrl, "https://user:s3cret@github.com/octocat/t3code.git"); | |
| 290 | }).pipe(Effect.provide(NodeServices.layer)), | |
| 291 | ); | |
| 292 | ||
| 293 | it.effect("discards only a directory git wrote to", () => | |
| 294 | Effect.gen(function* () { | |
| 295 | const fs = yield* FileSystem.FileSystem; | |
| 296 | const path = yield* Path.Path; | |
| 297 | const parent = yield* fs.makeTempDirectoryScoped({ prefix: "t3-source-control-discard-" }); | |
| 298 | const partial = path.join(parent, "partial"); | |
| 299 | yield* fs.makeDirectory(path.join(partial, ".git"), { recursive: true }); | |
| 300 | yield* fs.writeFileString(path.join(partial, "README.md"), "half"); | |
| 301 | const foreign = path.join(parent, "foreign"); | |
| 302 | yield* fs.makeDirectory(foreign); | |
| 303 | yield* fs.writeFileString(path.join(foreign, "notes.txt"), "mine"); | |
| 304 | ||
| 305 | // A file where the directory should be must not be removed either. | |
| 306 | const replaced = path.join(parent, "replaced"); | |
| 307 | yield* fs.writeFileString(replaced, "not a directory"); | |
| 308 | ||
| 309 | yield* Effect.gen(function* () { | |
| 310 | const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; | |
| 311 | yield* service.discardClone(partial); | |
| 312 | const error = yield* Effect.flip(service.discardClone(foreign)); | |
| 313 | assert.include(error.detail, "not from the clone"); | |
| 314 | const replacedError = yield* Effect.flip(service.discardClone(replaced)); | |
| 315 | assert.include(replacedError.detail, "could not be inspected"); | |
| 316 | // A destination that never got created is nothing to discard. | |
| 317 | yield* service.discardClone(path.join(parent, "missing")); | |
| 318 | }).pipe(Effect.provide(makeLayer({}))); | |
| 319 | ||
| 320 | // The partial clone is emptied but its directory (the workspace root) stays. | |
| 321 | assert.deepStrictEqual(yield* fs.readDirectory(partial), []); | |
| 322 | assert.deepStrictEqual(yield* fs.readDirectory(foreign), ["notes.txt"]); | |
| 323 | assert.strictEqual(yield* fs.readFileString(replaced), "not a directory"); | |
| 324 | }).pipe(Effect.provide(NodeServices.layer)), | |
| 325 | ); | |
| 326 | ||
| 327 | it.effect("redacts query tokens and userinfo containing '@' from reported URLs", () => | |
| 328 | Effect.gen(function* () { | |
| 329 | const fs = yield* FileSystem.FileSystem; | |
| 330 | const path = yield* Path.Path; | |
| 331 | const parent = yield* fs.makeTempDirectoryScoped({ prefix: "t3-source-control-redact2-" }); | |
| 332 | yield* Effect.gen(function* () { | |
| 333 | const service = yield* SourceControlRepositoryService.SourceControlRepositoryService; | |
| 334 | const query = yield* service.prepareClone({ | |
| 335 | remoteUrl: "https://github.com/octocat/t3code.git?access_token=s3cret", | |
| 336 | destinationPath: path.join(parent, "a"), | |
| 337 | }); | |
| 338 | assert.equal(query.remoteUrl, "https://github.com/octocat/t3code.git"); | |
| 339 | const nested = yield* service.prepareClone({ | |
| 340 | remoteUrl: "https://user:pa@rt@github.com/octocat/t3code.git", | |
| 341 | destinationPath: path.join(parent, "b"), | |
| 342 | }); | |
| 343 | assert.equal(nested.remoteUrl, "https://github.com/octocat/t3code.git"); | |
| 344 | }).pipe(Effect.provide(makeLayer({}))); | |
| 345 | }).pipe(Effect.provide(NodeServices.layer)), | |
| 346 | ); | |
| 347 | ||
| 200 | 348 | it.effect("preserves destination probe failures instead of treating them as missing paths", () => { |
| 201 | 349 | const fileSystemCause = PlatformError.systemError({ |
| 202 | 350 | _tag: "PermissionDenied", |
apps/server/src/sourceControl/SourceControlRepositoryService.ts+193-13| ... | ... | @@ -3,6 +3,7 @@ import * as Effect from "effect/Effect"; |
| 3 | 3 | import * as FileSystem from "effect/FileSystem"; |
| 4 | 4 | import * as Layer from "effect/Layer"; |
| 5 | 5 | import * as Path from "effect/Path"; |
| 6 | import * as Schedule from "effect/Schedule"; | |
| 6 | 7 | import * as Schema from "effect/Schema"; |
| 7 | 8 | |
| 8 | 9 | import { |
| ... | ... | @@ -20,6 +21,10 @@ import { |
| 20 | 21 | |
| 21 | 22 | import { ServerConfig } from "../config.ts"; |
| 22 | 23 | import { expandHomePathWith } from "../pathExpansion.ts"; |
| 24 | import { | |
| 25 | parseGitCloneProgressLine, | |
| 26 | type GitCloneProgressLine, | |
| 27 | } from "../project/gitCloneProgress.ts"; | |
| 23 | 28 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; |
| 24 | 29 | import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; |
| 25 | 30 | const isSourceControlRepositoryError = Schema.is(SourceControlRepositoryError); |
| ... | ... | @@ -30,15 +35,56 @@ export class SourceControlRepositoryService extends Context.Service< |
| 30 | 35 | readonly lookupRepository: ( |
| 31 | 36 | input: SourceControlRepositoryLookupInput, |
| 32 | 37 | ) => Effect.Effect<SourceControlRepositoryInfo, SourceControlRepositoryError>; |
| 38 | /** | |
| 39 | * Everything `cloneRepository` checks before running git: the resolved | |
| 40 | * remote, the normalized destination, and that the destination is empty. | |
| 41 | * Lets a caller create the project first and clone afterwards. | |
| 42 | */ | |
| 43 | readonly prepareClone: ( | |
| 44 | input: SourceControlCloneRepositoryInput, | |
| 45 | ) => Effect.Effect<SourceControlPreparedClone, SourceControlRepositoryError>; | |
| 33 | 46 | readonly cloneRepository: ( |
| 34 | 47 | input: SourceControlCloneRepositoryInput, |
| 48 | options?: SourceControlCloneOptions, | |
| 35 | 49 | ) => Effect.Effect<SourceControlCloneRepositoryResult, SourceControlRepositoryError>; |
| 50 | /** Removes a partial or failed clone so the destination is empty again. */ | |
| 51 | readonly discardClone: ( | |
| 52 | destinationPath: string, | |
| 53 | ) => Effect.Effect<void, SourceControlRepositoryError>; | |
| 36 | 54 | readonly publishRepository: ( |
| 37 | 55 | input: SourceControlPublishRepositoryInput, |
| 38 | 56 | ) => Effect.Effect<SourceControlPublishRepositoryResult, SourceControlRepositoryError>; |
| 39 | 57 | } |
| 40 | 58 | >()("t3/sourceControl/SourceControlRepositoryService") {} |
| 41 | 59 | |
| 60 | export interface SourceControlPreparedClone { | |
| 61 | readonly destinationPath: string; | |
| 62 | /** Credential-free; safe to show and to store in snapshots. */ | |
| 63 | readonly remoteUrl: string; | |
| 64 | /** What git is given; may carry embedded credentials. */ | |
| 65 | readonly cloneUrl: string; | |
| 66 | readonly repository: SourceControlRepositoryInfo | null; | |
| 67 | } | |
| 68 | ||
| 69 | export interface SourceControlCloneOptions { | |
| 70 | readonly onProgress?: (line: GitCloneProgressLine) => Effect.Effect<void>; | |
| 71 | /** Overrides the default clone budget; `null` disables the deadline. */ | |
| 72 | readonly timeoutMs?: number | null; | |
| 73 | } | |
| 74 | ||
| 75 | // The synchronous RPC (older clients, mobile) keeps a deadline: nothing else | |
| 76 | // tells the user a clone stalled. The tracked path passes null and relies on | |
| 77 | // progress and Cancel instead. | |
| 78 | const CLONE_TIMEOUT_MS = 120_000; | |
| 79 | const CLONE_ENV = { | |
| 80 | // `--progress` forces the transfer counters through the pipe; the delay env | |
| 81 | // makes the checkout counter start immediately. No tty means a credential | |
| 82 | // prompt would hang forever, so tell git to fail instead. | |
| 83 | GIT_PROGRESS_DELAY: "0", | |
| 84 | GIT_TERMINAL_PROMPT: "0", | |
| 85 | LC_ALL: "C", | |
| 86 | } satisfies NodeJS.ProcessEnv; | |
| 87 | ||
| 42 | 88 | function mapRepositoryError(operation: string, provider: SourceControlProviderKind) { |
| 43 | 89 | return Effect.mapError((cause: unknown) => |
| 44 | 90 | isSourceControlRepositoryError(cause) |
| ... | ... | @@ -64,6 +110,36 @@ function toRepositoryInfo( |
| 64 | 110 | }; |
| 65 | 111 | } |
| 66 | 112 | |
| 113 | /** | |
| 114 | * The URL clients see. A pasted `https://user:token@host/…` must not travel | |
| 115 | * back over `subscribeProjectClones` to every reader; git still gets the | |
| 116 | * original. | |
| 117 | */ | |
| 118 | function redactRemoteUrl(remoteUrl: string): string { | |
| 119 | try { | |
| 120 | const url = new URL(remoteUrl); | |
| 121 | // Clone URLs have no legitimate query; when one is present it is a token. | |
| 122 | if (url.username.length === 0 && url.password.length === 0 && url.search.length === 0) { | |
| 123 | return remoteUrl; | |
| 124 | } | |
| 125 | url.username = ""; | |
| 126 | url.password = ""; | |
| 127 | url.search = ""; | |
| 128 | return url.toString(); | |
| 129 | } catch { | |
| 130 | return remoteUrl; | |
| 131 | } | |
| 132 | } | |
| 133 | ||
| 134 | // Userinfo may itself contain `@`; everything up to the last one before the | |
| 135 | // host boundary goes. | |
| 136 | const URL_WITH_USERINFO = /\b([a-z][a-z0-9+.-]*:\/\/)[^\s/]+@/gi; | |
| 137 | ||
| 138 | /** Drops `user:token@` from any URL embedded in free text. */ | |
| 139 | function redactUrlCredentials(text: string): string { | |
| 140 | return text.replace(URL_WITH_USERINFO, "$1"); | |
| 141 | } | |
| 142 | ||
| 67 | 143 | function selectRemoteUrl( |
| 68 | 144 | urls: SourceControlRepositoryCloneUrls, |
| 69 | 145 | protocol: SourceControlCloneProtocol | undefined, |
| ... | ... | @@ -168,7 +244,7 @@ export const make = Effect.gen(function* () { |
| 168 | 244 | }, |
| 169 | 245 | ); |
| 170 | 246 | |
| 171 | const cloneRepository = Effect.fn("SourceControlRepositoryService.cloneRepository")(function* ( | |
| 247 | const prepareClone = Effect.fn("SourceControlRepositoryService.prepareClone")(function* ( | |
| 172 | 248 | input: SourceControlCloneRepositoryInput, |
| 173 | 249 | ) { |
| 174 | 250 | const preparedDestination = yield* prepareDestination(input.destinationPath); |
| ... | ... | @@ -194,21 +270,121 @@ export const make = Effect.gen(function* () { |
| 194 | 270 | }); |
| 195 | 271 | } |
| 196 | 272 | |
| 197 | yield* git.execute({ | |
| 198 | operation: "SourceControlRepositoryService.cloneRepository", | |
| 199 | cwd: preparedDestination.parentPath, | |
| 200 | args: ["clone", remoteUrl, preparedDestination.directoryName], | |
| 201 | timeoutMs: 120_000, | |
| 202 | maxOutputBytes: 256 * 1024, | |
| 203 | }); | |
| 204 | ||
| 205 | 273 | return { |
| 206 | cwd: preparedDestination.destinationPath, | |
| 207 | remoteUrl, | |
| 274 | destinationPath: preparedDestination.destinationPath, | |
| 275 | remoteUrl: redactRemoteUrl(remoteUrl), | |
| 276 | cloneUrl: remoteUrl, | |
| 208 | 277 | repository, |
| 278 | } satisfies SourceControlPreparedClone; | |
| 279 | }); | |
| 280 | ||
| 281 | const cloneRepository = Effect.fn("SourceControlRepositoryService.cloneRepository")(function* ( | |
| 282 | input: SourceControlCloneRepositoryInput, | |
| 283 | options?: SourceControlCloneOptions, | |
| 284 | ) { | |
| 285 | const prepared = yield* prepareClone(input); | |
| 286 | const onProgress = options?.onProgress; | |
| 287 | // Git interleaves progress redraws with its real messages on stderr. The | |
| 288 | // last non-progress lines are what explain a failure ("Repository not | |
| 289 | // found", "Permission denied"), so keep them for the error detail. | |
| 290 | const stderrTail: Array<string> = []; | |
| 291 | const onStderrLine = (line: string) => { | |
| 292 | const parsed = parseGitCloneProgressLine(line); | |
| 293 | if (parsed) return onProgress ? onProgress(parsed) : Effect.void; | |
| 294 | return Effect.sync(() => { | |
| 295 | const trimmed = line.trim(); | |
| 296 | if (trimmed.length === 0 || trimmed.startsWith("Cloning into")) return; | |
| 297 | // Git echoes the remote in some failures; the tail becomes user-facing text. | |
| 298 | stderrTail.push(redactUrlCredentials(trimmed)); | |
| 299 | if (stderrTail.length > 4) stderrTail.shift(); | |
| 300 | }); | |
| 301 | }; | |
| 302 | yield* git | |
| 303 | .execute({ | |
| 304 | operation: "SourceControlRepositoryService.cloneRepository", | |
| 305 | cwd: path.dirname(prepared.destinationPath), | |
| 306 | args: ["clone", "--progress", prepared.cloneUrl, path.basename(prepared.destinationPath)], | |
| 307 | timeoutMs: options?.timeoutMs === undefined ? CLONE_TIMEOUT_MS : options.timeoutMs, | |
| 308 | // Progress redraws add up on a slow multi-GB clone. The buffered copy | |
| 309 | // is never read (the tail is kept by hand above), so keep it small | |
| 310 | // and let the line callbacks keep flowing past the cap. | |
| 311 | maxOutputBytes: 256 * 1024, | |
| 312 | appendTruncationMarker: true, | |
| 313 | keepLineCallbacksAfterTruncation: true, | |
| 314 | env: CLONE_ENV, | |
| 315 | progress: { onStderrLine }, | |
| 316 | }) | |
| 317 | .pipe( | |
| 318 | Effect.mapError( | |
| 319 | (cause) => | |
| 320 | new SourceControlRepositoryError({ | |
| 321 | operation: "cloneRepository", | |
| 322 | provider: input.provider ?? "unknown", | |
| 323 | detail: | |
| 324 | stderrTail.length > 0 | |
| 325 | ? stderrTail.join(" ") | |
| 326 | : "The repository could not be cloned.", | |
| 327 | cause, | |
| 328 | }), | |
| 329 | ), | |
| 330 | ); | |
| 331 | ||
| 332 | return { | |
| 333 | cwd: prepared.destinationPath, | |
| 334 | remoteUrl: prepared.remoteUrl, | |
| 335 | repository: prepared.repository, | |
| 209 | 336 | }; |
| 210 | 337 | }); |
| 211 | 338 | |
| 339 | const discardClone = Effect.fn("SourceControlRepositoryService.discardClone")(function* ( | |
| 340 | destinationPath: string, | |
| 341 | ) { | |
| 342 | const normalized = yield* normalizeDestinationPath(destinationPath); | |
| 343 | // Only what git left behind may go. The destination was empty when the | |
| 344 | // clone started, so anything without a `.git` inside was put there by | |
| 345 | // someone else since; refuse rather than delete their files. | |
| 346 | // A missing destination is already discarded; any other read failure | |
| 347 | // (a file in its place, permissions) is not something to remove through. | |
| 348 | const entries = yield* fileSystem.readDirectory(normalized).pipe( | |
| 349 | Effect.catchIf( | |
| 350 | (cause) => cause.reason._tag === "NotFound", | |
| 351 | () => Effect.succeed<ReadonlyArray<string>>([]), | |
| 352 | ), | |
| 353 | Effect.mapError( | |
| 354 | (cause) => | |
| 355 | new SourceControlRepositoryError({ | |
| 356 | operation: "discardClone", | |
| 357 | provider: "unknown", | |
| 358 | detail: "The clone destination could not be inspected.", | |
| 359 | cause, | |
| 360 | }), | |
| 361 | ), | |
| 362 | ); | |
| 363 | if (entries.length > 0 && !entries.includes(".git")) { | |
| 364 | return yield* new SourceControlRepositoryError({ | |
| 365 | operation: "discardClone", | |
| 366 | provider: "unknown", | |
| 367 | detail: "Destination path contains files that are not from the clone.", | |
| 368 | }); | |
| 369 | } | |
| 370 | // The directory itself is the project's workspace root and must stay; | |
| 371 | // only git's partial contents go. An interrupted git may still be closing | |
| 372 | // files, so removal retries briefly. | |
| 373 | yield* fileSystem.remove(normalized, { recursive: true, force: true }).pipe( | |
| 374 | Effect.andThen(fileSystem.makeDirectory(normalized, { recursive: true })), | |
| 375 | Effect.retry({ schedule: Schedule.spaced("200 millis"), times: 5 }), | |
| 376 | Effect.mapError( | |
| 377 | (cause) => | |
| 378 | new SourceControlRepositoryError({ | |
| 379 | operation: "discardClone", | |
| 380 | provider: "unknown", | |
| 381 | detail: "The partial clone could not be removed.", | |
| 382 | cause, | |
| 383 | }), | |
| 384 | ), | |
| 385 | ); | |
| 386 | }); | |
| 387 | ||
| 212 | 388 | const publishRepository = Effect.fn("SourceControlRepositoryService.publishRepository")( |
| 213 | 389 | function* (input: SourceControlPublishRepositoryInput) { |
| 214 | 390 | const providerKind = yield* ensureConcreteProvider({ |
| ... | ... | @@ -269,10 +445,14 @@ export const make = Effect.gen(function* () { |
| 269 | 445 | return SourceControlRepositoryService.of({ |
| 270 | 446 | lookupRepository: (input) => |
| 271 | 447 | lookupRepository(input).pipe(mapRepositoryError("lookupRepository", input.provider)), |
| 272 | cloneRepository: (input) => | |
| 273 | cloneRepository(input).pipe( | |
| 448 | prepareClone: (input) => | |
| 449 | prepareClone(input).pipe(mapRepositoryError("cloneRepository", input.provider ?? "unknown")), | |
| 450 | cloneRepository: (input, options) => | |
| 451 | cloneRepository(input, options).pipe( | |
| 274 | 452 | mapRepositoryError("cloneRepository", input.provider ?? "unknown"), |
| 275 | 453 | ), |
| 454 | discardClone: (destinationPath) => | |
| 455 | discardClone(destinationPath).pipe(mapRepositoryError("discardClone", "unknown")), | |
| 276 | 456 | publishRepository: (input) => |
| 277 | 457 | publishRepository(input).pipe(mapRepositoryError("publishRepository", input.provider)), |
| 278 | 458 | }); |
apps/server/src/vcs/GitVcsDriver.ts+6| ... | ... | @@ -48,6 +48,12 @@ export interface ExecuteGitInput { |
| 48 | 48 | readonly timeoutMs?: number | null; |
| 49 | 49 | readonly maxOutputBytes?: number; |
| 50 | 50 | readonly appendTruncationMarker?: boolean; |
| 51 | /** | |
| 52 | * With `appendTruncationMarker`, keep invoking the line callbacks after the | |
| 53 | * buffered copy is full. For long-running commands whose output is only | |
| 54 | * consumed through `progress`. | |
| 55 | */ | |
| 56 | readonly keepLineCallbacksAfterTruncation?: boolean; | |
| 51 | 57 | readonly progress?: ExecuteGitProgress; |
| 52 | 58 | } |
| 53 | 59 |
apps/server/src/vcs/GitVcsDriverCore.test.ts+32| ... | ... | @@ -770,6 +770,38 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { |
| 770 | 770 | }), |
| 771 | 771 | ); |
| 772 | 772 | |
| 773 | it.effect("keeps line callbacks flowing past the output cap when asked", () => | |
| 774 | Effect.gen(function* () { | |
| 775 | const cwd = yield* makeTmpDir(); | |
| 776 | const driver = yield* GitVcsDriver.GitVcsDriver; | |
| 777 | // 4 KiB of multi-byte lines, well past a 512-byte cap; the last line | |
| 778 | // is the one a failure surface would need. | |
| 779 | const lines: Array<string> = []; | |
| 780 | const result = yield* driver.execute({ | |
| 781 | operation: "GitVcsDriver.test.callbacksPastCap", | |
| 782 | cwd, | |
| 783 | args: [ | |
| 784 | "-c", | |
| 785 | 'alias.spew=!for i in $(seq 1 128); do printf "é%03d\\n" $i >&2; done; echo fatal: last line >&2', | |
| 786 | "spew", | |
| 787 | ], | |
| 788 | maxOutputBytes: 512, | |
| 789 | appendTruncationMarker: true, | |
| 790 | keepLineCallbacksAfterTruncation: true, | |
| 791 | progress: { onStderrLine: (line) => Effect.sync(() => void lines.push(line)) }, | |
| 792 | }); | |
| 793 | ||
| 794 | assert.isTrue(result.stderrTruncated); | |
| 795 | assert.isAtMost(result.stderr.length, 600); | |
| 796 | assert.equal(lines.length, 129); | |
| 797 | assert.equal(lines[0], "é001"); | |
| 798 | assert.equal(lines[127], "é128"); | |
| 799 | assert.equal(lines.at(-1), "fatal: last line"); | |
| 800 | // No replacement characters: the cap landing inside "é" is invisible to callbacks. | |
| 801 | assert.isFalse(lines.some((line) => line.includes("\uFFFD"))); | |
| 802 | }), | |
| 803 | ); | |
| 804 | ||
| 773 | 805 | it.effect("recovers a structurally identified missing cwd as a non-repository", () => |
| 774 | 806 | Effect.gen(function* () { |
| 775 | 807 | const parent = yield* makeTmpDir(); |
apps/server/src/vcs/GitVcsDriverCore.ts+16-1| ... | ... | @@ -666,12 +666,19 @@ const collectOutput = Effect.fnUntraced(function* ( |
| 666 | 666 | maxOutputBytes: number, |
| 667 | 667 | appendTruncationMarker: boolean, |
| 668 | 668 | onLine: ((line: string) => Effect.Effect<void, never>) | undefined, |
| 669 | keepLineCallbacksAfterTruncation = false, | |
| 669 | 670 | ): Effect.fn.Return<{ readonly text: string; readonly truncated: boolean }, GitCommandError> { |
| 670 | 671 | const decoder = new TextDecoder(); |
| 672 | // With callbacks continuing past the cap, lines are decoded by their own | |
| 673 | // decoder from the first byte so no character is ever split at the cap. | |
| 674 | const lineDecoder = keepLineCallbacksAfterTruncation && onLine ? new TextDecoder() : null; | |
| 671 | 675 | let bytes = 0; |
| 672 | 676 | let text = ""; |
| 673 | 677 | let lineBuffer = ""; |
| 674 | 678 | let truncated = false; |
| 679 | // A separator-free stream past the cap must not grow the line buffer | |
| 680 | // without bound; a line longer than this is not one the callbacks want. | |
| 681 | const maxPendingLineBytes = 64 * 1024; | |
| 675 | 682 | |
| 676 | 683 | // Git redraws progress with a bare `\r` between updates and only ends the |
| 677 | 684 | // line once the step is done, so `\r` has to count as a line break here. |
| ... | ... | @@ -697,6 +704,11 @@ const collectOutput = Effect.fnUntraced(function* ( |
| 697 | 704 | |
| 698 | 705 | const processChunk = Effect.fnUntraced(function* (chunk: Uint8Array) { |
| 699 | 706 | if (appendTruncationMarker && truncated) { |
| 707 | if (lineDecoder) { | |
| 708 | lineBuffer += lineDecoder.decode(chunk, { stream: true }); | |
| 709 | yield* emitCompleteLines(false); | |
| 710 | if (lineBuffer.length > maxPendingLineBytes) lineBuffer = ""; | |
| 711 | } | |
| 700 | 712 | return; |
| 701 | 713 | } |
| 702 | 714 | const nextBytes = bytes + chunk.byteLength; |
| ... | ... | @@ -717,7 +729,7 @@ const collectOutput = Effect.fnUntraced(function* ( |
| 717 | 729 | |
| 718 | 730 | const decoded = decoder.decode(chunkToDecode, { stream: !truncated }); |
| 719 | 731 | text += decoded; |
| 720 | lineBuffer += decoded; | |
| 732 | lineBuffer += lineDecoder ? lineDecoder.decode(chunk, { stream: true }) : decoded; | |
| 721 | 733 | yield* emitCompleteLines(false); |
| 722 | 734 | }); |
| 723 | 735 | |
| ... | ... | @@ -735,6 +747,7 @@ const collectOutput = Effect.fnUntraced(function* ( |
| 735 | 747 | const remainder = truncated ? "" : decoder.decode(); |
| 736 | 748 | text += remainder; |
| 737 | 749 | lineBuffer += remainder; |
| 750 | if (lineDecoder) lineBuffer += lineDecoder.decode(); | |
| 738 | 751 | yield* emitCompleteLines(true); |
| 739 | 752 | return { |
| 740 | 753 | text, |
| ... | ... | @@ -802,6 +815,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 802 | 815 | maxOutputBytes, |
| 803 | 816 | appendTruncationMarker, |
| 804 | 817 | input.progress?.onStdoutLine, |
| 818 | input.keepLineCallbacksAfterTruncation, | |
| 805 | 819 | ), |
| 806 | 820 | collectOutput( |
| 807 | 821 | commandInput, |
| ... | ... | @@ -809,6 +823,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* |
| 809 | 823 | maxOutputBytes, |
| 810 | 824 | appendTruncationMarker, |
| 811 | 825 | input.progress?.onStderrLine, |
| 826 | input.keepLineCallbacksAfterTruncation, | |
| 812 | 827 | ), |
| 813 | 828 | child.exitCode.pipe( |
| 814 | 829 | Effect.mapError( |
apps/server/src/ws.ts+78| ... | ... | @@ -8,6 +8,7 @@ import * as DateTime from "effect/DateTime"; |
| 8 | 8 | import * as Duration from "effect/Duration"; |
| 9 | 9 | import * as Effect from "effect/Effect"; |
| 10 | 10 | import * as Fiber from "effect/Fiber"; |
| 11 | import * as FileSystem from "effect/FileSystem"; | |
| 11 | 12 | import * as Layer from "effect/Layer"; |
| 12 | 13 | import * as Option from "effect/Option"; |
| 13 | 14 | import * as Path from "effect/Path"; |
| ... | ... | @@ -133,6 +134,8 @@ import * as GitWorkflowService from "./git/GitWorkflowService.ts"; |
| 133 | 134 | import { linkCreatedPullRequest } from "./git/linkCreatedPullRequest.ts"; |
| 134 | 135 | import * as ReviewService from "./review/ReviewService.ts"; |
| 135 | 136 | import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; |
| 137 | import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts"; | |
| 138 | import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; | |
| 136 | 139 | import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts"; |
| 137 | 140 | import * as AgentSessionScanner from "./project/AgentSessionScanner.ts"; |
| 138 | 141 | import { importRecentAgentThreads } from "./project/AgentSessionImporter.ts"; |
| ... | ... | @@ -605,6 +608,17 @@ const makeWsRpcLayer = ( |
| 605 | 608 | }); |
| 606 | 609 | const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; |
| 607 | 610 | const worktreeSetupTracker = yield* WorktreeSetupTracker.WorktreeSetupTracker; |
| 611 | const projectCloneTracker = yield* ProjectCloneTracker.ProjectCloneTracker; | |
| 612 | const repositoryIdentityResolver = | |
| 613 | yield* RepositoryIdentityResolver.RepositoryIdentityResolver; | |
| 614 | // Clone hooks run on the tracker's fiber, outside any RPC, so the | |
| 615 | // normalizer's services are captured here rather than inherited. | |
| 616 | const normalizerContext = yield* Effect.context< | |
| 617 | | FileSystem.FileSystem | |
| 618 | | Path.Path | |
| 619 | | ServerConfig.ServerConfig | |
| 620 | | WorkspacePaths.WorkspacePaths | |
| 621 | >(); | |
| 608 | 622 | const agentSessionScanner = yield* AgentSessionScanner.AgentSessionScanner; |
| 609 | 623 | const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; |
| 610 | 624 | const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; |
| ... | ... | @@ -1615,6 +1629,7 @@ const makeWsRpcLayer = ( |
| 1615 | 1629 | observeRpcEffect( |
| 1616 | 1630 | ORCHESTRATION_WS_METHODS.dispatchCommand, |
| 1617 | 1631 | Effect.gen(function* () { |
| 1632 | yield* ProjectCloneTracker.rejectCommandsDuringClone(projectCloneTracker, command); | |
| 1618 | 1633 | const normalizedCommand = yield* normalizeDispatchCommand(command); |
| 1619 | 1634 | // Archive removes the thread from the client, so this transport |
| 1620 | 1635 | // closes its session and terminals after the command lands. |
| ... | ... | @@ -1646,6 +1661,10 @@ const makeWsRpcLayer = ( |
| 1646 | 1661 | Effect.tapError(() => cleanupFailedUploadedAttachments(command, normalizedCommand)), |
| 1647 | 1662 | ); |
| 1648 | 1663 | yield* recordClientCommandAnalytics(normalizedCommand); |
| 1664 | yield* ProjectCloneTracker.discardCloneForDeletedProject( | |
| 1665 | projectCloneTracker, | |
| 1666 | normalizedCommand, | |
| 1667 | ); | |
| 1649 | 1668 | if (archiveCommand) { |
| 1650 | 1669 | if (shouldStopSessionAfterCommand) { |
| 1651 | 1670 | yield* Effect.gen(function* () { |
| ... | ... | @@ -2657,6 +2676,65 @@ const makeWsRpcLayer = ( |
| 2657 | 2676 | "rpc.aggregate": "source-control", |
| 2658 | 2677 | }, |
| 2659 | 2678 | ), |
| 2679 | [WS_METHODS.projectCloneStart]: (input) => | |
| 2680 | observeRpcEffect( | |
| 2681 | WS_METHODS.projectCloneStart, | |
| 2682 | projectCloneTracker.start(input, { | |
| 2683 | createProject: (project) => | |
| 2684 | Effect.gen(function* () { | |
| 2685 | const normalizedCommand = yield* normalizeDispatchCommand({ | |
| 2686 | type: "project.create", | |
| 2687 | commandId: yield* serverCommandId("project-clone-create"), | |
| 2688 | projectId: project.projectId, | |
| 2689 | title: project.title, | |
| 2690 | workspaceRoot: project.workspaceRoot, | |
| 2691 | createWorkspaceRootIfMissing: true, | |
| 2692 | createdAt: project.createdAt, | |
| 2693 | }); | |
| 2694 | yield* dispatchNormalizedCommand(normalizedCommand); | |
| 2695 | yield* recordClientCommandAnalytics(normalizedCommand); | |
| 2696 | }).pipe(Effect.provideContext(normalizerContext)), | |
| 2697 | onCloned: (project) => | |
| 2698 | // The project was created against an empty directory, so its | |
| 2699 | // cached identity is "not a repository" until this refresh. | |
| 2700 | // Re-emitting the project shell carries the new identity to | |
| 2701 | // every client without a round trip. | |
| 2702 | repositoryIdentityResolver.resolve(project.workspaceRoot, { refresh: true }).pipe( | |
| 2703 | Effect.andThen( | |
| 2704 | Effect.gen(function* () { | |
| 2705 | const command = yield* normalizeDispatchCommand({ | |
| 2706 | type: "project.meta.update", | |
| 2707 | commandId: yield* serverCommandId("project-clone-done"), | |
| 2708 | projectId: project.projectId, | |
| 2709 | }); | |
| 2710 | yield* dispatchNormalizedCommand(command); | |
| 2711 | }), | |
| 2712 | ), | |
| 2713 | Effect.andThen(refreshGitStatus(project.workspaceRoot)), | |
| 2714 | Effect.ignoreCause({ log: true }), | |
| 2715 | Effect.provideContext(normalizerContext), | |
| 2716 | ), | |
| 2717 | }), | |
| 2718 | { "rpc.aggregate": "source-control" }, | |
| 2719 | ), | |
| 2720 | [WS_METHODS.projectCloneCancel]: (input) => | |
| 2721 | observeRpcEffect( | |
| 2722 | WS_METHODS.projectCloneCancel, | |
| 2723 | projectCloneTracker | |
| 2724 | .cancel(input.projectId) | |
| 2725 | .pipe(Effect.map((applied) => ({ applied }))), | |
| 2726 | { "rpc.aggregate": "source-control" }, | |
| 2727 | ), | |
| 2728 | [WS_METHODS.projectCloneRetry]: (input) => | |
| 2729 | observeRpcEffect( | |
| 2730 | WS_METHODS.projectCloneRetry, | |
| 2731 | projectCloneTracker.retry(input.projectId).pipe(Effect.map((applied) => ({ applied }))), | |
| 2732 | { "rpc.aggregate": "source-control" }, | |
| 2733 | ), | |
| 2734 | [WS_METHODS.subscribeProjectClones]: () => | |
| 2735 | observeRpcStream(WS_METHODS.subscribeProjectClones, projectCloneTracker.stream, { | |
| 2736 | "rpc.aggregate": "source-control", | |
| 2737 | }), | |
| 2660 | 2738 | [WS_METHODS.sourceControlPublishRepository]: (input) => |
| 2661 | 2739 | observeRpcEffect( |
| 2662 | 2740 | WS_METHODS.sourceControlPublishRepository, |
apps/web/src/components/ChatView.tsx+117-1| ... | ... | @@ -226,6 +226,7 @@ import { |
| 226 | 226 | AlarmClockIcon, |
| 227 | 227 | CheckCircle2Icon, |
| 228 | 228 | ChevronDownIcon, |
| 229 | DownloadIcon, | |
| 229 | 230 | GitBranchIcon, |
| 230 | 231 | Minimize2Icon, |
| 231 | 232 | PaperclipIcon, |
| ... | ... | @@ -259,6 +260,7 @@ import { |
| 259 | 260 | import { useNowMinute } from "../hooks/useNowMinute"; |
| 260 | 261 | import { usePanelAnimationSettings, usePanelPresence } from "../panelAnimations"; |
| 261 | 262 | import { useNewThreadHandler } from "../hooks/useHandleNewThread"; |
| 263 | import { useRemoveClonedProject } from "../hooks/useRemoveClonedProject"; | |
| 262 | 264 | import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl"; |
| 263 | 265 | import { useThreadActions } from "../hooks/useThreadActions"; |
| 264 | 266 | import { resolveAppModelSelectionForInstance } from "../modelSelection"; |
| ... | ... | @@ -328,6 +330,9 @@ import { |
| 328 | 330 | } from "@t3tools/client-runtime/state/threads"; |
| 329 | 331 | import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills"; |
| 330 | 332 | import { vcsEnvironment } from "../state/vcs"; |
| 333 | import { sourceControlEnvironment } from "../state/sourceControl"; | |
| 334 | import { useProjectClone } from "../state/projectClones"; | |
| 335 | import { projectCloneDisplayName, projectCloneProgressSummary } from "@t3tools/contracts"; | |
| 331 | 336 | import { useEnvironments, usePrimaryEnvironment } from "../state/environments"; |
| 332 | 337 | import { |
| 333 | 338 | useProject, |
| ... | ... | @@ -2094,6 +2099,113 @@ export default function ChatView(props: ChatViewProps) { |
| 2094 | 2099 | () => (activeProject ? resolveProjectScripts(settings, activeProject) : []), |
| 2095 | 2100 | [activeProject, settings], |
| 2096 | 2101 | ); |
| 2102 | // A project added by cloning exists before its files do. The draft stays | |
| 2103 | // editable throughout; only sending waits for the clone, and a failed | |
| 2104 | // clone offers its retry right where the user is looking. | |
| 2105 | const activeProjectClone = useProjectClone(activeProjectRef); | |
| 2106 | const cancelProjectClone = useAtomCommand(sourceControlEnvironment.cancelProjectClone, { | |
| 2107 | reportFailure: false, | |
| 2108 | }); | |
| 2109 | const retryProjectClone = useAtomCommand(sourceControlEnvironment.retryProjectClone, { | |
| 2110 | reportFailure: false, | |
| 2111 | }); | |
| 2112 | const removeClonedProject = useRemoveClonedProject(); | |
| 2113 | // The banner mirrors the server's clone state, so a request that never got | |
| 2114 | // there needs its own feedback. | |
| 2115 | const runProjectCloneAction = useCallback( | |
| 2116 | async ( | |
| 2117 | title: string, | |
| 2118 | action: () => Promise<AtomCommandResult<unknown, unknown>>, | |
| 2119 | ): Promise<void> => { | |
| 2120 | const result = await action(); | |
| 2121 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { | |
| 2122 | const error = squashAtomCommandFailure(result); | |
| 2123 | toastManager.add( | |
| 2124 | stackedThreadToast({ | |
| 2125 | type: "error", | |
| 2126 | title, | |
| 2127 | description: error instanceof Error ? error.message : "An error occurred.", | |
| 2128 | }), | |
| 2129 | ); | |
| 2130 | } | |
| 2131 | }, | |
| 2132 | [], | |
| 2133 | ); | |
| 2134 | const projectCloneSendBlockReason = | |
| 2135 | activeProjectClone === null | |
| 2136 | ? null | |
| 2137 | : activeProjectClone.phase === "running" | |
| 2138 | ? "Cloning repository" | |
| 2139 | : activeProjectClone.phase === "done" | |
| 2140 | ? null | |
| 2141 | : "Repository not cloned"; | |
| 2142 | const projectCloneBannerItem = useMemo<ComposerBannerStackItem | null>(() => { | |
| 2143 | if (!activeProjectClone || !activeProjectRef || activeProjectClone.phase === "done") { | |
| 2144 | return null; | |
| 2145 | } | |
| 2146 | const name = projectCloneDisplayName(activeProjectClone); | |
| 2147 | const { environmentId, projectId } = activeProjectRef; | |
| 2148 | if (activeProjectClone.phase === "running") { | |
| 2149 | return { | |
| 2150 | id: `project-clone:${projectId}`, | |
| 2151 | variant: "info", | |
| 2152 | priority: "activity", | |
| 2153 | icon: <DownloadIcon />, | |
| 2154 | title: `Cloning ${name}`, | |
| 2155 | description: projectCloneProgressSummary(activeProjectClone), | |
| 2156 | actions: ( | |
| 2157 | <Button | |
| 2158 | size="xs" | |
| 2159 | variant="ghost" | |
| 2160 | onClick={() => | |
| 2161 | void runProjectCloneAction("Failed to cancel clone", () => | |
| 2162 | cancelProjectClone({ environmentId, input: { projectId } }), | |
| 2163 | ) | |
| 2164 | } | |
| 2165 | > | |
| 2166 | Cancel | |
| 2167 | </Button> | |
| 2168 | ), | |
| 2169 | }; | |
| 2170 | } | |
| 2171 | const cancelled = activeProjectClone.phase === "cancelled"; | |
| 2172 | return { | |
| 2173 | id: `project-clone:${projectId}`, | |
| 2174 | variant: cancelled ? "warning" : "error", | |
| 2175 | icon: <DownloadIcon />, | |
| 2176 | title: cancelled ? `Cancelled cloning ${name}` : `Failed to clone ${name}`, | |
| 2177 | description: cancelled ? "Retry to bring in the repository." : activeProjectClone.error, | |
| 2178 | actions: ( | |
| 2179 | <> | |
| 2180 | <Button | |
| 2181 | size="xs" | |
| 2182 | variant="ghost" | |
| 2183 | onClick={() => void removeClonedProject({ environmentId, projectId })} | |
| 2184 | > | |
| 2185 | Remove project | |
| 2186 | </Button> | |
| 2187 | <Button | |
| 2188 | size="xs" | |
| 2189 | variant="ghost" | |
| 2190 | onClick={() => | |
| 2191 | void runProjectCloneAction("Failed to retry clone", () => | |
| 2192 | retryProjectClone({ environmentId, input: { projectId } }), | |
| 2193 | ) | |
| 2194 | } | |
| 2195 | > | |
| 2196 | Retry | |
| 2197 | </Button> | |
| 2198 | </> | |
| 2199 | ), | |
| 2200 | }; | |
| 2201 | }, [ | |
| 2202 | activeProjectClone, | |
| 2203 | activeProjectRef, | |
| 2204 | cancelProjectClone, | |
| 2205 | removeClonedProject, | |
| 2206 | retryProjectClone, | |
| 2207 | runProjectCloneAction, | |
| 2208 | ]); | |
| 2097 | 2209 | const activeProjectDefaultModelSelection = activeProjectSettings.settings.defaultModelSelection; |
| 2098 | 2210 | const handleNewThreadInActiveProject = useCallback(() => { |
| 2099 | 2211 | startNewThreadForProject(activeProjectRef, handleNewThread); |
| ... | ... | @@ -6248,10 +6360,12 @@ export default function ChatView(props: ChatViewProps) { |
| 6248 | 6360 | const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem]; |
| 6249 | 6361 | // The user asked for this one, so it leads the notice tier instead of trailing it. |
| 6250 | 6362 | const usageLimitsItems = usageLimitsBanner === null ? [] : [usageLimitsBanner]; |
| 6363 | const projectCloneItems = projectCloneBannerItem === null ? [] : [projectCloneBannerItem]; | |
| 6251 | 6364 | if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { |
| 6252 | 6365 | return [ |
| 6253 | 6366 | ...feedbackBannerItems, |
| 6254 | 6367 | ...usageLimitsItems, |
| 6368 | ...projectCloneItems, | |
| 6255 | 6369 | ...systemComposerBannerItems, |
| 6256 | 6370 | ...backgroundLivenessItems, |
| 6257 | 6371 | ...resumeCompactionItems, |
| ... | ... | @@ -6262,6 +6376,7 @@ export default function ChatView(props: ChatViewProps) { |
| 6262 | 6376 | return [ |
| 6263 | 6377 | ...feedbackBannerItems, |
| 6264 | 6378 | ...usageLimitsItems, |
| 6379 | ...projectCloneItems, | |
| 6265 | 6380 | ...systemComposerBannerItems, |
| 6266 | 6381 | ...backgroundLivenessItems, |
| 6267 | 6382 | ...resumeCompactionItems, |
| ... | ... | @@ -6314,6 +6429,7 @@ export default function ChatView(props: ChatViewProps) { |
| 6314 | 6429 | isRestoringThreadBranch, |
| 6315 | 6430 | localCheckoutBranchMismatch, |
| 6316 | 6431 | parkedThreadBannerItem, |
| 6432 | projectCloneBannerItem, | |
| 6317 | 6433 | resumeCompactionBannerItem, |
| 6318 | 6434 | showBranchMismatchBanner, |
| 6319 | 6435 | systemComposerBannerItems, |
| ... | ... | @@ -9162,7 +9278,7 @@ export default function ChatView(props: ChatViewProps) { |
| 9162 | 9278 | ? "Sending feedback" |
| 9163 | 9279 | : threadDetailLoading |
| 9164 | 9280 | ? "Messages loading" |
| 9165 | : null | |
| 9281 | : projectCloneSendBlockReason | |
| 9166 | 9282 | } |
| 9167 | 9283 | isPreparingWorktree={isPreparingWorktree} |
| 9168 | 9284 | bannerItems={composerBannerItems} |
apps/web/src/components/CommandPalette.tsx+61-6| ... | ... | @@ -84,7 +84,7 @@ import { sourceControlEnvironment } from "../state/sourceControl"; |
| 84 | 84 | import { useAtomCommand } from "../state/use-atom-command"; |
| 85 | 85 | import { useAtomQueryRunner } from "../state/use-atom-query-runner"; |
| 86 | 86 | import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; |
| 87 | import { useProjects, useServerConfigs, useThreadShells } from "../state/entities"; | |
| 87 | import { useProjects, useServerConfigs, useThreadShells, waitForProject } from "../state/entities"; | |
| 88 | 88 | import { useThreadSearch } from "../state/queries"; |
| 89 | 89 | import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions"; |
| 90 | 90 | import { |
| ... | ... | @@ -643,6 +643,9 @@ function OpenCommandPaletteDialog(props: { |
| 643 | 643 | const cloneRepository = useAtomCommand(sourceControlEnvironment.cloneRepository, { |
| 644 | 644 | reportFailure: false, |
| 645 | 645 | }); |
| 646 | const startProjectClone = useAtomCommand(sourceControlEnvironment.startProjectClone, { | |
| 647 | reportFailure: false, | |
| 648 | }); | |
| 646 | 649 | const { environments } = useEnvironments(); |
| 647 | 650 | const desktopLocalBootstraps = useDesktopLocalBootstraps(); |
| 648 | 651 | const primaryEnvironmentId = usePrimaryEnvironmentId(); |
| ... | ... | @@ -2198,28 +2201,80 @@ function OpenCommandPaletteDialog(props: { |
| 2198 | 2201 | return; |
| 2199 | 2202 | } |
| 2200 | 2203 | |
| 2204 | // Older servers only offer the blocking clone: the palette has to wait | |
| 2205 | // for git so it can add the project afterwards. | |
| 2206 | if (browseEnvironment?.serverConfig?.environment.capabilities.projectCloneTracking !== true) { | |
| 2207 | setIsRemoteProjectCloning(true); | |
| 2208 | const cloneResult = await cloneRepository({ | |
| 2209 | environmentId: addProjectCloneFlow.environmentId, | |
| 2210 | input: { | |
| 2211 | remoteUrl: addProjectCloneFlow.remoteUrl, | |
| 2212 | destinationPath, | |
| 2213 | }, | |
| 2214 | }); | |
| 2215 | setIsRemoteProjectCloning(false); | |
| 2216 | if (cloneResult._tag === "Failure") { | |
| 2217 | if (!isAtomCommandInterrupted(cloneResult)) { | |
| 2218 | toastManager.add( | |
| 2219 | stackedThreadToast({ | |
| 2220 | type: "error", | |
| 2221 | title: "Clone failed", | |
| 2222 | description: errorMessage(squashAtomCommandFailure(cloneResult)), | |
| 2223 | }), | |
| 2224 | ); | |
| 2225 | } | |
| 2226 | return; | |
| 2227 | } | |
| 2228 | await handleAddProject(cloneResult.value.cwd); | |
| 2229 | return; | |
| 2230 | } | |
| 2231 | ||
| 2232 | // The server creates the project and clones in the background; progress | |
| 2233 | // shows in a toast and in the draft's composer banner, so the palette | |
| 2234 | // closes as soon as the clone is under way. Only problems found before | |
| 2235 | // git runs (bad destination, unknown repository) come back here. | |
| 2236 | const projectId = newProjectId(); | |
| 2201 | 2237 | setIsRemoteProjectCloning(true); |
| 2202 | const cloneResult = await cloneRepository({ | |
| 2238 | const startResult = await startProjectClone({ | |
| 2203 | 2239 | environmentId: addProjectCloneFlow.environmentId, |
| 2204 | 2240 | input: { |
| 2241 | projectId, | |
| 2242 | title: inferProjectTitleFromPath(destinationPath), | |
| 2243 | createdAt: new Date().toISOString(), | |
| 2205 | 2244 | remoteUrl: addProjectCloneFlow.remoteUrl, |
| 2206 | 2245 | destinationPath, |
| 2207 | 2246 | }, |
| 2208 | 2247 | }); |
| 2209 | 2248 | setIsRemoteProjectCloning(false); |
| 2210 | if (cloneResult._tag === "Failure") { | |
| 2211 | if (!isAtomCommandInterrupted(cloneResult)) { | |
| 2249 | if (startResult._tag === "Failure") { | |
| 2250 | if (!isAtomCommandInterrupted(startResult)) { | |
| 2212 | 2251 | toastManager.add( |
| 2213 | 2252 | stackedThreadToast({ |
| 2214 | 2253 | type: "error", |
| 2215 | 2254 | title: "Clone failed", |
| 2216 | description: errorMessage(squashAtomCommandFailure(cloneResult)), | |
| 2255 | description: errorMessage(squashAtomCommandFailure(startResult)), | |
| 2217 | 2256 | }), |
| 2218 | 2257 | ); |
| 2219 | 2258 | } |
| 2220 | 2259 | return; |
| 2221 | 2260 | } |
| 2222 | await handleAddProject(cloneResult.value.cwd); | |
| 2261 | setOpen(false); | |
| 2262 | const projectRef = scopeProjectRef(addProjectCloneFlow.environmentId, projectId); | |
| 2263 | // The create event usually lands before this call returns; give the shell | |
| 2264 | // stream a moment so the draft opens with its project resolved instead of | |
| 2265 | // flashing the project picker. | |
| 2266 | await waitForProject(projectRef, 3_000).catch(() => null); | |
| 2267 | const navigationResult = await settlePromise(() => handleNewThread(projectRef)); | |
| 2268 | if (navigationResult._tag === "Failure") { | |
| 2269 | const error = squashAtomCommandFailure(navigationResult); | |
| 2270 | toastManager.add( | |
| 2271 | stackedThreadToast({ | |
| 2272 | type: "error", | |
| 2273 | title: "Failed to open project", | |
| 2274 | description: error instanceof Error ? error.message : "An error occurred.", | |
| 2275 | }), | |
| 2276 | ); | |
| 2277 | } | |
| 2223 | 2278 | } |
| 2224 | 2279 | |
| 2225 | 2280 | const browseTo = useCallback( |
apps/web/src/components/ProjectCloneToastCoordinator.tsx created+240| ... | ... | @@ -0,0 +1,240 @@ |
| 1 | import { useParams } from "@tanstack/react-router"; | |
| 2 | import { scopeProjectRef } from "@t3tools/client-runtime/environment"; | |
| 3 | import { | |
| 4 | type AtomCommandResult, | |
| 5 | isAtomCommandInterrupted, | |
| 6 | squashAtomCommandFailure, | |
| 7 | } from "@t3tools/client-runtime/state/runtime"; | |
| 8 | import { | |
| 9 | projectCloneDisplayName, | |
| 10 | projectCloneProgressSummary, | |
| 11 | type EnvironmentId, | |
| 12 | type ProjectCloneSnapshot, | |
| 13 | type ProjectId, | |
| 14 | } from "@t3tools/contracts"; | |
| 15 | import { useCallback, useEffect, useRef } from "react"; | |
| 16 | ||
| 17 | import { useNewThreadHandler } from "../hooks/useHandleNewThread"; | |
| 18 | import { useRemoveClonedProject } from "../hooks/useRemoveClonedProject"; | |
| 19 | import { useEnvironments } from "../state/environments"; | |
| 20 | import { useEnvironmentProjectClones } from "../state/projectClones"; | |
| 21 | import { sourceControlEnvironment } from "../state/sourceControl"; | |
| 22 | import { useAtomCommand } from "../state/use-atom-command"; | |
| 23 | import { type DraftId, useComposerDraftStore } from "../composerDraftStore"; | |
| 24 | import { toastManager } from "./ui/toast"; | |
| 25 | import { stackedThreadToast } from "./ui/toastHelpers"; | |
| 26 | ||
| 27 | /** | |
| 28 | * One toast per clone in flight, on every environment. The palette that | |
| 29 | * started a clone closes right away, so this is where its progress lives: | |
| 30 | * the toast updates in place as git reports stages, then settles into a | |
| 31 | * success or failure state with the matching action. | |
| 32 | */ | |
| 33 | export function ProjectCloneToastCoordinator() { | |
| 34 | const { environments } = useEnvironments(); | |
| 35 | return environments.map((environment) => ( | |
| 36 | <EnvironmentCloneToasts | |
| 37 | key={environment.environmentId} | |
| 38 | environmentId={environment.environmentId} | |
| 39 | /> | |
| 40 | )); | |
| 41 | } | |
| 42 | ||
| 43 | interface TrackedToast { | |
| 44 | readonly toastId: ReturnType<typeof toastManager.add>; | |
| 45 | /** The last snapshot rendered, so an identical redraw does not touch the toast. */ | |
| 46 | readonly renderedKey: string; | |
| 47 | readonly phase: ProjectCloneSnapshot["phase"]; | |
| 48 | } | |
| 49 | ||
| 50 | function renderKey(clone: ProjectCloneSnapshot): string { | |
| 51 | return `${clone.phase}:${clone.stage}:${clone.percent ?? ""}:${clone.detail ?? ""}:${clone.error ?? ""}`; | |
| 52 | } | |
| 53 | ||
| 54 | function EnvironmentCloneToasts({ environmentId }: { environmentId: EnvironmentId }) { | |
| 55 | const clones = useEnvironmentProjectClones(environmentId); | |
| 56 | const handleNewThread = useNewThreadHandler(); | |
| 57 | const { draftId: routeDraftId } = useParams({ strict: false }); | |
| 58 | const cancelClone = useAtomCommand(sourceControlEnvironment.cancelProjectClone, { | |
| 59 | reportFailure: false, | |
| 60 | }); | |
| 61 | const retryClone = useAtomCommand(sourceControlEnvironment.retryProjectClone, { | |
| 62 | reportFailure: false, | |
| 63 | }); | |
| 64 | // The toast mirrors the server's clone state, so a request that never got | |
| 65 | // there needs its own feedback. | |
| 66 | const runCloneAction = useCallback( | |
| 67 | async (title: string, action: () => Promise<AtomCommandResult<unknown, unknown>>) => { | |
| 68 | const result = await action(); | |
| 69 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { | |
| 70 | const error = squashAtomCommandFailure(result); | |
| 71 | toastManager.add( | |
| 72 | stackedThreadToast({ | |
| 73 | type: "error", | |
| 74 | title, | |
| 75 | description: error instanceof Error ? error.message : "An error occurred.", | |
| 76 | }), | |
| 77 | ); | |
| 78 | } | |
| 79 | }, | |
| 80 | [], | |
| 81 | ); | |
| 82 | const removeClonedProject = useRemoveClonedProject(); | |
| 83 | const toasts = useRef(new Map<ProjectId, TrackedToast>()); | |
| 84 | ||
| 85 | // Whether the user is already looking at this project's draft: the composer | |
| 86 | // banner shows the same progress and actions there, so the toast steps | |
| 87 | // aside and comes back if they navigate away mid-clone. | |
| 88 | const isViewingProjectDraft = useCallback( | |
| 89 | (projectId: ProjectId) => { | |
| 90 | if (!routeDraftId) return false; | |
| 91 | const draft = useComposerDraftStore.getState().getDraftSession(routeDraftId as DraftId); | |
| 92 | return draft?.environmentId === environmentId && draft.projectId === projectId; | |
| 93 | }, | |
| 94 | [environmentId, routeDraftId], | |
| 95 | ); | |
| 96 | ||
| 97 | const openProject = useCallback( | |
| 98 | (projectId: ProjectId) => { | |
| 99 | void handleNewThread(scopeProjectRef(environmentId, projectId)); | |
| 100 | }, | |
| 101 | [environmentId, handleNewThread], | |
| 102 | ); | |
| 103 | ||
| 104 | useEffect(() => { | |
| 105 | const seen = new Set<ProjectId>(); | |
| 106 | for (const clone of clones) { | |
| 107 | seen.add(clone.projectId); | |
| 108 | const key = renderKey(clone); | |
| 109 | const tracked = toasts.current.get(clone.projectId); | |
| 110 | const name = projectCloneDisplayName(clone); | |
| 111 | // Handlers run later than this pass, so they look the toast up then. | |
| 112 | const closeToast = () => { | |
| 113 | const current = toasts.current.get(clone.projectId); | |
| 114 | if (!current) return; | |
| 115 | toastManager.close(current.toastId); | |
| 116 | toasts.current.delete(clone.projectId); | |
| 117 | }; | |
| 118 | if (isViewingProjectDraft(clone.projectId)) { | |
| 119 | closeToast(); | |
| 120 | continue; | |
| 121 | } | |
| 122 | if (tracked?.renderedKey === key) continue; | |
| 123 | ||
| 124 | if (clone.phase === "running") { | |
| 125 | const options = stackedThreadToast({ | |
| 126 | type: "loading", | |
| 127 | title: `Cloning ${name}`, | |
| 128 | description: projectCloneProgressSummary(clone), | |
| 129 | timeout: 0, | |
| 130 | actionProps: { | |
| 131 | children: "Cancel", | |
| 132 | onClick: () => { | |
| 133 | void runCloneAction("Failed to cancel clone", () => | |
| 134 | cancelClone({ environmentId, input: { projectId: clone.projectId } }), | |
| 135 | ); | |
| 136 | }, | |
| 137 | }, | |
| 138 | data: { hideCopyButton: true }, | |
| 139 | }); | |
| 140 | if (tracked) { | |
| 141 | toastManager.update(tracked.toastId, options); | |
| 142 | toasts.current.set(clone.projectId, { ...tracked, renderedKey: key, phase: "running" }); | |
| 143 | } else { | |
| 144 | const toastId = toastManager.add(options); | |
| 145 | toasts.current.set(clone.projectId, { toastId, renderedKey: key, phase: "running" }); | |
| 146 | } | |
| 147 | continue; | |
| 148 | } | |
| 149 | ||
| 150 | if (clone.phase === "done") { | |
| 151 | const options = stackedThreadToast({ | |
| 152 | type: "success", | |
| 153 | title: `Cloned ${name}`, | |
| 154 | description: clone.destinationPath, | |
| 155 | timeout: 8_000, | |
| 156 | actionProps: { | |
| 157 | children: "Open project", | |
| 158 | onClick: () => { | |
| 159 | closeToast(); | |
| 160 | openProject(clone.projectId); | |
| 161 | }, | |
| 162 | }, | |
| 163 | data: { hideCopyButton: true }, | |
| 164 | }); | |
| 165 | if (tracked) { | |
| 166 | toastManager.update(tracked.toastId, options); | |
| 167 | toasts.current.set(clone.projectId, { ...tracked, renderedKey: key, phase: "done" }); | |
| 168 | } else { | |
| 169 | const toastId = toastManager.add(options); | |
| 170 | toasts.current.set(clone.projectId, { toastId, renderedKey: key, phase: "done" }); | |
| 171 | } | |
| 172 | continue; | |
| 173 | } | |
| 174 | ||
| 175 | // Failed or cancelled: the project stays, pointing at an empty folder. | |
| 176 | // Retry from here; the draft's composer banner offers the same. | |
| 177 | const cancelled = clone.phase === "cancelled"; | |
| 178 | const options = stackedThreadToast({ | |
| 179 | type: cancelled ? "info" : "error", | |
| 180 | title: cancelled ? `Cancelled cloning ${name}` : `Failed to clone ${name}`, | |
| 181 | description: cancelled ? clone.destinationPath : (clone.error ?? "The clone failed."), | |
| 182 | timeout: 0, | |
| 183 | actionProps: { | |
| 184 | children: "Retry", | |
| 185 | onClick: () => { | |
| 186 | void runCloneAction("Failed to retry clone", () => | |
| 187 | retryClone({ environmentId, input: { projectId: clone.projectId } }), | |
| 188 | ); | |
| 189 | }, | |
| 190 | }, | |
| 191 | data: { | |
| 192 | ...(cancelled ? { hideCopyButton: true } : {}), | |
| 193 | secondaryActionProps: { | |
| 194 | children: "Remove project", | |
| 195 | onClick: () => { | |
| 196 | // The server drops the clone with the project, which closes | |
| 197 | // this toast; a failed removal leaves it (and Retry) in place. | |
| 198 | void removeClonedProject({ environmentId, projectId: clone.projectId }); | |
| 199 | }, | |
| 200 | }, | |
| 201 | }, | |
| 202 | }); | |
| 203 | if (tracked) { | |
| 204 | toastManager.update(tracked.toastId, options); | |
| 205 | toasts.current.set(clone.projectId, { ...tracked, renderedKey: key, phase: clone.phase }); | |
| 206 | } else { | |
| 207 | const toastId = toastManager.add(options); | |
| 208 | toasts.current.set(clone.projectId, { toastId, renderedKey: key, phase: clone.phase }); | |
| 209 | } | |
| 210 | } | |
| 211 | ||
| 212 | // A clone the server stopped tracking (done and expired, or its project | |
| 213 | // was removed) takes its toast with it, unless it already settled into a | |
| 214 | // timed success toast that dismisses itself. | |
| 215 | for (const [projectId, tracked] of toasts.current) { | |
| 216 | if (seen.has(projectId)) continue; | |
| 217 | if (tracked.phase !== "done") toastManager.close(tracked.toastId); | |
| 218 | toasts.current.delete(projectId); | |
| 219 | } | |
| 220 | }, [ | |
| 221 | cancelClone, | |
| 222 | clones, | |
| 223 | environmentId, | |
| 224 | isViewingProjectDraft, | |
| 225 | openProject, | |
| 226 | removeClonedProject, | |
| 227 | retryClone, | |
| 228 | runCloneAction, | |
| 229 | ]); | |
| 230 | ||
| 231 | useEffect( | |
| 232 | () => () => { | |
| 233 | for (const tracked of toasts.current.values()) toastManager.close(tracked.toastId); | |
| 234 | toasts.current.clear(); | |
| 235 | }, | |
| 236 | [], | |
| 237 | ); | |
| 238 | ||
| 239 | return null; | |
| 240 | } |
apps/web/src/hooks/useRemoveClonedProject.ts created+62| ... | ... | @@ -0,0 +1,62 @@ |
| 1 | import { useRouter } from "@tanstack/react-router"; | |
| 2 | import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; | |
| 3 | import type { ScopedProjectRef } from "@t3tools/contracts"; | |
| 4 | import { useCallback } from "react"; | |
| 5 | ||
| 6 | import { useComposerDraftStore } from "../composerDraftStore"; | |
| 7 | import { resolveThreadRouteTarget } from "../threadRoutes"; | |
| 8 | import { releaseProjectDraftUploads } from "../lib/composerDraftUploads"; | |
| 9 | import { projectEnvironment } from "../state/projects"; | |
| 10 | import { useAtomCommand } from "../state/use-atom-command"; | |
| 11 | import { stackedThreadToast, toastManager } from "../components/ui/toast"; | |
| 12 | ||
| 13 | /** | |
| 14 | * Removes a project whose clone never landed. The server clears the empty | |
| 15 | * folder along with the clone, so there is nothing to confirm: no threads | |
| 16 | * exist yet and the draft is the only thing lost, which the user is looking | |
| 17 | * at when they click. | |
| 18 | */ | |
| 19 | export function useRemoveClonedProject() { | |
| 20 | const router = useRouter(); | |
| 21 | const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false }); | |
| 22 | ||
| 23 | return useCallback( | |
| 24 | async (projectRef: ScopedProjectRef) => { | |
| 25 | const draftStore = useComposerDraftStore.getState(); | |
| 26 | const result = await deleteProject({ | |
| 27 | environmentId: projectRef.environmentId, | |
| 28 | // Not forced: a project whose clone never landed has no threads, and | |
| 29 | // if one appeared in the meantime the server refuses rather than | |
| 30 | // silently deleting it. | |
| 31 | input: { projectId: projectRef.projectId }, | |
| 32 | }); | |
| 33 | if (result._tag === "Failure") { | |
| 34 | const error = squashAtomCommandFailure(result); | |
| 35 | toastManager.add( | |
| 36 | stackedThreadToast({ | |
| 37 | type: "error", | |
| 38 | title: "Failed to remove project", | |
| 39 | description: error instanceof Error ? error.message : "An error occurred.", | |
| 40 | }), | |
| 41 | ); | |
| 42 | return false; | |
| 43 | } | |
| 44 | // Read the route after the await: the user may have moved on while the | |
| 45 | // delete was in flight, and only a draft of this project needs to go. | |
| 46 | const routeParams = router.state.matches[router.state.matches.length - 1]?.params ?? {}; | |
| 47 | const routeTarget = resolveThreadRouteTarget(routeParams); | |
| 48 | const viewingDraft = | |
| 49 | routeTarget?.kind === "draft" ? draftStore.getDraftSession(routeTarget.draftId) : null; | |
| 50 | const viewingThisProject = | |
| 51 | viewingDraft?.environmentId === projectRef.environmentId && | |
| 52 | viewingDraft.projectId === projectRef.projectId; | |
| 53 | releaseProjectDraftUploads(projectRef); | |
| 54 | const projectDraft = draftStore.getDraftThreadByProjectRef(projectRef); | |
| 55 | if (projectDraft) draftStore.clearDraftThread(projectDraft.draftId); | |
| 56 | draftStore.clearProjectDraftThreadId(projectRef); | |
| 57 | if (viewingThisProject) void router.navigate({ to: "/", replace: true }); | |
| 58 | return true; | |
| 59 | }, | |
| 60 | [deleteProject, router], | |
| 61 | ); | |
| 62 | } |
apps/web/src/routes/__root.tsx+2| ... | ... | @@ -27,6 +27,7 @@ import { SnapShotCoordinator } from "../components/desktop/SnapShotCoordinator"; |
| 27 | 27 | import { DesktopAppActivationCoordinator } from "../components/desktop/DesktopAppActivationCoordinator"; |
| 28 | 28 | import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; |
| 29 | 29 | import { ThreadNotificationCoordinator } from "../components/ThreadNotificationCoordinator"; |
| 30 | import { ProjectCloneToastCoordinator } from "../components/ProjectCloneToastCoordinator"; | |
| 30 | 31 | import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator"; |
| 31 | 32 | import { ThemeEditorHost } from "../components/settings/ThemeEditorHost"; |
| 32 | 33 | import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; |
| ... | ... | @@ -222,6 +223,7 @@ function RootRouteView() { |
| 222 | 223 | <ThreadNotificationCoordinator /> |
| 223 | 224 | <ConfirmDialogHost /> |
| 224 | 225 | <SlowRpcRequestToastCoordinator /> |
| 226 | <ProjectCloneToastCoordinator /> | |
| 225 | 227 | <HostedStaticEnvironmentBootstrap /> |
| 226 | 228 | {primaryEnvironmentAuthenticated ? ( |
| 227 | 229 | <EventRouter skipInitialBootstrapNavigation={returningFromWelcomeRef.current} /> |
apps/web/src/state/projectClones.ts created+52| ... | ... | @@ -0,0 +1,52 @@ |
| 1 | import { useAtomValue } from "@effect/atom-react"; | |
| 2 | import { parseScopedProjectKey, scopedProjectKey } from "@t3tools/client-runtime/environment"; | |
| 3 | import type { EnvironmentId, ProjectCloneSnapshot, ScopedProjectRef } from "@t3tools/contracts"; | |
| 4 | import * as Option from "effect/Option"; | |
| 5 | import { AsyncResult, Atom } from "effect/unstable/reactivity"; | |
| 6 | ||
| 7 | import { environmentServerConfigsAtom } from "./server"; | |
| 8 | import { sourceControlEnvironment } from "./sourceControl"; | |
| 9 | ||
| 10 | const EMPTY_CLONES: ReadonlyArray<ProjectCloneSnapshot> = []; | |
| 11 | const EMPTY_CLONE_ATOM = Atom.make<ProjectCloneSnapshot | null>(null).pipe( | |
| 12 | Atom.withLabel("web-project-clone:empty"), | |
| 13 | ); | |
| 14 | ||
| 15 | /** | |
| 16 | * Latest clone list an environment has streamed; empty until the subscription | |
| 17 | * delivers, and never subscribed on servers that predate clone tracking. | |
| 18 | */ | |
| 19 | const environmentProjectClonesAtom = Atom.family((environmentId: EnvironmentId) => | |
| 20 | Atom.make((get): ReadonlyArray<ProjectCloneSnapshot> => { | |
| 21 | const supported = | |
| 22 | get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities | |
| 23 | .projectCloneTracking === true; | |
| 24 | if (!supported) return EMPTY_CLONES; | |
| 25 | const result = get(sourceControlEnvironment.projectClones({ environmentId, input: {} })); | |
| 26 | return Option.getOrElse(AsyncResult.value(result), () => EMPTY_CLONES); | |
| 27 | }).pipe(Atom.withLabel(`web-project-clones:${environmentId}`)), | |
| 28 | ); | |
| 29 | ||
| 30 | const projectCloneAtom = Atom.family((key: string) => { | |
| 31 | const ref = parseScopedProjectKey(key); | |
| 32 | return Atom.make((get): ProjectCloneSnapshot | null => { | |
| 33 | if (ref === null) return null; | |
| 34 | const clones = get(environmentProjectClonesAtom(ref.environmentId)); | |
| 35 | return clones.find((clone) => clone.projectId === ref.projectId) ?? null; | |
| 36 | }).pipe(Atom.withLabel(`web-project-clone:${key}`)); | |
| 37 | }); | |
| 38 | ||
| 39 | /** | |
| 40 | * The tracked clone for a project, or null once it finished (or never | |
| 41 | * existed). Subscribing here opens the environment's clone stream, which is | |
| 42 | * cheap: the server sends an empty list and stays quiet until a clone starts. | |
| 43 | */ | |
| 44 | export function useProjectClone(ref: ScopedProjectRef | null): ProjectCloneSnapshot | null { | |
| 45 | return useAtomValue(ref === null ? EMPTY_CLONE_ATOM : projectCloneAtom(scopedProjectKey(ref))); | |
| 46 | } | |
| 47 | ||
| 48 | export function useEnvironmentProjectClones( | |
| 49 | environmentId: EnvironmentId, | |
| 50 | ): ReadonlyArray<ProjectCloneSnapshot> { | |
| 51 | return useAtomValue(environmentProjectClonesAtom(environmentId)); | |
| 52 | } |
docs/user/source-control.md+4-1| ... | ... | @@ -76,7 +76,10 @@ az login |
| 76 | 76 | ## Clone or publish a project |
| 77 | 77 | |
| 78 | 78 | Use **Add Project** in the command palette (`Cmd/Ctrl+K`) to clone a repository. Choose a hosting |
| 79 | provider or paste a Git URL, then choose where to save it. | |
| 79 | provider or paste a Git URL, then choose where to save it. The project opens right away while the | |
| 80 | clone runs in the background: you can write your first prompt, and sending waits until the files | |
| 81 | are in place. A toast tracks progress and lets you cancel; if the clone fails, retry it from the | |
| 82 | toast or from the banner above the composer. | |
| 80 | 83 | |
| 81 | 84 | For a local Git repository without a remote, **Publish Repository** creates a hosted repository, |
| 82 | 85 | adds it as `origin`, and pushes your commits. If there are no commits yet, it creates the remote; |
packages/client-runtime/src/rpc/client.ts+1| ... | ... | @@ -57,6 +57,7 @@ export type EnvironmentSubscriptionRpcTag = |
| 57 | 57 | | typeof WS_METHODS.previewAutomationConnect |
| 58 | 58 | | typeof WS_METHODS.subscribeVcsStatus |
| 59 | 59 | | typeof WS_METHODS.subscribeWorktreeSetup |
| 60 | | typeof WS_METHODS.subscribeProjectClones | |
| 60 | 61 | | typeof WS_METHODS.terminalAttach; |
| 61 | 62 | |
| 62 | 63 | export type EnvironmentStreamCommandRpcTag = |
packages/client-runtime/src/state/sourceControl.ts+38| ... | ... | @@ -5,6 +5,7 @@ import { |
| 5 | 5 | createAtomCommandScheduler, |
| 6 | 6 | createEnvironmentRpcCommand, |
| 7 | 7 | createEnvironmentRpcQueryAtomFamily, |
| 8 | createEnvironmentRpcSubscriptionAtomFamily, | |
| 8 | 9 | } from "./runtime.ts"; |
| 9 | 10 | import type { EnvironmentRegistry } from "../connection/registry.ts"; |
| 10 | 11 | import { EnvironmentCacheStore } from "../platform/persistence.ts"; |
| ... | ... | @@ -33,6 +34,43 @@ export function createSourceControlEnvironmentAtoms<R, E>( |
| 33 | 34 | key: ({ environmentId }) => environmentId, |
| 34 | 35 | }, |
| 35 | 36 | }), |
| 37 | // Clone-backed project creation. The RPC returns once the project exists | |
| 38 | // and the clone runs in the background; `projectClones` carries progress. | |
| 39 | startProjectClone: createEnvironmentRpcCommand(runtime, { | |
| 40 | label: "environment-data:source-control:project-clone-start", | |
| 41 | tag: WS_METHODS.projectCloneStart, | |
| 42 | scheduler: commandScheduler, | |
| 43 | concurrency: { | |
| 44 | mode: "serial", | |
| 45 | key: ({ environmentId }) => environmentId, | |
| 46 | }, | |
| 47 | }), | |
| 48 | // Cancel and retry share the start queue so a double click cannot race | |
| 49 | // two actions against the same clone. | |
| 50 | cancelProjectClone: createEnvironmentRpcCommand(runtime, { | |
| 51 | label: "environment-data:source-control:project-clone-cancel", | |
| 52 | tag: WS_METHODS.projectCloneCancel, | |
| 53 | scheduler: commandScheduler, | |
| 54 | concurrency: { | |
| 55 | mode: "serial", | |
| 56 | key: ({ environmentId }) => environmentId, | |
| 57 | }, | |
| 58 | }), | |
| 59 | retryProjectClone: createEnvironmentRpcCommand(runtime, { | |
| 60 | label: "environment-data:source-control:project-clone-retry", | |
| 61 | tag: WS_METHODS.projectCloneRetry, | |
| 62 | scheduler: commandScheduler, | |
| 63 | concurrency: { | |
| 64 | mode: "serial", | |
| 65 | key: ({ environmentId }) => environmentId, | |
| 66 | }, | |
| 67 | }), | |
| 68 | // Every clone the environment tracks. Empty until a clone starts; a | |
| 69 | // finished clone drops out after a grace period, a failed one stays. | |
| 70 | projectClones: createEnvironmentRpcSubscriptionAtomFamily(runtime, { | |
| 71 | label: "environment-data:source-control:project-clones", | |
| 72 | tag: WS_METHODS.subscribeProjectClones, | |
| 73 | }), | |
| 36 | 74 | publishRepository: createEnvironmentRpcCommand(runtime, { |
| 37 | 75 | label: "environment-data:source-control:publish-repository", |
| 38 | 76 | tag: WS_METHODS.sourceControlPublishRepository, |
packages/contracts/src/environment.ts+5| ... | ... | @@ -153,6 +153,11 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ |
| 153 | 153 | this is false — no update would ever repaint it. Absent on older |
| 154 | 154 | servers, which may still publish, so only an explicit false skips. */ |
| 155 | 155 | agentActivityPublishing: Schema.optionalKey(Schema.Boolean), |
| 156 | /** Server runs repository clones for new projects in the background and | |
| 157 | streams their progress (`projectClone.*`, `subscribeProjectClones`). | |
| 158 | Absent on older servers, where clients must clone with the blocking | |
| 159 | `sourceControl.cloneRepository` call instead. */ | |
| 160 | projectCloneTracking: Schema.optionalKey(Schema.Boolean), | |
| 156 | 161 | /** Server detects `platform.machine` and persists the `environmentIcon` |
| 157 | 162 | setting. Older servers drop the key on write, so clients show the |
| 158 | 163 | picker inert rather than offering a choice that would never stick. */ |
packages/contracts/src/index.ts+1| ... | ... | @@ -25,6 +25,7 @@ export * from "./settings.ts"; |
| 25 | 25 | export * from "./git.ts"; |
| 26 | 26 | export * from "./vcs.ts"; |
| 27 | 27 | export * from "./sourceControl.ts"; |
| 28 | export * from "./projectClone.ts"; | |
| 28 | 29 | export * from "./pullRequest.ts"; |
| 29 | 30 | export * from "./orchestration.ts"; |
| 30 | 31 | export * from "./t3ProjectFile.ts"; |
packages/contracts/src/projectClone.ts created+122| ... | ... | @@ -0,0 +1,122 @@ |
| 1 | import * as Schema from "effect/Schema"; | |
| 2 | ||
| 3 | import { IsoDateTime, NonNegativeInt, ProjectId, TrimmedNonEmptyString } from "./baseSchemas.ts"; | |
| 4 | import { | |
| 5 | SourceControlCloneProtocol, | |
| 6 | SourceControlProviderKind, | |
| 7 | SourceControlRepositoryInfo, | |
| 8 | } from "./sourceControl.ts"; | |
| 9 | ||
| 10 | /** | |
| 11 | * Live progress of a repository clone that backs a freshly added project. The | |
| 12 | * server keeps this in memory only: a finished clone is dropped after a short | |
| 13 | * grace period, a failed one stays until it is retried or the project is | |
| 14 | * removed, and a server restart forgets in-flight clones (the project keeps | |
| 15 | * its empty workspace root and can be removed like any other project). | |
| 16 | */ | |
| 17 | /** Producers clamp free text to these before publishing so encoding never fails. */ | |
| 18 | export const PROJECT_CLONE_DETAIL_MAX_LENGTH = 200; | |
| 19 | export const PROJECT_CLONE_ERROR_MAX_LENGTH = 1000; | |
| 20 | ||
| 21 | /** Follows git's own clone phases as they appear on stderr, in order. */ | |
| 22 | export const ProjectCloneStage = Schema.Literals([ | |
| 23 | "connecting", | |
| 24 | "counting", | |
| 25 | "receiving", | |
| 26 | "resolving", | |
| 27 | "checkout", | |
| 28 | ]); | |
| 29 | export type ProjectCloneStage = typeof ProjectCloneStage.Type; | |
| 30 | ||
| 31 | export const ProjectClonePhase = Schema.Literals(["running", "done", "failed", "cancelled"]); | |
| 32 | export type ProjectClonePhase = typeof ProjectClonePhase.Type; | |
| 33 | ||
| 34 | export const ProjectCloneSnapshot = Schema.Struct({ | |
| 35 | projectId: ProjectId, | |
| 36 | remoteUrl: TrimmedNonEmptyString, | |
| 37 | destinationPath: TrimmedNonEmptyString, | |
| 38 | repository: Schema.NullOr(SourceControlRepositoryInfo), | |
| 39 | phase: ProjectClonePhase, | |
| 40 | stage: ProjectCloneStage, | |
| 41 | /** Percent of the current stage, parsed from git's progress lines. */ | |
| 42 | percent: Schema.NullOr(Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 100 }))), | |
| 43 | /** Trailing text from the progress line, typically transfer size and rate. */ | |
| 44 | detail: Schema.NullOr(Schema.String.check(Schema.isMaxLength(PROJECT_CLONE_DETAIL_MAX_LENGTH))), | |
| 45 | /** Human readable reason when phase is failed. */ | |
| 46 | error: Schema.NullOr(Schema.String.check(Schema.isMaxLength(PROJECT_CLONE_ERROR_MAX_LENGTH))), | |
| 47 | startedAt: IsoDateTime, | |
| 48 | endedAt: Schema.NullOr(IsoDateTime), | |
| 49 | sequence: NonNegativeInt, | |
| 50 | }); | |
| 51 | export type ProjectCloneSnapshot = typeof ProjectCloneSnapshot.Type; | |
| 52 | ||
| 53 | export const ProjectCloneSubscribeInput = Schema.Struct({}); | |
| 54 | export type ProjectCloneSubscribeInput = typeof ProjectCloneSubscribeInput.Type; | |
| 55 | ||
| 56 | /** Every tracked clone on the environment. Sent first, then after every change. */ | |
| 57 | export const ProjectCloneListEvent = Schema.Array(ProjectCloneSnapshot); | |
| 58 | export type ProjectCloneListEvent = typeof ProjectCloneListEvent.Type; | |
| 59 | ||
| 60 | export const ProjectCloneStartInput = Schema.Struct({ | |
| 61 | projectId: ProjectId, | |
| 62 | title: TrimmedNonEmptyString, | |
| 63 | createdAt: IsoDateTime, | |
| 64 | provider: Schema.optional(SourceControlProviderKind), | |
| 65 | repository: Schema.optional(TrimmedNonEmptyString), | |
| 66 | remoteUrl: Schema.optional(TrimmedNonEmptyString), | |
| 67 | destinationPath: TrimmedNonEmptyString, | |
| 68 | protocol: Schema.optional(SourceControlCloneProtocol), | |
| 69 | }); | |
| 70 | export type ProjectCloneStartInput = typeof ProjectCloneStartInput.Type; | |
| 71 | ||
| 72 | export const ProjectCloneStartResult = Schema.Struct({ | |
| 73 | projectId: ProjectId, | |
| 74 | cwd: TrimmedNonEmptyString, | |
| 75 | remoteUrl: TrimmedNonEmptyString, | |
| 76 | repository: Schema.NullOr(SourceControlRepositoryInfo), | |
| 77 | }); | |
| 78 | export type ProjectCloneStartResult = typeof ProjectCloneStartResult.Type; | |
| 79 | ||
| 80 | export const ProjectCloneActionInput = Schema.Struct({ | |
| 81 | projectId: ProjectId, | |
| 82 | }); | |
| 83 | export type ProjectCloneActionInput = typeof ProjectCloneActionInput.Type; | |
| 84 | ||
| 85 | export const ProjectCloneActionResult = Schema.Struct({ | |
| 86 | applied: Schema.Boolean, | |
| 87 | }); | |
| 88 | export type ProjectCloneActionResult = typeof ProjectCloneActionResult.Type; | |
| 89 | ||
| 90 | function projectCloneStageLabel(stage: ProjectCloneStage): string { | |
| 91 | switch (stage) { | |
| 92 | case "connecting": | |
| 93 | return "Connecting"; | |
| 94 | case "counting": | |
| 95 | return "Counting objects"; | |
| 96 | case "receiving": | |
| 97 | return "Receiving objects"; | |
| 98 | case "resolving": | |
| 99 | return "Resolving deltas"; | |
| 100 | case "checkout": | |
| 101 | return "Checking out files"; | |
| 102 | } | |
| 103 | } | |
| 104 | ||
| 105 | /** Display name for a clone: the looked-up `owner/repo`, else the folder being cloned into. */ | |
| 106 | export function projectCloneDisplayName( | |
| 107 | snapshot: Pick<ProjectCloneSnapshot, "repository" | "destinationPath">, | |
| 108 | ): string { | |
| 109 | if (snapshot.repository) return snapshot.repository.nameWithOwner; | |
| 110 | const segments = snapshot.destinationPath.split(/[/\\]/).filter((segment) => segment.length > 0); | |
| 111 | return segments[segments.length - 1] ?? snapshot.destinationPath; | |
| 112 | } | |
| 113 | ||
| 114 | /** One-line progress summary: `Receiving objects · 45% · 12.3 MiB | 5.0 MiB/s`. */ | |
| 115 | export function projectCloneProgressSummary( | |
| 116 | snapshot: Pick<ProjectCloneSnapshot, "stage" | "percent" | "detail">, | |
| 117 | ): string { | |
| 118 | const parts = [projectCloneStageLabel(snapshot.stage)]; | |
| 119 | if (snapshot.percent !== null) parts.push(`${snapshot.percent}%`); | |
| 120 | if (snapshot.detail) parts.push(snapshot.detail); | |
| 121 | return parts.join(" · "); | |
| 122 | } |
packages/contracts/src/rpc.ts+47| ... | ... | @@ -249,6 +249,14 @@ import { |
| 249 | 249 | } from "./providerUsageLimits.ts"; |
| 250 | 250 | import { UsagePricing, UsageReadError, UsageSummary, UsageSummaryInput } from "./usage.ts"; |
| 251 | 251 | import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts"; |
| 252 | import { | |
| 253 | ProjectCloneActionInput, | |
| 254 | ProjectCloneActionResult, | |
| 255 | ProjectCloneListEvent, | |
| 256 | ProjectCloneStartInput, | |
| 257 | ProjectCloneStartResult, | |
| 258 | ProjectCloneSubscribeInput, | |
| 259 | } from "./projectClone.ts"; | |
| 252 | 260 | import { |
| 253 | 261 | SourceControlCloneRepositoryInput, |
| 254 | 262 | SourceControlCloneRepositoryResult, |
| ... | ... | @@ -407,6 +415,10 @@ export const WS_METHODS = { |
| 407 | 415 | sourceControlLookupRepository: "sourceControl.lookupRepository", |
| 408 | 416 | sourceControlCloneRepository: "sourceControl.cloneRepository", |
| 409 | 417 | sourceControlPublishRepository: "sourceControl.publishRepository", |
| 418 | projectCloneStart: "projectClone.start", | |
| 419 | projectCloneCancel: "projectClone.cancel", | |
| 420 | projectCloneRetry: "projectClone.retry", | |
| 421 | subscribeProjectClones: "subscribeProjectClones", | |
| 410 | 422 | |
| 411 | 423 | // Streaming subscriptions |
| 412 | 424 | subscribeVcsStatus: "subscribeVcsStatus", |
| ... | ... | @@ -842,6 +854,37 @@ const WsSourceControlCloneRepositoryRpc = Rpc.make(WS_METHODS.sourceControlClone |
| 842 | 854 | error: Schema.Union([SourceControlRepositoryError, EnvironmentAuthorizationError]), |
| 843 | 855 | }); |
| 844 | 856 | |
| 857 | // Clone-backed project creation. `start` returns once the project exists and | |
| 858 | // the clone is running; progress arrives on the subscription. | |
| 859 | const WsProjectCloneStartRpc = Rpc.make(WS_METHODS.projectCloneStart, { | |
| 860 | payload: ProjectCloneStartInput, | |
| 861 | success: ProjectCloneStartResult, | |
| 862 | error: Schema.Union([ | |
| 863 | SourceControlRepositoryError, | |
| 864 | OrchestrationDispatchCommandError, | |
| 865 | EnvironmentAuthorizationError, | |
| 866 | ]), | |
| 867 | }); | |
| 868 | ||
| 869 | const WsProjectCloneCancelRpc = Rpc.make(WS_METHODS.projectCloneCancel, { | |
| 870 | payload: ProjectCloneActionInput, | |
| 871 | success: ProjectCloneActionResult, | |
| 872 | error: EnvironmentAuthorizationError, | |
| 873 | }); | |
| 874 | ||
| 875 | const WsProjectCloneRetryRpc = Rpc.make(WS_METHODS.projectCloneRetry, { | |
| 876 | payload: ProjectCloneActionInput, | |
| 877 | success: ProjectCloneActionResult, | |
| 878 | error: Schema.Union([SourceControlRepositoryError, EnvironmentAuthorizationError]), | |
| 879 | }); | |
| 880 | ||
| 881 | const WsSubscribeProjectClonesRpc = Rpc.make(WS_METHODS.subscribeProjectClones, { | |
| 882 | payload: ProjectCloneSubscribeInput, | |
| 883 | success: ProjectCloneListEvent, | |
| 884 | error: EnvironmentAuthorizationError, | |
| 885 | stream: true, | |
| 886 | }); | |
| 887 | ||
| 845 | 888 | const WsSourceControlPublishRepositoryRpc = Rpc.make(WS_METHODS.sourceControlPublishRepository, { |
| 846 | 889 | payload: SourceControlPublishRepositoryInput, |
| 847 | 890 | success: SourceControlPublishRepositoryResult, |
| ... | ... | @@ -1379,6 +1422,10 @@ export const WsRpcGroup = RpcGroup.make( |
| 1379 | 1422 | WsSourceControlLookupRepositoryRpc, |
| 1380 | 1423 | WsSourceControlCloneRepositoryRpc, |
| 1381 | 1424 | WsSourceControlPublishRepositoryRpc, |
| 1425 | WsProjectCloneStartRpc, | |
| 1426 | WsProjectCloneCancelRpc, | |
| 1427 | WsProjectCloneRetryRpc, | |
| 1428 | WsSubscribeProjectClonesRpc, | |
| 1382 | 1429 | WsProjectsListEntriesRpc, |
| 1383 | 1430 | WsProjectsReadFileRpc, |
| 1384 | 1431 | WsProjectsSearchContentsRpc, |