authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-14 13:00:28-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-14 13:00:28-07:00
log8d7c700c1788c4f42932cd3be541b3c630bd664d
tree720b483d810558340af36f33f05610c4aea78eb0
parent112a7088da63ea4fa48fee63dc8f29557c545ed7
signature Signed by PGP key B5690EEEBB952194

feat(web): clone repositories in the background instead of holding the palette open (#11762)

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,6 +97,10 @@ export const RPC_REQUIRED_SCOPES = {
97 [WS_METHODS.sourceControlLookupRepository]: AuthOrchestrationReadScope,97 [WS_METHODS.sourceControlLookupRepository]: AuthOrchestrationReadScope,
98 [WS_METHODS.sourceControlCloneRepository]: AuthOrchestrationOperateScope,98 [WS_METHODS.sourceControlCloneRepository]: AuthOrchestrationOperateScope,
99 [WS_METHODS.sourceControlPublishRepository]: AuthOrchestrationOperateScope,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 [WS_METHODS.projectsListEntries]: AuthOrchestrationReadScope,104 [WS_METHODS.projectsListEntries]: AuthOrchestrationReadScope,
101 [WS_METHODS.projectsReadFile]: AuthOrchestrationReadScope,105 [WS_METHODS.projectsReadFile]: AuthOrchestrationReadScope,
102 [WS_METHODS.projectsSearchContents]: AuthOrchestrationReadScope,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,6 +39,7 @@ import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSna
39import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts";39import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts";
40import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts";40import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts";
41import { orchestrationHttpApiLayer } from "./orchestration/http.ts";41import { orchestrationHttpApiLayer } from "./orchestration/http.ts";
42import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts";
42import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts";43import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts";
43import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts";44import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts";
44import {45import {
...@@ -363,7 +364,16 @@ const withLiveProjectCliServer = <A, E, R>(baseDir: string, run: () => Effect.Ef...@@ -363,7 +364,16 @@ const withLiveProjectCliServer = <A, E, R>(baseDir: string, run: () => Effect.Ef
363 Effect.gen(function* () {364 Effect.gen(function* () {
364 const config = yield* makeCliTestServerConfig(baseDir);365 const config = yield* makeCliTestServerConfig(baseDir);
365 const routesLayer = HttpApiBuilder.layer(ProjectCliHttpApi).pipe(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 Layer.provide(environmentAuthenticatedAuthLayer),377 Layer.provide(environmentAuthenticatedAuthLayer),
368 );378 );
369 const appLayer = HttpRouter.serve(routesLayer, {379 const appLayer = HttpRouter.serve(routesLayer, {
apps/server/src/environment/ServerEnvironment.ts+1
...@@ -236,6 +236,7 @@ export const make = Effect.gen(function* () {...@@ -236,6 +236,7 @@ export const make = Effect.gen(function* () {
236 pullRequestStackActions: true,236 pullRequestStackActions: true,
237 threadPullRequestLinking: true,237 threadPullRequestLinking: true,
238 environmentIcon: true,238 environmentIcon: true,
239 projectCloneTracking: true,
239 ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }),240 ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }),
240 ...(serverSelfUpdate === "boot-service" || desktopAppUpdate241 ...(serverSelfUpdate === "boot-service" || desktopAppUpdate
241 ? {242 ? {
apps/server/src/orchestration/http.ts+16-1
...@@ -16,6 +16,7 @@ import {...@@ -16,6 +16,7 @@ import {
16 failEnvironmentNotFound,16 failEnvironmentNotFound,
17 requireEnvironmentScope,17 requireEnvironmentScope,
18} from "../auth/http.ts";18} from "../auth/http.ts";
19import * as ProjectCloneTracker from "../project/ProjectCloneTracker.ts";
19import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts";20import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts";
20import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts";21import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts";
2122
...@@ -25,6 +26,7 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group(...@@ -25,6 +26,7 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group(
25 Effect.fnUntraced(function* (handlers) {26 Effect.fnUntraced(function* (handlers) {
26 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;27 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
27 const orchestrationEngine = yield* OrchestrationEngineService;28 const orchestrationEngine = yield* OrchestrationEngineService;
29 const projectCloneTracker = yield* ProjectCloneTracker.ProjectCloneTracker;
2830
29 return handlers31 return handlers
30 .handle(32 .handle(
...@@ -93,10 +95,18 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group(...@@ -93,10 +95,18 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group(
93 Effect.fn("environment.orchestration.dispatch")(function* (args) {95 Effect.fn("environment.orchestration.dispatch")(function* (args) {
94 yield* annotateEnvironmentRequest(args.endpoint.name);96 yield* annotateEnvironmentRequest(args.endpoint.name);
95 yield* requireEnvironmentScope(AuthOrchestrationOperateScope);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 const normalizedCommand = yield* normalizeDispatchCommand(args.payload).pipe(106 const normalizedCommand = yield* normalizeDispatchCommand(args.payload).pipe(
97 Effect.catch(() => failEnvironmentInvalidRequest("invalid_command")),107 Effect.catch(() => failEnvironmentInvalidRequest("invalid_command")),
98 );108 );
99 return yield* orchestrationEngine.dispatch(normalizedCommand).pipe(109 const result = yield* orchestrationEngine.dispatch(normalizedCommand).pipe(
100 Effect.tapError(() =>110 Effect.tapError(() =>
101 cleanupFailedUploadedAttachments(args.payload, normalizedCommand),111 cleanupFailedUploadedAttachments(args.payload, normalizedCommand),
102 ),112 ),
...@@ -104,6 +114,11 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group(...@@ -104,6 +114,11 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group(
104 failEnvironmentInternal("orchestration_dispatch_failed", cause),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 @@
1import { describe, expect, it } from "@effect/vitest";
2import {
3 OrchestrationDispatchCommandError,
4 ProjectId,
5 SourceControlRepositoryError,
6} from "@t3tools/contracts";
7import * as Deferred from "effect/Deferred";
8import * as Effect from "effect/Effect";
9import * as Fiber from "effect/Fiber";
10import * as Layer from "effect/Layer";
11import * as Stream from "effect/Stream";
12import * as TestClock from "effect/testing/TestClock";
13
14import * as SourceControlRepositoryService from "../sourceControl/SourceControlRepositoryService.ts";
15import * as ProjectCloneTracker from "./ProjectCloneTracker.ts";
16import { parseGitCloneProgressLine } from "./gitCloneProgress.ts";
17
18const projectId = ProjectId.make("project-1");
19const 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
27function 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
65describe("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
275describe("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 @@
1import type {
2 OrchestrationCommand,
3 ProjectCloneSnapshot,
4 ProjectCloneStage,
5 ProjectCloneStartInput,
6 ProjectCloneStartResult,
7 ProjectId,
8 SourceControlRepositoryInfo,
9} from "@t3tools/contracts";
10import {
11 OrchestrationDispatchCommandError,
12 PROJECT_CLONE_DETAIL_MAX_LENGTH,
13 PROJECT_CLONE_ERROR_MAX_LENGTH,
14 SourceControlRepositoryError,
15} from "@t3tools/contracts";
16import * as Cause from "effect/Cause";
17import * as Context from "effect/Context";
18import * as DateTime from "effect/DateTime";
19import * as Effect from "effect/Effect";
20import * as Exit from "effect/Exit";
21import * as Fiber from "effect/Fiber";
22import * as Layer from "effect/Layer";
23import * as PubSub from "effect/PubSub";
24import * as Queue from "effect/Queue";
25import * as Ref from "effect/Ref";
26import * as Schema from "effect/Schema";
27import * as Semaphore from "effect/Semaphore";
28import * as Scope from "effect/Scope";
29import * as Stream from "effect/Stream";
30
31import * 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 */
45export 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 */
80export 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. */
95const DONE_RETENTION = "30 seconds";
96
97function clampText(text: string, maxLength: number): string {
98 return text.length <= maxLength ? text : `${text.slice(0, maxLength - 1)}…`;
99}
100
101const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
102
103interface 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. */
116export 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
436const 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 */
444export 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
466function 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. */
473export 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
479function 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
487export const layer = Layer.effect(ProjectCloneTracker, make);
apps/server/src/project/gitCloneProgress.ts created+44
...@@ -0,0 +1,44 @@
1import type { ProjectCloneStage } from "@t3tools/contracts";
2
3export 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
10const 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
20const 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 */
28export 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,6 +148,7 @@ import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts";
148import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts";148import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts";
149import * as ServerSettings from "./serverSettings.ts";149import * as ServerSettings from "./serverSettings.ts";
150import * as TerminalManager from "./terminal/Manager.ts";150import * as TerminalManager from "./terminal/Manager.ts";
151import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts";
151import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts";152import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts";
152import * as PreviewManager from "./preview/Manager.ts";153import * as PreviewManager from "./preview/Manager.ts";
153import * as PortScanner from "./preview/PortScanner.ts";154import * as PortScanner from "./preview/PortScanner.ts";
...@@ -933,6 +934,13 @@ const buildAppUnderTest = (options?: {...@@ -933,6 +934,13 @@ const buildAppUnderTest = (options?: {
933 ...options?.layers?.terminalManager,934 ...options?.layers?.terminalManager,
934 }),935 }),
935 WorktreeSetupTracker.layer,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 Layer.provide(946 Layer.provide(
...@@ -7238,6 +7246,106 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -7238,6 +7246,106 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
7238 }).pipe(Effect.provide(NodeHttpServer.layerTest)),7246 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
7239 );7247 );
72407248
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 it.effect("records thread analytics only after a client command succeeds", () =>7349 it.effect("records thread analytics only after a client command succeeds", () =>
7242 Effect.gen(function* () {7350 Effect.gen(function* () {
7243 const effects: string[] = [];7351 const effects: string[] = [];
apps/server/src/server.ts+6
...@@ -103,6 +103,7 @@ import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts";...@@ -103,6 +103,7 @@ import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts";
103import * as VcsProcess from "./vcs/VcsProcess.ts";103import * as VcsProcess from "./vcs/VcsProcess.ts";
104import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts";104import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts";
105import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts";105import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts";
106import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts";
106import * as GitWorkflowService from "./git/GitWorkflowService.ts";107import * as GitWorkflowService from "./git/GitWorkflowService.ts";
107import * as ReviewService from "./review/ReviewService.ts";108import * as ReviewService from "./review/ReviewService.ts";
108import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts";109import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts";
...@@ -354,6 +355,10 @@ const SourceControlRepositoryServiceLayerLive = SourceControlRepositoryService.l...@@ -354,6 +355,10 @@ const SourceControlRepositoryServiceLayerLive = SourceControlRepositoryService.l
354 Layer.provideMerge(SourceControlProviderRegistryLayerLive),355 Layer.provideMerge(SourceControlProviderRegistryLayerLive),
355);356);
356357
358const ProjectCloneTrackerLayerLive = ProjectCloneTracker.layer.pipe(
359 Layer.provide(SourceControlRepositoryServiceLayerLive),
360);
361
357const ReviewLayerLive = ReviewService.layer.pipe(362const ReviewLayerLive = ReviewService.layer.pipe(
358 Layer.provideMerge(GitVcsDriver.layer),363 Layer.provideMerge(GitVcsDriver.layer),
359 Layer.provideMerge(VcsDriverRegistryLayerLive),364 Layer.provideMerge(VcsDriverRegistryLayerLive),
...@@ -366,6 +371,7 @@ const VcsLayerLive = Layer.empty.pipe(...@@ -366,6 +371,7 @@ const VcsLayerLive = Layer.empty.pipe(
366 Layer.provideMerge(GitWorkflowLayerLive),371 Layer.provideMerge(GitWorkflowLayerLive),
367 Layer.provideMerge(ReviewLayerLive),372 Layer.provideMerge(ReviewLayerLive),
368 Layer.provideMerge(SourceControlRepositoryServiceLayerLive),373 Layer.provideMerge(SourceControlRepositoryServiceLayerLive),
374 Layer.provideMerge(ProjectCloneTrackerLayerLive),
369 Layer.provideMerge(375 Layer.provideMerge(
370 VcsStatusBroadcaster.layer.pipe(376 VcsStatusBroadcaster.layer.pipe(
371 Layer.provide(GitWorkflowLayerLive),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,7 +178,7 @@ it.effect("clones a looked-up repository into the requested destination", () =>
178 assert.deepStrictEqual(cloneCalls, [178 assert.deepStrictEqual(cloneCalls, [
179 {179 {
180 cwd: parent,180 cwd: parent,
181 args: ["clone", CLONE_URLS.url, "t3code"],181 args: ["clone", "--progress", CLONE_URLS.url, "t3code"],
182 },182 },
183 ]);183 ]);
184 }).pipe(184 }).pipe(
...@@ -197,6 +197,154 @@ it.effect("clones a looked-up repository into the requested destination", () =>...@@ -197,6 +197,154 @@ it.effect("clones a looked-up repository into the requested destination", () =>
197 }).pipe(Effect.provide(NodeServices.layer)),197 }).pipe(Effect.provide(NodeServices.layer)),
198);198);
199199
200it.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
261it.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
293it.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
327it.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
200it.effect("preserves destination probe failures instead of treating them as missing paths", () => {348it.effect("preserves destination probe failures instead of treating them as missing paths", () => {
201 const fileSystemCause = PlatformError.systemError({349 const fileSystemCause = PlatformError.systemError({
202 _tag: "PermissionDenied",350 _tag: "PermissionDenied",
apps/server/src/sourceControl/SourceControlRepositoryService.ts+193-13
...@@ -3,6 +3,7 @@ import * as Effect from "effect/Effect";...@@ -3,6 +3,7 @@ import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";3import * as FileSystem from "effect/FileSystem";
4import * as Layer from "effect/Layer";4import * as Layer from "effect/Layer";
5import * as Path from "effect/Path";5import * as Path from "effect/Path";
6import * as Schedule from "effect/Schedule";
6import * as Schema from "effect/Schema";7import * as Schema from "effect/Schema";
78
8import {9import {
...@@ -20,6 +21,10 @@ import {...@@ -20,6 +21,10 @@ import {
2021
21import { ServerConfig } from "../config.ts";22import { ServerConfig } from "../config.ts";
22import { expandHomePathWith } from "../pathExpansion.ts";23import { expandHomePathWith } from "../pathExpansion.ts";
24import {
25 parseGitCloneProgressLine,
26 type GitCloneProgressLine,
27} from "../project/gitCloneProgress.ts";
23import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";28import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
24import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";29import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
25const isSourceControlRepositoryError = Schema.is(SourceControlRepositoryError);30const isSourceControlRepositoryError = Schema.is(SourceControlRepositoryError);
...@@ -30,15 +35,56 @@ export class SourceControlRepositoryService extends Context.Service<...@@ -30,15 +35,56 @@ export class SourceControlRepositoryService extends Context.Service<
30 readonly lookupRepository: (35 readonly lookupRepository: (
31 input: SourceControlRepositoryLookupInput,36 input: SourceControlRepositoryLookupInput,
32 ) => Effect.Effect<SourceControlRepositoryInfo, SourceControlRepositoryError>;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 readonly cloneRepository: (46 readonly cloneRepository: (
34 input: SourceControlCloneRepositoryInput,47 input: SourceControlCloneRepositoryInput,
48 options?: SourceControlCloneOptions,
35 ) => Effect.Effect<SourceControlCloneRepositoryResult, SourceControlRepositoryError>;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 readonly publishRepository: (54 readonly publishRepository: (
37 input: SourceControlPublishRepositoryInput,55 input: SourceControlPublishRepositoryInput,
38 ) => Effect.Effect<SourceControlPublishRepositoryResult, SourceControlRepositoryError>;56 ) => Effect.Effect<SourceControlPublishRepositoryResult, SourceControlRepositoryError>;
39 }57 }
40>()("t3/sourceControl/SourceControlRepositoryService") {}58>()("t3/sourceControl/SourceControlRepositoryService") {}
4159
60export 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
69export 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.
78const CLONE_TIMEOUT_MS = 120_000;
79const 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
42function mapRepositoryError(operation: string, provider: SourceControlProviderKind) {88function mapRepositoryError(operation: string, provider: SourceControlProviderKind) {
43 return Effect.mapError((cause: unknown) =>89 return Effect.mapError((cause: unknown) =>
44 isSourceControlRepositoryError(cause)90 isSourceControlRepositoryError(cause)
...@@ -64,6 +110,36 @@ function toRepositoryInfo(...@@ -64,6 +110,36 @@ function toRepositoryInfo(
64 };110 };
65}111}
66112
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 */
118function 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.
136const URL_WITH_USERINFO = /\b([a-z][a-z0-9+.-]*:\/\/)[^\s/]+@/gi;
137
138/** Drops `user:token@` from any URL embedded in free text. */
139function redactUrlCredentials(text: string): string {
140 return text.replace(URL_WITH_USERINFO, "$1");
141}
142
67function selectRemoteUrl(143function selectRemoteUrl(
68 urls: SourceControlRepositoryCloneUrls,144 urls: SourceControlRepositoryCloneUrls,
69 protocol: SourceControlCloneProtocol | undefined,145 protocol: SourceControlCloneProtocol | undefined,
...@@ -168,7 +244,7 @@ export const make = Effect.gen(function* () {...@@ -168,7 +244,7 @@ export const make = Effect.gen(function* () {
168 },244 },
169 );245 );
170246
171 const cloneRepository = Effect.fn("SourceControlRepositoryService.cloneRepository")(function* (247 const prepareClone = Effect.fn("SourceControlRepositoryService.prepareClone")(function* (
172 input: SourceControlCloneRepositoryInput,248 input: SourceControlCloneRepositoryInput,
173 ) {249 ) {
174 const preparedDestination = yield* prepareDestination(input.destinationPath);250 const preparedDestination = yield* prepareDestination(input.destinationPath);
...@@ -194,21 +270,121 @@ export const make = Effect.gen(function* () {...@@ -194,21 +270,121 @@ export const make = Effect.gen(function* () {
194 });270 });
195 }271 }
196272
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 return {273 return {
206 cwd: preparedDestination.destinationPath,274 destinationPath: preparedDestination.destinationPath,
207 remoteUrl,275 remoteUrl: redactRemoteUrl(remoteUrl),
276 cloneUrl: remoteUrl,
208 repository,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 });
211338
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 const publishRepository = Effect.fn("SourceControlRepositoryService.publishRepository")(388 const publishRepository = Effect.fn("SourceControlRepositoryService.publishRepository")(
213 function* (input: SourceControlPublishRepositoryInput) {389 function* (input: SourceControlPublishRepositoryInput) {
214 const providerKind = yield* ensureConcreteProvider({390 const providerKind = yield* ensureConcreteProvider({
...@@ -269,10 +445,14 @@ export const make = Effect.gen(function* () {...@@ -269,10 +445,14 @@ export const make = Effect.gen(function* () {
269 return SourceControlRepositoryService.of({445 return SourceControlRepositoryService.of({
270 lookupRepository: (input) =>446 lookupRepository: (input) =>
271 lookupRepository(input).pipe(mapRepositoryError("lookupRepository", input.provider)),447 lookupRepository(input).pipe(mapRepositoryError("lookupRepository", input.provider)),
272 cloneRepository: (input) =>448 prepareClone: (input) =>
273 cloneRepository(input).pipe(449 prepareClone(input).pipe(mapRepositoryError("cloneRepository", input.provider ?? "unknown")),
450 cloneRepository: (input, options) =>
451 cloneRepository(input, options).pipe(
274 mapRepositoryError("cloneRepository", input.provider ?? "unknown"),452 mapRepositoryError("cloneRepository", input.provider ?? "unknown"),
275 ),453 ),
454 discardClone: (destinationPath) =>
455 discardClone(destinationPath).pipe(mapRepositoryError("discardClone", "unknown")),
276 publishRepository: (input) =>456 publishRepository: (input) =>
277 publishRepository(input).pipe(mapRepositoryError("publishRepository", input.provider)),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,6 +48,12 @@ export interface ExecuteGitInput {
48 readonly timeoutMs?: number | null;48 readonly timeoutMs?: number | null;
49 readonly maxOutputBytes?: number;49 readonly maxOutputBytes?: number;
50 readonly appendTruncationMarker?: boolean;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 readonly progress?: ExecuteGitProgress;57 readonly progress?: ExecuteGitProgress;
52}58}
5359
apps/server/src/vcs/GitVcsDriverCore.test.ts+32
...@@ -770,6 +770,38 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {...@@ -770,6 +770,38 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
770 }),770 }),
771 );771 );
772772
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 it.effect("recovers a structurally identified missing cwd as a non-repository", () =>805 it.effect("recovers a structurally identified missing cwd as a non-repository", () =>
774 Effect.gen(function* () {806 Effect.gen(function* () {
775 const parent = yield* makeTmpDir();807 const parent = yield* makeTmpDir();
apps/server/src/vcs/GitVcsDriverCore.ts+16-1
...@@ -666,12 +666,19 @@ const collectOutput = Effect.fnUntraced(function* (...@@ -666,12 +666,19 @@ const collectOutput = Effect.fnUntraced(function* (
666 maxOutputBytes: number,666 maxOutputBytes: number,
667 appendTruncationMarker: boolean,667 appendTruncationMarker: boolean,
668 onLine: ((line: string) => Effect.Effect<void, never>) | undefined,668 onLine: ((line: string) => Effect.Effect<void, never>) | undefined,
669 keepLineCallbacksAfterTruncation = false,
669): Effect.fn.Return<{ readonly text: string; readonly truncated: boolean }, GitCommandError> {670): Effect.fn.Return<{ readonly text: string; readonly truncated: boolean }, GitCommandError> {
670 const decoder = new TextDecoder();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 let bytes = 0;675 let bytes = 0;
672 let text = "";676 let text = "";
673 let lineBuffer = "";677 let lineBuffer = "";
674 let truncated = false;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;
675682
676 // Git redraws progress with a bare `\r` between updates and only ends the683 // Git redraws progress with a bare `\r` between updates and only ends the
677 // line once the step is done, so `\r` has to count as a line break here.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,6 +704,11 @@ const collectOutput = Effect.fnUntraced(function* (
697704
698 const processChunk = Effect.fnUntraced(function* (chunk: Uint8Array) {705 const processChunk = Effect.fnUntraced(function* (chunk: Uint8Array) {
699 if (appendTruncationMarker && truncated) {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 return;712 return;
701 }713 }
702 const nextBytes = bytes + chunk.byteLength;714 const nextBytes = bytes + chunk.byteLength;
...@@ -717,7 +729,7 @@ const collectOutput = Effect.fnUntraced(function* (...@@ -717,7 +729,7 @@ const collectOutput = Effect.fnUntraced(function* (
717729
718 const decoded = decoder.decode(chunkToDecode, { stream: !truncated });730 const decoded = decoder.decode(chunkToDecode, { stream: !truncated });
719 text += decoded;731 text += decoded;
720 lineBuffer += decoded;732 lineBuffer += lineDecoder ? lineDecoder.decode(chunk, { stream: true }) : decoded;
721 yield* emitCompleteLines(false);733 yield* emitCompleteLines(false);
722 });734 });
723735
...@@ -735,6 +747,7 @@ const collectOutput = Effect.fnUntraced(function* (...@@ -735,6 +747,7 @@ const collectOutput = Effect.fnUntraced(function* (
735 const remainder = truncated ? "" : decoder.decode();747 const remainder = truncated ? "" : decoder.decode();
736 text += remainder;748 text += remainder;
737 lineBuffer += remainder;749 lineBuffer += remainder;
750 if (lineDecoder) lineBuffer += lineDecoder.decode();
738 yield* emitCompleteLines(true);751 yield* emitCompleteLines(true);
739 return {752 return {
740 text,753 text,
...@@ -802,6 +815,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -802,6 +815,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
802 maxOutputBytes,815 maxOutputBytes,
803 appendTruncationMarker,816 appendTruncationMarker,
804 input.progress?.onStdoutLine,817 input.progress?.onStdoutLine,
818 input.keepLineCallbacksAfterTruncation,
805 ),819 ),
806 collectOutput(820 collectOutput(
807 commandInput,821 commandInput,
...@@ -809,6 +823,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*...@@ -809,6 +823,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
809 maxOutputBytes,823 maxOutputBytes,
810 appendTruncationMarker,824 appendTruncationMarker,
811 input.progress?.onStderrLine,825 input.progress?.onStderrLine,
826 input.keepLineCallbacksAfterTruncation,
812 ),827 ),
813 child.exitCode.pipe(828 child.exitCode.pipe(
814 Effect.mapError(829 Effect.mapError(
apps/server/src/ws.ts+78
...@@ -8,6 +8,7 @@ import * as DateTime from "effect/DateTime";...@@ -8,6 +8,7 @@ import * as DateTime from "effect/DateTime";
8import * as Duration from "effect/Duration";8import * as Duration from "effect/Duration";
9import * as Effect from "effect/Effect";9import * as Effect from "effect/Effect";
10import * as Fiber from "effect/Fiber";10import * as Fiber from "effect/Fiber";
11import * as FileSystem from "effect/FileSystem";
11import * as Layer from "effect/Layer";12import * as Layer from "effect/Layer";
12import * as Option from "effect/Option";13import * as Option from "effect/Option";
13import * as Path from "effect/Path";14import * as Path from "effect/Path";
...@@ -133,6 +134,8 @@ import * as GitWorkflowService from "./git/GitWorkflowService.ts";...@@ -133,6 +134,8 @@ import * as GitWorkflowService from "./git/GitWorkflowService.ts";
133import { linkCreatedPullRequest } from "./git/linkCreatedPullRequest.ts";134import { linkCreatedPullRequest } from "./git/linkCreatedPullRequest.ts";
134import * as ReviewService from "./review/ReviewService.ts";135import * as ReviewService from "./review/ReviewService.ts";
135import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts";136import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts";
137import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts";
138import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts";
136import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts";139import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts";
137import * as AgentSessionScanner from "./project/AgentSessionScanner.ts";140import * as AgentSessionScanner from "./project/AgentSessionScanner.ts";
138import { importRecentAgentThreads } from "./project/AgentSessionImporter.ts";141import { importRecentAgentThreads } from "./project/AgentSessionImporter.ts";
...@@ -605,6 +608,17 @@ const makeWsRpcLayer = (...@@ -605,6 +608,17 @@ const makeWsRpcLayer = (
605 });608 });
606 const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner;609 const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner;
607 const worktreeSetupTracker = yield* WorktreeSetupTracker.WorktreeSetupTracker;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 const agentSessionScanner = yield* AgentSessionScanner.AgentSessionScanner;622 const agentSessionScanner = yield* AgentSessionScanner.AgentSessionScanner;
609 const serverEnvironment = yield* ServerEnvironment.ServerEnvironment;623 const serverEnvironment = yield* ServerEnvironment.ServerEnvironment;
610 const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy;624 const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy;
...@@ -1615,6 +1629,7 @@ const makeWsRpcLayer = (...@@ -1615,6 +1629,7 @@ const makeWsRpcLayer = (
1615 observeRpcEffect(1629 observeRpcEffect(
1616 ORCHESTRATION_WS_METHODS.dispatchCommand,1630 ORCHESTRATION_WS_METHODS.dispatchCommand,
1617 Effect.gen(function* () {1631 Effect.gen(function* () {
1632 yield* ProjectCloneTracker.rejectCommandsDuringClone(projectCloneTracker, command);
1618 const normalizedCommand = yield* normalizeDispatchCommand(command);1633 const normalizedCommand = yield* normalizeDispatchCommand(command);
1619 // Archive removes the thread from the client, so this transport1634 // Archive removes the thread from the client, so this transport
1620 // closes its session and terminals after the command lands.1635 // closes its session and terminals after the command lands.
...@@ -1646,6 +1661,10 @@ const makeWsRpcLayer = (...@@ -1646,6 +1661,10 @@ const makeWsRpcLayer = (
1646 Effect.tapError(() => cleanupFailedUploadedAttachments(command, normalizedCommand)),1661 Effect.tapError(() => cleanupFailedUploadedAttachments(command, normalizedCommand)),
1647 );1662 );
1648 yield* recordClientCommandAnalytics(normalizedCommand);1663 yield* recordClientCommandAnalytics(normalizedCommand);
1664 yield* ProjectCloneTracker.discardCloneForDeletedProject(
1665 projectCloneTracker,
1666 normalizedCommand,
1667 );
1649 if (archiveCommand) {1668 if (archiveCommand) {
1650 if (shouldStopSessionAfterCommand) {1669 if (shouldStopSessionAfterCommand) {
1651 yield* Effect.gen(function* () {1670 yield* Effect.gen(function* () {
...@@ -2657,6 +2676,65 @@ const makeWsRpcLayer = (...@@ -2657,6 +2676,65 @@ const makeWsRpcLayer = (
2657 "rpc.aggregate": "source-control",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 [WS_METHODS.sourceControlPublishRepository]: (input) =>2738 [WS_METHODS.sourceControlPublishRepository]: (input) =>
2661 observeRpcEffect(2739 observeRpcEffect(
2662 WS_METHODS.sourceControlPublishRepository,2740 WS_METHODS.sourceControlPublishRepository,
apps/web/src/components/ChatView.tsx+117-1
...@@ -226,6 +226,7 @@ import {...@@ -226,6 +226,7 @@ import {
226 AlarmClockIcon,226 AlarmClockIcon,
227 CheckCircle2Icon,227 CheckCircle2Icon,
228 ChevronDownIcon,228 ChevronDownIcon,
229 DownloadIcon,
229 GitBranchIcon,230 GitBranchIcon,
230 Minimize2Icon,231 Minimize2Icon,
231 PaperclipIcon,232 PaperclipIcon,
...@@ -259,6 +260,7 @@ import {...@@ -259,6 +260,7 @@ import {
259import { useNowMinute } from "../hooks/useNowMinute";260import { useNowMinute } from "../hooks/useNowMinute";
260import { usePanelAnimationSettings, usePanelPresence } from "../panelAnimations";261import { usePanelAnimationSettings, usePanelPresence } from "../panelAnimations";
261import { useNewThreadHandler } from "../hooks/useHandleNewThread";262import { useNewThreadHandler } from "../hooks/useHandleNewThread";
263import { useRemoveClonedProject } from "../hooks/useRemoveClonedProject";
262import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl";264import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl";
263import { useThreadActions } from "../hooks/useThreadActions";265import { useThreadActions } from "../hooks/useThreadActions";
264import { resolveAppModelSelectionForInstance } from "../modelSelection";266import { resolveAppModelSelectionForInstance } from "../modelSelection";
...@@ -328,6 +330,9 @@ import {...@@ -328,6 +330,9 @@ import {
328} from "@t3tools/client-runtime/state/threads";330} from "@t3tools/client-runtime/state/threads";
329import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills";331import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills";
330import { vcsEnvironment } from "../state/vcs";332import { vcsEnvironment } from "../state/vcs";
333import { sourceControlEnvironment } from "../state/sourceControl";
334import { useProjectClone } from "../state/projectClones";
335import { projectCloneDisplayName, projectCloneProgressSummary } from "@t3tools/contracts";
331import { useEnvironments, usePrimaryEnvironment } from "../state/environments";336import { useEnvironments, usePrimaryEnvironment } from "../state/environments";
332import {337import {
333 useProject,338 useProject,
...@@ -2094,6 +2099,113 @@ export default function ChatView(props: ChatViewProps) {...@@ -2094,6 +2099,113 @@ export default function ChatView(props: ChatViewProps) {
2094 () => (activeProject ? resolveProjectScripts(settings, activeProject) : []),2099 () => (activeProject ? resolveProjectScripts(settings, activeProject) : []),
2095 [activeProject, settings],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 const activeProjectDefaultModelSelection = activeProjectSettings.settings.defaultModelSelection;2209 const activeProjectDefaultModelSelection = activeProjectSettings.settings.defaultModelSelection;
2098 const handleNewThreadInActiveProject = useCallback(() => {2210 const handleNewThreadInActiveProject = useCallback(() => {
2099 startNewThreadForProject(activeProjectRef, handleNewThread);2211 startNewThreadForProject(activeProjectRef, handleNewThread);
...@@ -6248,10 +6360,12 @@ export default function ChatView(props: ChatViewProps) {...@@ -6248,10 +6360,12 @@ export default function ChatView(props: ChatViewProps) {
6248 const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem];6360 const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem];
6249 // The user asked for this one, so it leads the notice tier instead of trailing it.6361 // The user asked for this one, so it leads the notice tier instead of trailing it.
6250 const usageLimitsItems = usageLimitsBanner === null ? [] : [usageLimitsBanner];6362 const usageLimitsItems = usageLimitsBanner === null ? [] : [usageLimitsBanner];
6363 const projectCloneItems = projectCloneBannerItem === null ? [] : [projectCloneBannerItem];
6251 if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) {6364 if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) {
6252 return [6365 return [
6253 ...feedbackBannerItems,6366 ...feedbackBannerItems,
6254 ...usageLimitsItems,6367 ...usageLimitsItems,
6368 ...projectCloneItems,
6255 ...systemComposerBannerItems,6369 ...systemComposerBannerItems,
6256 ...backgroundLivenessItems,6370 ...backgroundLivenessItems,
6257 ...resumeCompactionItems,6371 ...resumeCompactionItems,
...@@ -6262,6 +6376,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -6262,6 +6376,7 @@ export default function ChatView(props: ChatViewProps) {
6262 return [6376 return [
6263 ...feedbackBannerItems,6377 ...feedbackBannerItems,
6264 ...usageLimitsItems,6378 ...usageLimitsItems,
6379 ...projectCloneItems,
6265 ...systemComposerBannerItems,6380 ...systemComposerBannerItems,
6266 ...backgroundLivenessItems,6381 ...backgroundLivenessItems,
6267 ...resumeCompactionItems,6382 ...resumeCompactionItems,
...@@ -6314,6 +6429,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -6314,6 +6429,7 @@ export default function ChatView(props: ChatViewProps) {
6314 isRestoringThreadBranch,6429 isRestoringThreadBranch,
6315 localCheckoutBranchMismatch,6430 localCheckoutBranchMismatch,
6316 parkedThreadBannerItem,6431 parkedThreadBannerItem,
6432 projectCloneBannerItem,
6317 resumeCompactionBannerItem,6433 resumeCompactionBannerItem,
6318 showBranchMismatchBanner,6434 showBranchMismatchBanner,
6319 systemComposerBannerItems,6435 systemComposerBannerItems,
...@@ -9162,7 +9278,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -9162,7 +9278,7 @@ export default function ChatView(props: ChatViewProps) {
9162 ? "Sending feedback"9278 ? "Sending feedback"
9163 : threadDetailLoading9279 : threadDetailLoading
9164 ? "Messages loading"9280 ? "Messages loading"
9165 : null9281 : projectCloneSendBlockReason
9166 }9282 }
9167 isPreparingWorktree={isPreparingWorktree}9283 isPreparingWorktree={isPreparingWorktree}
9168 bannerItems={composerBannerItems}9284 bannerItems={composerBannerItems}
apps/web/src/components/CommandPalette.tsx+61-6
...@@ -84,7 +84,7 @@ import { sourceControlEnvironment } from "../state/sourceControl";...@@ -84,7 +84,7 @@ import { sourceControlEnvironment } from "../state/sourceControl";
84import { useAtomCommand } from "../state/use-atom-command";84import { useAtomCommand } from "../state/use-atom-command";
85import { useAtomQueryRunner } from "../state/use-atom-query-runner";85import { useAtomQueryRunner } from "../state/use-atom-query-runner";
86import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments";86import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments";
87import { useProjects, useServerConfigs, useThreadShells } from "../state/entities";87import { useProjects, useServerConfigs, useThreadShells, waitForProject } from "../state/entities";
88import { useThreadSearch } from "../state/queries";88import { useThreadSearch } from "../state/queries";
89import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions";89import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions";
90import {90import {
...@@ -643,6 +643,9 @@ function OpenCommandPaletteDialog(props: {...@@ -643,6 +643,9 @@ function OpenCommandPaletteDialog(props: {
643 const cloneRepository = useAtomCommand(sourceControlEnvironment.cloneRepository, {643 const cloneRepository = useAtomCommand(sourceControlEnvironment.cloneRepository, {
644 reportFailure: false,644 reportFailure: false,
645 });645 });
646 const startProjectClone = useAtomCommand(sourceControlEnvironment.startProjectClone, {
647 reportFailure: false,
648 });
646 const { environments } = useEnvironments();649 const { environments } = useEnvironments();
647 const desktopLocalBootstraps = useDesktopLocalBootstraps();650 const desktopLocalBootstraps = useDesktopLocalBootstraps();
648 const primaryEnvironmentId = usePrimaryEnvironmentId();651 const primaryEnvironmentId = usePrimaryEnvironmentId();
...@@ -2198,28 +2201,80 @@ function OpenCommandPaletteDialog(props: {...@@ -2198,28 +2201,80 @@ function OpenCommandPaletteDialog(props: {
2198 return;2201 return;
2199 }2202 }
22002203
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 setIsRemoteProjectCloning(true);2237 setIsRemoteProjectCloning(true);
2202 const cloneResult = await cloneRepository({2238 const startResult = await startProjectClone({
2203 environmentId: addProjectCloneFlow.environmentId,2239 environmentId: addProjectCloneFlow.environmentId,
2204 input: {2240 input: {
2241 projectId,
2242 title: inferProjectTitleFromPath(destinationPath),
2243 createdAt: new Date().toISOString(),
2205 remoteUrl: addProjectCloneFlow.remoteUrl,2244 remoteUrl: addProjectCloneFlow.remoteUrl,
2206 destinationPath,2245 destinationPath,
2207 },2246 },
2208 });2247 });
2209 setIsRemoteProjectCloning(false);2248 setIsRemoteProjectCloning(false);
2210 if (cloneResult._tag === "Failure") {2249 if (startResult._tag === "Failure") {
2211 if (!isAtomCommandInterrupted(cloneResult)) {2250 if (!isAtomCommandInterrupted(startResult)) {
2212 toastManager.add(2251 toastManager.add(
2213 stackedThreadToast({2252 stackedThreadToast({
2214 type: "error",2253 type: "error",
2215 title: "Clone failed",2254 title: "Clone failed",
2216 description: errorMessage(squashAtomCommandFailure(cloneResult)),2255 description: errorMessage(squashAtomCommandFailure(startResult)),
2217 }),2256 }),
2218 );2257 );
2219 }2258 }
2220 return;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 }
22242279
2225 const browseTo = useCallback(2280 const browseTo = useCallback(
apps/web/src/components/ProjectCloneToastCoordinator.tsx created+240
...@@ -0,0 +1,240 @@
1import { useParams } from "@tanstack/react-router";
2import { scopeProjectRef } from "@t3tools/client-runtime/environment";
3import {
4 type AtomCommandResult,
5 isAtomCommandInterrupted,
6 squashAtomCommandFailure,
7} from "@t3tools/client-runtime/state/runtime";
8import {
9 projectCloneDisplayName,
10 projectCloneProgressSummary,
11 type EnvironmentId,
12 type ProjectCloneSnapshot,
13 type ProjectId,
14} from "@t3tools/contracts";
15import { useCallback, useEffect, useRef } from "react";
16
17import { useNewThreadHandler } from "../hooks/useHandleNewThread";
18import { useRemoveClonedProject } from "../hooks/useRemoveClonedProject";
19import { useEnvironments } from "../state/environments";
20import { useEnvironmentProjectClones } from "../state/projectClones";
21import { sourceControlEnvironment } from "../state/sourceControl";
22import { useAtomCommand } from "../state/use-atom-command";
23import { type DraftId, useComposerDraftStore } from "../composerDraftStore";
24import { toastManager } from "./ui/toast";
25import { 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 */
33export 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
43interface 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
50function renderKey(clone: ProjectCloneSnapshot): string {
51 return `${clone.phase}:${clone.stage}:${clone.percent ?? ""}:${clone.detail ?? ""}:${clone.error ?? ""}`;
52}
53
54function 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 @@
1import { useRouter } from "@tanstack/react-router";
2import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
3import type { ScopedProjectRef } from "@t3tools/contracts";
4import { useCallback } from "react";
5
6import { useComposerDraftStore } from "../composerDraftStore";
7import { resolveThreadRouteTarget } from "../threadRoutes";
8import { releaseProjectDraftUploads } from "../lib/composerDraftUploads";
9import { projectEnvironment } from "../state/projects";
10import { useAtomCommand } from "../state/use-atom-command";
11import { 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 */
19export 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,6 +27,7 @@ import { SnapShotCoordinator } from "../components/desktop/SnapShotCoordinator";
27import { DesktopAppActivationCoordinator } from "../components/desktop/DesktopAppActivationCoordinator";27import { DesktopAppActivationCoordinator } from "../components/desktop/DesktopAppActivationCoordinator";
28import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification";28import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification";
29import { ThreadNotificationCoordinator } from "../components/ThreadNotificationCoordinator";29import { ThreadNotificationCoordinator } from "../components/ThreadNotificationCoordinator";
30import { ProjectCloneToastCoordinator } from "../components/ProjectCloneToastCoordinator";
30import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator";31import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator";
31import { ThemeEditorHost } from "../components/settings/ThemeEditorHost";32import { ThemeEditorHost } from "../components/settings/ThemeEditorHost";
32import { useCopyToClipboard } from "../hooks/useCopyToClipboard";33import { useCopyToClipboard } from "../hooks/useCopyToClipboard";
...@@ -222,6 +223,7 @@ function RootRouteView() {...@@ -222,6 +223,7 @@ function RootRouteView() {
222 <ThreadNotificationCoordinator />223 <ThreadNotificationCoordinator />
223 <ConfirmDialogHost />224 <ConfirmDialogHost />
224 <SlowRpcRequestToastCoordinator />225 <SlowRpcRequestToastCoordinator />
226 <ProjectCloneToastCoordinator />
225 <HostedStaticEnvironmentBootstrap />227 <HostedStaticEnvironmentBootstrap />
226 {primaryEnvironmentAuthenticated ? (228 {primaryEnvironmentAuthenticated ? (
227 <EventRouter skipInitialBootstrapNavigation={returningFromWelcomeRef.current} />229 <EventRouter skipInitialBootstrapNavigation={returningFromWelcomeRef.current} />
apps/web/src/state/projectClones.ts created+52
...@@ -0,0 +1,52 @@
1import { useAtomValue } from "@effect/atom-react";
2import { parseScopedProjectKey, scopedProjectKey } from "@t3tools/client-runtime/environment";
3import type { EnvironmentId, ProjectCloneSnapshot, ScopedProjectRef } from "@t3tools/contracts";
4import * as Option from "effect/Option";
5import { AsyncResult, Atom } from "effect/unstable/reactivity";
6
7import { environmentServerConfigsAtom } from "./server";
8import { sourceControlEnvironment } from "./sourceControl";
9
10const EMPTY_CLONES: ReadonlyArray<ProjectCloneSnapshot> = [];
11const 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 */
19const 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
30const 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 */
44export function useProjectClone(ref: ScopedProjectRef | null): ProjectCloneSnapshot | null {
45 return useAtomValue(ref === null ? EMPTY_CLONE_ATOM : projectCloneAtom(scopedProjectKey(ref)));
46}
47
48export 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,7 +76,10 @@ az login
76## Clone or publish a project76## Clone or publish a project
7777
78Use **Add Project** in the command palette (`Cmd/Ctrl+K`) to clone a repository. Choose a hosting78Use **Add Project** in the command palette (`Cmd/Ctrl+K`) to clone a repository. Choose a hosting
79provider or paste a Git URL, then choose where to save it.79provider or paste a Git URL, then choose where to save it. The project opens right away while the
80clone runs in the background: you can write your first prompt, and sending waits until the files
81are in place. A toast tracks progress and lets you cancel; if the clone fails, retry it from the
82toast or from the banner above the composer.
8083
81For a local Git repository without a remote, **Publish Repository** creates a hosted repository,84For a local Git repository without a remote, **Publish Repository** creates a hosted repository,
82adds it as `origin`, and pushes your commits. If there are no commits yet, it creates the remote;85adds 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,6 +57,7 @@ export type EnvironmentSubscriptionRpcTag =
57 | typeof WS_METHODS.previewAutomationConnect57 | typeof WS_METHODS.previewAutomationConnect
58 | typeof WS_METHODS.subscribeVcsStatus58 | typeof WS_METHODS.subscribeVcsStatus
59 | typeof WS_METHODS.subscribeWorktreeSetup59 | typeof WS_METHODS.subscribeWorktreeSetup
60 | typeof WS_METHODS.subscribeProjectClones
60 | typeof WS_METHODS.terminalAttach;61 | typeof WS_METHODS.terminalAttach;
6162
62export type EnvironmentStreamCommandRpcTag =63export type EnvironmentStreamCommandRpcTag =
packages/client-runtime/src/state/sourceControl.ts+38
...@@ -5,6 +5,7 @@ import {...@@ -5,6 +5,7 @@ import {
5 createAtomCommandScheduler,5 createAtomCommandScheduler,
6 createEnvironmentRpcCommand,6 createEnvironmentRpcCommand,
7 createEnvironmentRpcQueryAtomFamily,7 createEnvironmentRpcQueryAtomFamily,
8 createEnvironmentRpcSubscriptionAtomFamily,
8} from "./runtime.ts";9} from "./runtime.ts";
9import type { EnvironmentRegistry } from "../connection/registry.ts";10import type { EnvironmentRegistry } from "../connection/registry.ts";
10import { EnvironmentCacheStore } from "../platform/persistence.ts";11import { EnvironmentCacheStore } from "../platform/persistence.ts";
...@@ -33,6 +34,43 @@ export function createSourceControlEnvironmentAtoms<R, E>(...@@ -33,6 +34,43 @@ export function createSourceControlEnvironmentAtoms<R, E>(
33 key: ({ environmentId }) => environmentId,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 publishRepository: createEnvironmentRpcCommand(runtime, {74 publishRepository: createEnvironmentRpcCommand(runtime, {
37 label: "environment-data:source-control:publish-repository",75 label: "environment-data:source-control:publish-repository",
38 tag: WS_METHODS.sourceControlPublishRepository,76 tag: WS_METHODS.sourceControlPublishRepository,
packages/contracts/src/environment.ts+5
...@@ -153,6 +153,11 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({...@@ -153,6 +153,11 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
153 this is false — no update would ever repaint it. Absent on older153 this is false — no update would ever repaint it. Absent on older
154 servers, which may still publish, so only an explicit false skips. */154 servers, which may still publish, so only an explicit false skips. */
155 agentActivityPublishing: Schema.optionalKey(Schema.Boolean),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 /** Server detects `platform.machine` and persists the `environmentIcon`161 /** Server detects `platform.machine` and persists the `environmentIcon`
157 setting. Older servers drop the key on write, so clients show the162 setting. Older servers drop the key on write, so clients show the
158 picker inert rather than offering a choice that would never stick. */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,6 +25,7 @@ export * from "./settings.ts";
25export * from "./git.ts";25export * from "./git.ts";
26export * from "./vcs.ts";26export * from "./vcs.ts";
27export * from "./sourceControl.ts";27export * from "./sourceControl.ts";
28export * from "./projectClone.ts";
28export * from "./pullRequest.ts";29export * from "./pullRequest.ts";
29export * from "./orchestration.ts";30export * from "./orchestration.ts";
30export * from "./t3ProjectFile.ts";31export * from "./t3ProjectFile.ts";
packages/contracts/src/projectClone.ts created+122
...@@ -0,0 +1,122 @@
1import * as Schema from "effect/Schema";
2
3import { IsoDateTime, NonNegativeInt, ProjectId, TrimmedNonEmptyString } from "./baseSchemas.ts";
4import {
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. */
18export const PROJECT_CLONE_DETAIL_MAX_LENGTH = 200;
19export const PROJECT_CLONE_ERROR_MAX_LENGTH = 1000;
20
21/** Follows git's own clone phases as they appear on stderr, in order. */
22export const ProjectCloneStage = Schema.Literals([
23 "connecting",
24 "counting",
25 "receiving",
26 "resolving",
27 "checkout",
28]);
29export type ProjectCloneStage = typeof ProjectCloneStage.Type;
30
31export const ProjectClonePhase = Schema.Literals(["running", "done", "failed", "cancelled"]);
32export type ProjectClonePhase = typeof ProjectClonePhase.Type;
33
34export 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});
51export type ProjectCloneSnapshot = typeof ProjectCloneSnapshot.Type;
52
53export const ProjectCloneSubscribeInput = Schema.Struct({});
54export type ProjectCloneSubscribeInput = typeof ProjectCloneSubscribeInput.Type;
55
56/** Every tracked clone on the environment. Sent first, then after every change. */
57export const ProjectCloneListEvent = Schema.Array(ProjectCloneSnapshot);
58export type ProjectCloneListEvent = typeof ProjectCloneListEvent.Type;
59
60export 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});
70export type ProjectCloneStartInput = typeof ProjectCloneStartInput.Type;
71
72export const ProjectCloneStartResult = Schema.Struct({
73 projectId: ProjectId,
74 cwd: TrimmedNonEmptyString,
75 remoteUrl: TrimmedNonEmptyString,
76 repository: Schema.NullOr(SourceControlRepositoryInfo),
77});
78export type ProjectCloneStartResult = typeof ProjectCloneStartResult.Type;
79
80export const ProjectCloneActionInput = Schema.Struct({
81 projectId: ProjectId,
82});
83export type ProjectCloneActionInput = typeof ProjectCloneActionInput.Type;
84
85export const ProjectCloneActionResult = Schema.Struct({
86 applied: Schema.Boolean,
87});
88export type ProjectCloneActionResult = typeof ProjectCloneActionResult.Type;
89
90function 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. */
106export 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`. */
115export 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,6 +249,14 @@ import {
249} from "./providerUsageLimits.ts";249} from "./providerUsageLimits.ts";
250import { UsagePricing, UsageReadError, UsageSummary, UsageSummaryInput } from "./usage.ts";250import { UsagePricing, UsageReadError, UsageSummary, UsageSummaryInput } from "./usage.ts";
251import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts";251import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts";
252import {
253 ProjectCloneActionInput,
254 ProjectCloneActionResult,
255 ProjectCloneListEvent,
256 ProjectCloneStartInput,
257 ProjectCloneStartResult,
258 ProjectCloneSubscribeInput,
259} from "./projectClone.ts";
252import {260import {
253 SourceControlCloneRepositoryInput,261 SourceControlCloneRepositoryInput,
254 SourceControlCloneRepositoryResult,262 SourceControlCloneRepositoryResult,
...@@ -407,6 +415,10 @@ export const WS_METHODS = {...@@ -407,6 +415,10 @@ export const WS_METHODS = {
407 sourceControlLookupRepository: "sourceControl.lookupRepository",415 sourceControlLookupRepository: "sourceControl.lookupRepository",
408 sourceControlCloneRepository: "sourceControl.cloneRepository",416 sourceControlCloneRepository: "sourceControl.cloneRepository",
409 sourceControlPublishRepository: "sourceControl.publishRepository",417 sourceControlPublishRepository: "sourceControl.publishRepository",
418 projectCloneStart: "projectClone.start",
419 projectCloneCancel: "projectClone.cancel",
420 projectCloneRetry: "projectClone.retry",
421 subscribeProjectClones: "subscribeProjectClones",
410422
411 // Streaming subscriptions423 // Streaming subscriptions
412 subscribeVcsStatus: "subscribeVcsStatus",424 subscribeVcsStatus: "subscribeVcsStatus",
...@@ -842,6 +854,37 @@ const WsSourceControlCloneRepositoryRpc = Rpc.make(WS_METHODS.sourceControlClone...@@ -842,6 +854,37 @@ const WsSourceControlCloneRepositoryRpc = Rpc.make(WS_METHODS.sourceControlClone
842 error: Schema.Union([SourceControlRepositoryError, EnvironmentAuthorizationError]),854 error: Schema.Union([SourceControlRepositoryError, EnvironmentAuthorizationError]),
843});855});
844856
857// Clone-backed project creation. `start` returns once the project exists and
858// the clone is running; progress arrives on the subscription.
859const 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
869const WsProjectCloneCancelRpc = Rpc.make(WS_METHODS.projectCloneCancel, {
870 payload: ProjectCloneActionInput,
871 success: ProjectCloneActionResult,
872 error: EnvironmentAuthorizationError,
873});
874
875const WsProjectCloneRetryRpc = Rpc.make(WS_METHODS.projectCloneRetry, {
876 payload: ProjectCloneActionInput,
877 success: ProjectCloneActionResult,
878 error: Schema.Union([SourceControlRepositoryError, EnvironmentAuthorizationError]),
879});
880
881const WsSubscribeProjectClonesRpc = Rpc.make(WS_METHODS.subscribeProjectClones, {
882 payload: ProjectCloneSubscribeInput,
883 success: ProjectCloneListEvent,
884 error: EnvironmentAuthorizationError,
885 stream: true,
886});
887
845const WsSourceControlPublishRepositoryRpc = Rpc.make(WS_METHODS.sourceControlPublishRepository, {888const WsSourceControlPublishRepositoryRpc = Rpc.make(WS_METHODS.sourceControlPublishRepository, {
846 payload: SourceControlPublishRepositoryInput,889 payload: SourceControlPublishRepositoryInput,
847 success: SourceControlPublishRepositoryResult,890 success: SourceControlPublishRepositoryResult,
...@@ -1379,6 +1422,10 @@ export const WsRpcGroup = RpcGroup.make(...@@ -1379,6 +1422,10 @@ export const WsRpcGroup = RpcGroup.make(
1379 WsSourceControlLookupRepositoryRpc,1422 WsSourceControlLookupRepositoryRpc,
1380 WsSourceControlCloneRepositoryRpc,1423 WsSourceControlCloneRepositoryRpc,
1381 WsSourceControlPublishRepositoryRpc,1424 WsSourceControlPublishRepositoryRpc,
1425 WsProjectCloneStartRpc,
1426 WsProjectCloneCancelRpc,
1427 WsProjectCloneRetryRpc,
1428 WsSubscribeProjectClonesRpc,
1382 WsProjectsListEntriesRpc,1429 WsProjectsListEntriesRpc,
1383 WsProjectsReadFileRpc,1430 WsProjectsReadFileRpc,
1384 WsProjectsSearchContentsRpc,1431 WsProjectsSearchContentsRpc,