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 = {
9797 [WS_METHODS.sourceControlLookupRepository]: AuthOrchestrationReadScope,
9898 [WS_METHODS.sourceControlCloneRepository]: AuthOrchestrationOperateScope,
9999 [WS_METHODS.sourceControlPublishRepository]: AuthOrchestrationOperateScope,
100 [WS_METHODS.projectCloneStart]: AuthOrchestrationOperateScope,
101 [WS_METHODS.projectCloneCancel]: AuthOrchestrationOperateScope,
102 [WS_METHODS.projectCloneRetry]: AuthOrchestrationOperateScope,
103 [WS_METHODS.subscribeProjectClones]: AuthOrchestrationReadScope,
100104 [WS_METHODS.projectsListEntries]: AuthOrchestrationReadScope,
101105 [WS_METHODS.projectsReadFile]: AuthOrchestrationReadScope,
102106 [WS_METHODS.projectsSearchContents]: AuthOrchestrationReadScope,
apps/server/src/bin.test.ts+11-1
......@@ -39,6 +39,7 @@ import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSna
3939import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts";
4040import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts";
4141import { orchestrationHttpApiLayer } from "./orchestration/http.ts";
42import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts";
4243import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts";
4344import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts";
4445import {
......@@ -363,7 +364,16 @@ const withLiveProjectCliServer = <A, E, R>(baseDir: string, run: () => Effect.Ef
363364 Effect.gen(function* () {
364365 const config = yield* makeCliTestServerConfig(baseDir);
365366 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 ),
367377 Layer.provide(environmentAuthenticatedAuthLayer),
368378 );
369379 const appLayer = HttpRouter.serve(routesLayer, {
apps/server/src/environment/ServerEnvironment.ts+1
......@@ -236,6 +236,7 @@ export const make = Effect.gen(function* () {
236236 pullRequestStackActions: true,
237237 threadPullRequestLinking: true,
238238 environmentIcon: true,
239 projectCloneTracking: true,
239240 ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }),
240241 ...(serverSelfUpdate === "boot-service" || desktopAppUpdate
241242 ? {
apps/server/src/orchestration/http.ts+16-1
......@@ -16,6 +16,7 @@ import {
1616 failEnvironmentNotFound,
1717 requireEnvironmentScope,
1818} from "../auth/http.ts";
19import * as ProjectCloneTracker from "../project/ProjectCloneTracker.ts";
1920import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts";
2021import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts";
2122
......@@ -25,6 +26,7 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group(
2526 Effect.fnUntraced(function* (handlers) {
2627 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
2728 const orchestrationEngine = yield* OrchestrationEngineService;
29 const projectCloneTracker = yield* ProjectCloneTracker.ProjectCloneTracker;
2830
2931 return handlers
3032 .handle(
......@@ -93,10 +95,18 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group(
9395 Effect.fn("environment.orchestration.dispatch")(function* (args) {
9496 yield* annotateEnvironmentRequest(args.endpoint.name);
9597 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 );
96106 const normalizedCommand = yield* normalizeDispatchCommand(args.payload).pipe(
97107 Effect.catch(() => failEnvironmentInvalidRequest("invalid_command")),
98108 );
99 return yield* orchestrationEngine.dispatch(normalizedCommand).pipe(
109 const result = yield* orchestrationEngine.dispatch(normalizedCommand).pipe(
100110 Effect.tapError(() =>
101111 cleanupFailedUploadedAttachments(args.payload, normalizedCommand),
102112 ),
......@@ -104,6 +114,11 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group(
104114 failEnvironmentInternal("orchestration_dispatch_failed", cause),
105115 ),
106116 );
117 yield* ProjectCloneTracker.discardCloneForDeletedProject(
118 projectCloneTracker,
119 normalizedCommand,
120 );
121 return result;
107122 }),
108123 );
109124 }),
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";
148148import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts";
149149import * as ServerSettings from "./serverSettings.ts";
150150import * as TerminalManager from "./terminal/Manager.ts";
151import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts";
151152import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts";
152153import * as PreviewManager from "./preview/Manager.ts";
153154import * as PortScanner from "./preview/PortScanner.ts";
......@@ -933,6 +934,13 @@ const buildAppUnderTest = (options?: {
933934 ...options?.layers?.terminalManager,
934935 }),
935936 WorktreeSetupTracker.layer,
937 ProjectCloneTracker.layer.pipe(
938 Layer.provide(
939 Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({
940 ...options?.layers?.sourceControlRepositoryService,
941 }),
942 ),
943 ),
936944 ),
937945 ),
938946 Layer.provide(
......@@ -7238,6 +7246,106 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
72387246 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
72397247 );
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
72417349 it.effect("records thread analytics only after a client command succeeds", () =>
72427350 Effect.gen(function* () {
72437351 const effects: string[] = [];
apps/server/src/server.ts+6
......@@ -103,6 +103,7 @@ import * as VcsProjectConfig from "./vcs/VcsProjectConfig.ts";
103103import * as VcsProcess from "./vcs/VcsProcess.ts";
104104import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts";
105105import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts";
106import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts";
106107import * as GitWorkflowService from "./git/GitWorkflowService.ts";
107108import * as ReviewService from "./review/ReviewService.ts";
108109import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts";
......@@ -354,6 +355,10 @@ const SourceControlRepositoryServiceLayerLive = SourceControlRepositoryService.l
354355 Layer.provideMerge(SourceControlProviderRegistryLayerLive),
355356);
356357
358const ProjectCloneTrackerLayerLive = ProjectCloneTracker.layer.pipe(
359 Layer.provide(SourceControlRepositoryServiceLayerLive),
360);
361
357362const ReviewLayerLive = ReviewService.layer.pipe(
358363 Layer.provideMerge(GitVcsDriver.layer),
359364 Layer.provideMerge(VcsDriverRegistryLayerLive),
......@@ -366,6 +371,7 @@ const VcsLayerLive = Layer.empty.pipe(
366371 Layer.provideMerge(GitWorkflowLayerLive),
367372 Layer.provideMerge(ReviewLayerLive),
368373 Layer.provideMerge(SourceControlRepositoryServiceLayerLive),
374 Layer.provideMerge(ProjectCloneTrackerLayerLive),
369375 Layer.provideMerge(
370376 VcsStatusBroadcaster.layer.pipe(
371377 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", () =>
178178 assert.deepStrictEqual(cloneCalls, [
179179 {
180180 cwd: parent,
181 args: ["clone", CLONE_URLS.url, "t3code"],
181 args: ["clone", "--progress", CLONE_URLS.url, "t3code"],
182182 },
183183 ]);
184184 }).pipe(
......@@ -197,6 +197,154 @@ it.effect("clones a looked-up repository into the requested destination", () =>
197197 }).pipe(Effect.provide(NodeServices.layer)),
198198);
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
200348it.effect("preserves destination probe failures instead of treating them as missing paths", () => {
201349 const fileSystemCause = PlatformError.systemError({
202350 _tag: "PermissionDenied",
apps/server/src/sourceControl/SourceControlRepositoryService.ts+193-13
......@@ -3,6 +3,7 @@ import * as Effect from "effect/Effect";
33import * as FileSystem from "effect/FileSystem";
44import * as Layer from "effect/Layer";
55import * as Path from "effect/Path";
6import * as Schedule from "effect/Schedule";
67import * as Schema from "effect/Schema";
78
89import {
......@@ -20,6 +21,10 @@ import {
2021
2122import { ServerConfig } from "../config.ts";
2223import { expandHomePathWith } from "../pathExpansion.ts";
24import {
25 parseGitCloneProgressLine,
26 type GitCloneProgressLine,
27} from "../project/gitCloneProgress.ts";
2328import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
2429import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
2530const isSourceControlRepositoryError = Schema.is(SourceControlRepositoryError);
......@@ -30,15 +35,56 @@ export class SourceControlRepositoryService extends Context.Service<
3035 readonly lookupRepository: (
3136 input: SourceControlRepositoryLookupInput,
3237 ) => 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>;
3346 readonly cloneRepository: (
3447 input: SourceControlCloneRepositoryInput,
48 options?: SourceControlCloneOptions,
3549 ) => 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>;
3654 readonly publishRepository: (
3755 input: SourceControlPublishRepositoryInput,
3856 ) => Effect.Effect<SourceControlPublishRepositoryResult, SourceControlRepositoryError>;
3957 }
4058>()("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
4288function mapRepositoryError(operation: string, provider: SourceControlProviderKind) {
4389 return Effect.mapError((cause: unknown) =>
4490 isSourceControlRepositoryError(cause)
......@@ -64,6 +110,36 @@ function toRepositoryInfo(
64110 };
65111}
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
67143function selectRemoteUrl(
68144 urls: SourceControlRepositoryCloneUrls,
69145 protocol: SourceControlCloneProtocol | undefined,
......@@ -168,7 +244,7 @@ export const make = Effect.gen(function* () {
168244 },
169245 );
170246
171 const cloneRepository = Effect.fn("SourceControlRepositoryService.cloneRepository")(function* (
247 const prepareClone = Effect.fn("SourceControlRepositoryService.prepareClone")(function* (
172248 input: SourceControlCloneRepositoryInput,
173249 ) {
174250 const preparedDestination = yield* prepareDestination(input.destinationPath);
......@@ -194,21 +270,121 @@ export const make = Effect.gen(function* () {
194270 });
195271 }
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
205273 return {
206 cwd: preparedDestination.destinationPath,
207 remoteUrl,
274 destinationPath: preparedDestination.destinationPath,
275 remoteUrl: redactRemoteUrl(remoteUrl),
276 cloneUrl: remoteUrl,
208277 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,
209336 };
210337 });
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
212388 const publishRepository = Effect.fn("SourceControlRepositoryService.publishRepository")(
213389 function* (input: SourceControlPublishRepositoryInput) {
214390 const providerKind = yield* ensureConcreteProvider({
......@@ -269,10 +445,14 @@ export const make = Effect.gen(function* () {
269445 return SourceControlRepositoryService.of({
270446 lookupRepository: (input) =>
271447 lookupRepository(input).pipe(mapRepositoryError("lookupRepository", input.provider)),
272 cloneRepository: (input) =>
273 cloneRepository(input).pipe(
448 prepareClone: (input) =>
449 prepareClone(input).pipe(mapRepositoryError("cloneRepository", input.provider ?? "unknown")),
450 cloneRepository: (input, options) =>
451 cloneRepository(input, options).pipe(
274452 mapRepositoryError("cloneRepository", input.provider ?? "unknown"),
275453 ),
454 discardClone: (destinationPath) =>
455 discardClone(destinationPath).pipe(mapRepositoryError("discardClone", "unknown")),
276456 publishRepository: (input) =>
277457 publishRepository(input).pipe(mapRepositoryError("publishRepository", input.provider)),
278458 });
apps/server/src/vcs/GitVcsDriver.ts+6
......@@ -48,6 +48,12 @@ export interface ExecuteGitInput {
4848 readonly timeoutMs?: number | null;
4949 readonly maxOutputBytes?: number;
5050 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;
5157 readonly progress?: ExecuteGitProgress;
5258}
5359
apps/server/src/vcs/GitVcsDriverCore.test.ts+32
......@@ -770,6 +770,38 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
770770 }),
771771 );
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
773805 it.effect("recovers a structurally identified missing cwd as a non-repository", () =>
774806 Effect.gen(function* () {
775807 const parent = yield* makeTmpDir();
apps/server/src/vcs/GitVcsDriverCore.ts+16-1
......@@ -666,12 +666,19 @@ const collectOutput = Effect.fnUntraced(function* (
666666 maxOutputBytes: number,
667667 appendTruncationMarker: boolean,
668668 onLine: ((line: string) => Effect.Effect<void, never>) | undefined,
669 keepLineCallbacksAfterTruncation = false,
669670): Effect.fn.Return<{ readonly text: string; readonly truncated: boolean }, GitCommandError> {
670671 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;
671675 let bytes = 0;
672676 let text = "";
673677 let lineBuffer = "";
674678 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
676683 // Git redraws progress with a bare `\r` between updates and only ends the
677684 // 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* (
697704
698705 const processChunk = Effect.fnUntraced(function* (chunk: Uint8Array) {
699706 if (appendTruncationMarker && truncated) {
707 if (lineDecoder) {
708 lineBuffer += lineDecoder.decode(chunk, { stream: true });
709 yield* emitCompleteLines(false);
710 if (lineBuffer.length > maxPendingLineBytes) lineBuffer = "";
711 }
700712 return;
701713 }
702714 const nextBytes = bytes + chunk.byteLength;
......@@ -717,7 +729,7 @@ const collectOutput = Effect.fnUntraced(function* (
717729
718730 const decoded = decoder.decode(chunkToDecode, { stream: !truncated });
719731 text += decoded;
720 lineBuffer += decoded;
732 lineBuffer += lineDecoder ? lineDecoder.decode(chunk, { stream: true }) : decoded;
721733 yield* emitCompleteLines(false);
722734 });
723735
......@@ -735,6 +747,7 @@ const collectOutput = Effect.fnUntraced(function* (
735747 const remainder = truncated ? "" : decoder.decode();
736748 text += remainder;
737749 lineBuffer += remainder;
750 if (lineDecoder) lineBuffer += lineDecoder.decode();
738751 yield* emitCompleteLines(true);
739752 return {
740753 text,
......@@ -802,6 +815,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
802815 maxOutputBytes,
803816 appendTruncationMarker,
804817 input.progress?.onStdoutLine,
818 input.keepLineCallbacksAfterTruncation,
805819 ),
806820 collectOutput(
807821 commandInput,
......@@ -809,6 +823,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
809823 maxOutputBytes,
810824 appendTruncationMarker,
811825 input.progress?.onStderrLine,
826 input.keepLineCallbacksAfterTruncation,
812827 ),
813828 child.exitCode.pipe(
814829 Effect.mapError(
apps/server/src/ws.ts+78
......@@ -8,6 +8,7 @@ import * as DateTime from "effect/DateTime";
88import * as Duration from "effect/Duration";
99import * as Effect from "effect/Effect";
1010import * as Fiber from "effect/Fiber";
11import * as FileSystem from "effect/FileSystem";
1112import * as Layer from "effect/Layer";
1213import * as Option from "effect/Option";
1314import * as Path from "effect/Path";
......@@ -133,6 +134,8 @@ import * as GitWorkflowService from "./git/GitWorkflowService.ts";
133134import { linkCreatedPullRequest } from "./git/linkCreatedPullRequest.ts";
134135import * as ReviewService from "./review/ReviewService.ts";
135136import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts";
137import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts";
138import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts";
136139import * as WorktreeSetupTracker from "./project/WorktreeSetupTracker.ts";
137140import * as AgentSessionScanner from "./project/AgentSessionScanner.ts";
138141import { importRecentAgentThreads } from "./project/AgentSessionImporter.ts";
......@@ -605,6 +608,17 @@ const makeWsRpcLayer = (
605608 });
606609 const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner;
607610 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 >();
608622 const agentSessionScanner = yield* AgentSessionScanner.AgentSessionScanner;
609623 const serverEnvironment = yield* ServerEnvironment.ServerEnvironment;
610624 const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy;
......@@ -1615,6 +1629,7 @@ const makeWsRpcLayer = (
16151629 observeRpcEffect(
16161630 ORCHESTRATION_WS_METHODS.dispatchCommand,
16171631 Effect.gen(function* () {
1632 yield* ProjectCloneTracker.rejectCommandsDuringClone(projectCloneTracker, command);
16181633 const normalizedCommand = yield* normalizeDispatchCommand(command);
16191634 // Archive removes the thread from the client, so this transport
16201635 // closes its session and terminals after the command lands.
......@@ -1646,6 +1661,10 @@ const makeWsRpcLayer = (
16461661 Effect.tapError(() => cleanupFailedUploadedAttachments(command, normalizedCommand)),
16471662 );
16481663 yield* recordClientCommandAnalytics(normalizedCommand);
1664 yield* ProjectCloneTracker.discardCloneForDeletedProject(
1665 projectCloneTracker,
1666 normalizedCommand,
1667 );
16491668 if (archiveCommand) {
16501669 if (shouldStopSessionAfterCommand) {
16511670 yield* Effect.gen(function* () {
......@@ -2657,6 +2676,65 @@ const makeWsRpcLayer = (
26572676 "rpc.aggregate": "source-control",
26582677 },
26592678 ),
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 }),
26602738 [WS_METHODS.sourceControlPublishRepository]: (input) =>
26612739 observeRpcEffect(
26622740 WS_METHODS.sourceControlPublishRepository,
apps/web/src/components/ChatView.tsx+117-1
......@@ -226,6 +226,7 @@ import {
226226 AlarmClockIcon,
227227 CheckCircle2Icon,
228228 ChevronDownIcon,
229 DownloadIcon,
229230 GitBranchIcon,
230231 Minimize2Icon,
231232 PaperclipIcon,
......@@ -259,6 +260,7 @@ import {
259260import { useNowMinute } from "../hooks/useNowMinute";
260261import { usePanelAnimationSettings, usePanelPresence } from "../panelAnimations";
261262import { useNewThreadHandler } from "../hooks/useHandleNewThread";
263import { useRemoveClonedProject } from "../hooks/useRemoveClonedProject";
262264import { useOpenPanelPullRequestUrl } from "../hooks/useOpenPanelPullRequestUrl";
263265import { useThreadActions } from "../hooks/useThreadActions";
264266import { resolveAppModelSelectionForInstance } from "../modelSelection";
......@@ -328,6 +330,9 @@ import {
328330} from "@t3tools/client-runtime/state/threads";
329331import { resolveProviderSkillsForCwd } from "@t3tools/client-runtime/providerSkills";
330332import { vcsEnvironment } from "../state/vcs";
333import { sourceControlEnvironment } from "../state/sourceControl";
334import { useProjectClone } from "../state/projectClones";
335import { projectCloneDisplayName, projectCloneProgressSummary } from "@t3tools/contracts";
331336import { useEnvironments, usePrimaryEnvironment } from "../state/environments";
332337import {
333338 useProject,
......@@ -2094,6 +2099,113 @@ export default function ChatView(props: ChatViewProps) {
20942099 () => (activeProject ? resolveProjectScripts(settings, activeProject) : []),
20952100 [activeProject, settings],
20962101 );
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 ]);
20972209 const activeProjectDefaultModelSelection = activeProjectSettings.settings.defaultModelSelection;
20982210 const handleNewThreadInActiveProject = useCallback(() => {
20992211 startNewThreadForProject(activeProjectRef, handleNewThread);
......@@ -6248,10 +6360,12 @@ export default function ChatView(props: ChatViewProps) {
62486360 const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem];
62496361 // The user asked for this one, so it leads the notice tier instead of trailing it.
62506362 const usageLimitsItems = usageLimitsBanner === null ? [] : [usageLimitsBanner];
6363 const projectCloneItems = projectCloneBannerItem === null ? [] : [projectCloneBannerItem];
62516364 if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) {
62526365 return [
62536366 ...feedbackBannerItems,
62546367 ...usageLimitsItems,
6368 ...projectCloneItems,
62556369 ...systemComposerBannerItems,
62566370 ...backgroundLivenessItems,
62576371 ...resumeCompactionItems,
......@@ -6262,6 +6376,7 @@ export default function ChatView(props: ChatViewProps) {
62626376 return [
62636377 ...feedbackBannerItems,
62646378 ...usageLimitsItems,
6379 ...projectCloneItems,
62656380 ...systemComposerBannerItems,
62666381 ...backgroundLivenessItems,
62676382 ...resumeCompactionItems,
......@@ -6314,6 +6429,7 @@ export default function ChatView(props: ChatViewProps) {
63146429 isRestoringThreadBranch,
63156430 localCheckoutBranchMismatch,
63166431 parkedThreadBannerItem,
6432 projectCloneBannerItem,
63176433 resumeCompactionBannerItem,
63186434 showBranchMismatchBanner,
63196435 systemComposerBannerItems,
......@@ -9162,7 +9278,7 @@ export default function ChatView(props: ChatViewProps) {
91629278 ? "Sending feedback"
91639279 : threadDetailLoading
91649280 ? "Messages loading"
9165 : null
9281 : projectCloneSendBlockReason
91669282 }
91679283 isPreparingWorktree={isPreparingWorktree}
91689284 bannerItems={composerBannerItems}
apps/web/src/components/CommandPalette.tsx+61-6
......@@ -84,7 +84,7 @@ import { sourceControlEnvironment } from "../state/sourceControl";
8484import { useAtomCommand } from "../state/use-atom-command";
8585import { useAtomQueryRunner } from "../state/use-atom-query-runner";
8686import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments";
87import { useProjects, useServerConfigs, useThreadShells } from "../state/entities";
87import { useProjects, useServerConfigs, useThreadShells, waitForProject } from "../state/entities";
8888import { useThreadSearch } from "../state/queries";
8989import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions";
9090import {
......@@ -643,6 +643,9 @@ function OpenCommandPaletteDialog(props: {
643643 const cloneRepository = useAtomCommand(sourceControlEnvironment.cloneRepository, {
644644 reportFailure: false,
645645 });
646 const startProjectClone = useAtomCommand(sourceControlEnvironment.startProjectClone, {
647 reportFailure: false,
648 });
646649 const { environments } = useEnvironments();
647650 const desktopLocalBootstraps = useDesktopLocalBootstraps();
648651 const primaryEnvironmentId = usePrimaryEnvironmentId();
......@@ -2198,28 +2201,80 @@ function OpenCommandPaletteDialog(props: {
21982201 return;
21992202 }
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();
22012237 setIsRemoteProjectCloning(true);
2202 const cloneResult = await cloneRepository({
2238 const startResult = await startProjectClone({
22032239 environmentId: addProjectCloneFlow.environmentId,
22042240 input: {
2241 projectId,
2242 title: inferProjectTitleFromPath(destinationPath),
2243 createdAt: new Date().toISOString(),
22052244 remoteUrl: addProjectCloneFlow.remoteUrl,
22062245 destinationPath,
22072246 },
22082247 });
22092248 setIsRemoteProjectCloning(false);
2210 if (cloneResult._tag === "Failure") {
2211 if (!isAtomCommandInterrupted(cloneResult)) {
2249 if (startResult._tag === "Failure") {
2250 if (!isAtomCommandInterrupted(startResult)) {
22122251 toastManager.add(
22132252 stackedThreadToast({
22142253 type: "error",
22152254 title: "Clone failed",
2216 description: errorMessage(squashAtomCommandFailure(cloneResult)),
2255 description: errorMessage(squashAtomCommandFailure(startResult)),
22172256 }),
22182257 );
22192258 }
22202259 return;
22212260 }
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 }
22232278 }
22242279
22252280 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";
2727import { DesktopAppActivationCoordinator } from "../components/desktop/DesktopAppActivationCoordinator";
2828import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification";
2929import { ThreadNotificationCoordinator } from "../components/ThreadNotificationCoordinator";
30import { ProjectCloneToastCoordinator } from "../components/ProjectCloneToastCoordinator";
3031import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator";
3132import { ThemeEditorHost } from "../components/settings/ThemeEditorHost";
3233import { useCopyToClipboard } from "../hooks/useCopyToClipboard";
......@@ -222,6 +223,7 @@ function RootRouteView() {
222223 <ThreadNotificationCoordinator />
223224 <ConfirmDialogHost />
224225 <SlowRpcRequestToastCoordinator />
226 <ProjectCloneToastCoordinator />
225227 <HostedStaticEnvironmentBootstrap />
226228 {primaryEnvironmentAuthenticated ? (
227229 <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
7676## Clone or publish a project
7777
7878Use **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
8184For a local Git repository without a remote, **Publish Repository** creates a hosted repository,
8285adds 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 =
5757 | typeof WS_METHODS.previewAutomationConnect
5858 | typeof WS_METHODS.subscribeVcsStatus
5959 | typeof WS_METHODS.subscribeWorktreeSetup
60 | typeof WS_METHODS.subscribeProjectClones
6061 | typeof WS_METHODS.terminalAttach;
6162
6263export type EnvironmentStreamCommandRpcTag =
packages/client-runtime/src/state/sourceControl.ts+38
......@@ -5,6 +5,7 @@ import {
55 createAtomCommandScheduler,
66 createEnvironmentRpcCommand,
77 createEnvironmentRpcQueryAtomFamily,
8 createEnvironmentRpcSubscriptionAtomFamily,
89} from "./runtime.ts";
910import type { EnvironmentRegistry } from "../connection/registry.ts";
1011import { EnvironmentCacheStore } from "../platform/persistence.ts";
......@@ -33,6 +34,43 @@ export function createSourceControlEnvironmentAtoms<R, E>(
3334 key: ({ environmentId }) => environmentId,
3435 },
3536 }),
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 }),
3674 publishRepository: createEnvironmentRpcCommand(runtime, {
3775 label: "environment-data:source-control:publish-repository",
3876 tag: WS_METHODS.sourceControlPublishRepository,
packages/contracts/src/environment.ts+5
......@@ -153,6 +153,11 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
153153 this is false — no update would ever repaint it. Absent on older
154154 servers, which may still publish, so only an explicit false skips. */
155155 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),
156161 /** Server detects `platform.machine` and persists the `environmentIcon`
157162 setting. Older servers drop the key on write, so clients show the
158163 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";
2525export * from "./git.ts";
2626export * from "./vcs.ts";
2727export * from "./sourceControl.ts";
28export * from "./projectClone.ts";
2829export * from "./pullRequest.ts";
2930export * from "./orchestration.ts";
3031export * 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 {
249249} from "./providerUsageLimits.ts";
250250import { UsagePricing, UsageReadError, UsageSummary, UsageSummaryInput } from "./usage.ts";
251251import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts";
252import {
253 ProjectCloneActionInput,
254 ProjectCloneActionResult,
255 ProjectCloneListEvent,
256 ProjectCloneStartInput,
257 ProjectCloneStartResult,
258 ProjectCloneSubscribeInput,
259} from "./projectClone.ts";
252260import {
253261 SourceControlCloneRepositoryInput,
254262 SourceControlCloneRepositoryResult,
......@@ -407,6 +415,10 @@ export const WS_METHODS = {
407415 sourceControlLookupRepository: "sourceControl.lookupRepository",
408416 sourceControlCloneRepository: "sourceControl.cloneRepository",
409417 sourceControlPublishRepository: "sourceControl.publishRepository",
418 projectCloneStart: "projectClone.start",
419 projectCloneCancel: "projectClone.cancel",
420 projectCloneRetry: "projectClone.retry",
421 subscribeProjectClones: "subscribeProjectClones",
410422
411423 // Streaming subscriptions
412424 subscribeVcsStatus: "subscribeVcsStatus",
......@@ -842,6 +854,37 @@ const WsSourceControlCloneRepositoryRpc = Rpc.make(WS_METHODS.sourceControlClone
842854 error: Schema.Union([SourceControlRepositoryError, EnvironmentAuthorizationError]),
843855});
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
845888const WsSourceControlPublishRepositoryRpc = Rpc.make(WS_METHODS.sourceControlPublishRepository, {
846889 payload: SourceControlPublishRepositoryInput,
847890 success: SourceControlPublishRepositoryResult,
......@@ -1379,6 +1422,10 @@ export const WsRpcGroup = RpcGroup.make(
13791422 WsSourceControlLookupRepositoryRpc,
13801423 WsSourceControlCloneRepositoryRpc,
13811424 WsSourceControlPublishRepositoryRpc,
1425 WsProjectCloneStartRpc,
1426 WsProjectCloneCancelRpc,
1427 WsProjectCloneRetryRpc,
1428 WsSubscribeProjectClonesRpc,
13821429 WsProjectsListEntriesRpc,
13831430 WsProjectsReadFileRpc,
13841431 WsProjectsSearchContentsRpc,