Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions apps/server/src/orchestration-v2/ThreadLaunchService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2460,6 +2460,79 @@ it.effect("tells the thread when its worktree branch could not be renamed", () =
}),
);

it.effect("reports submodule downloads as their own setup step", () =>
Effect.gen(function* () {
const progressReady =
yield* Deferred.make<
NonNullable<
NonNullable<
Parameters<GitWorkflow.GitWorkflowService["Service"]["createWorktree"]>[1]
>["progress"]
>
>();
const release = yield* Deferred.make<void>();
const harness = makeHarness({
createWorktree: (input, options) =>
Deferred.succeed(progressReady, options?.progress ?? {}).pipe(
Effect.andThen(Deferred.await(release)),
Effect.as({
worktree: {
path: "/repo-worktrees/feature",
refName: input.newRefName,
headSha: "abc",
},
} as never),
),
});
yield* Effect.gen(function* () {
const launches = yield* ThreadLaunch.ThreadLaunchService;
const tracker = yield* WorktreeSetupTracker.WorktreeSetupTracker;
const launched = yield* launches.launch(
launchInput({
command: "launch:submodule-progress",
thread: "thread:submodule-progress",
message: "Start",
workspace: { type: "worktree", baseRef: "main" },
}),
);
const stage = (id: string) =>
tracker
.get(launched.threadId)
.pipe(Effect.map((snapshot) => snapshot?.stages.find((entry) => entry.id === id)));
const progress = yield* Deferred.await(progressReady);
const line = (text: string) => progress.onSubmoduleLine?.(text) ?? Effect.void;

yield* progress.onSubmodulesStarted?.() ?? Effect.void;
yield* line("Receiving objects: 20% (2/10), 4.00 MiB | 1.50 MiB/s");
yield* line("Receiving objects: 30% (3/10), 6.00 MiB | 1.50 MiB/s");
assert.equal((yield* stage("checkout"))?.status, "done");
assert.deepInclude(yield* stage("submodules"), {
status: "running",
detail: "Receiving objects: 20% (2/10), 4.00 MiB | 1.50 MiB/s",
});

yield* TestClock.adjust("1 second");
yield* line("Receiving objects: 60% (6/10), 12.00 MiB | 1.50 MiB/s");
assert.equal(
(yield* stage("submodules"))?.detail,
"Receiving objects: 60% (6/10), 12.00 MiB | 1.50 MiB/s",
);

yield* line("Submodule path 'vendor/o'hare': checked out 'abc'");
yield* progress.onSubmodulesFinished?.({ ok: true, detail: null }) ?? Effect.void;
yield* Deferred.succeed(release, undefined);
yield* tracker.stream(launched.threadId).pipe(
Stream.filter((snapshot) => snapshot?.phase !== "running"),
Stream.runHead,
);
assert.deepInclude(yield* stage("submodules"), {
status: "done",
detail: "vendor/o'hare",
});
}).pipe(Effect.provide(harness.layer));
}),
);

it.effect.each([0, 1])("releases an async setup before its completion with exit %s", (exitCode) =>
Effect.gen(function* () {
const completion = yield* Deferred.make<{ exitCode: number | null; durationMs: number }>();
Expand Down Expand Up @@ -2509,6 +2582,10 @@ it.effect.each([0, 1])("releases an async setup before its completion with exit
settled?.stages.find((stage) => stage.id === "setup-script")?.status,
exitCode === 0 ? "done" : "failed",
);
assert.deepInclude(
settled?.stages.find((stage) => stage.id === "submodules"),
{ status: "skipped", detail: "none" },
);
assert.equal(
(yield* threads.getThreadProjection(launched.threadId)).runs[0]?.status,
"starting",
Expand Down
52 changes: 50 additions & 2 deletions apps/server/src/orchestration-v2/ThreadLaunchService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
ThreadId,
} from "@t3tools/contracts";
import * as Cause from "effect/Cause";
import * as Clock from "effect/Clock";
import * as Context from "effect/Context";
import * as DateTime from "effect/DateTime";
import * as Effect from "effect/Effect";
Expand All @@ -41,6 +42,7 @@ import {
} from "@t3tools/shared/git";

import * as GitWorkflow from "../git/GitWorkflowService.ts";
import { parseGitCloneProgressLine } from "../project/gitCloneProgress.ts";
import * as ProjectService from "../project/ProjectService.ts";
import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts";
import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts";
Expand Down Expand Up @@ -437,7 +439,7 @@ const make = Effect.gen(function* () {
threadId,
branch: input.workspaceStrategy.branch ?? null,
baseRef: input.workspaceStrategy.baseRef,
stages: ["fetch", "checkout", "setup-script", "agent"],
stages: ["fetch", "checkout", "submodules", "setup-script", "agent"],
fiber: yield* Effect.fiber,
});
} else if (reused !== undefined) {
Expand Down Expand Up @@ -552,6 +554,7 @@ const make = Effect.gen(function* () {
branch = flattenTemporaryWorktreeBranchName(branch);
}
yield* setupTracker.stageStatus(threadId, "checkout", "running");
let submoduleProgressReportedAt = Number.NEGATIVE_INFINITY;
const worktree = yield* git
.createWorktree(
{
Expand All @@ -570,6 +573,42 @@ const make = Effect.gen(function* () {
}),
onCheckoutProgress: (progress) =>
setupTracker.stage(threadId, "checkout", { percent: progress.percent }),
onSubmodulesStarted: () =>
setupTracker
.stageStatus(threadId, "checkout", "done")
.pipe(
Effect.andThen(setupTracker.stageStatus(threadId, "submodules", "running")),
),
onSubmodulesDisabled: ({ source }) =>
setupTracker.stageStatus(
threadId,
"submodules",
"skipped",
`disabled in ${source}`,
),
onSubmoduleLine: (line) => {
// Paths may contain quotes; git closes the path with "': ".
const submodulePath = /^\s*Submodule path '(.+)': /.exec(line)?.[1];
if (submodulePath !== undefined) {
return setupTracker.stage(threadId, "submodules", { detail: submodulePath });
}
if (parseGitCloneProgressLine(line) === null) return Effect.void;
// Git redraws on every percent; hold the websocket to one update a second.
return Effect.flatMap(Clock.currentTimeMillis, (now) => {
if (now - submoduleProgressReportedAt < 1000) return Effect.void;
submoduleProgressReportedAt = now;
return setupTracker.stage(threadId, "submodules", {
detail: line.trim().replace(/\s+/g, " "),
});
});
},
onSubmodulesFinished: ({ ok, detail }) =>
setupTracker.stageStatus(
threadId,
"submodules",
ok ? "done" : "warning",
ok ? undefined : (detail ?? "submodule checkout failed"),
),
},
},
)
Expand All @@ -578,7 +617,16 @@ const make = Effect.gen(function* () {
branch = worktree.worktree.refName;
createdWorktreePath = worktreePath;
createdBranch = branch;
yield* setupTracker.update(threadId, (snapshot) => ({ ...snapshot, worktreePath, branch }));
yield* setupTracker.update(threadId, (snapshot) => ({
...snapshot,
worktreePath,
branch,
stages: snapshot.stages.map((stage) =>
stage.id === "submodules" && stage.status === "pending"
? { ...stage, status: "skipped", detail: "none" }
: stage,
),
}));
yield* setupTracker.stageStatus(threadId, "checkout", "done");
}

Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/project/ProjectCloneTracker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,9 @@ describe("parseGitCloneProgressLine", () => {
expect(
parseGitCloneProgressLine("Receiving objects: 45% (4500/10000), 12.30 MiB | 5.00 MiB/s"),
).toEqual({ stage: "receiving", percent: 45, detail: "12.30 MiB | 5.00 MiB/s" });
expect(
parseGitCloneProgressLine("Receiving objects: 100% (5/5), 40.00 MiB | 1.40 MiB/s, done."),
).toEqual({ stage: "receiving", percent: 100, detail: "40.00 MiB | 1.40 MiB/s" });
expect(parseGitCloneProgressLine("Resolving deltas: 100% (700/700), done.")).toEqual({
stage: "resolving",
percent: 100,
Expand Down
10 changes: 8 additions & 2 deletions apps/server/src/project/gitCloneProgress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ const STAGE_PREFIXES: ReadonlyArray<readonly [RegExp, ProjectCloneStage]> = [
[/^Checking out files/, "checkout"],
];

const PERCENT = /:\s+(\d+)%\s+\((\d+)\/(\d+)\)(?:,\s*(.*?))?\s*(?:,\s*done\.)?\s*$/;
// Linear on hostile remote output: the tail is trimmed in code (optional `\s*`/`.*?` groups backtrack
// cubically), and `s` lets it span U+2028 so a failed `$` cannot retry at every later counter.
const PERCENT = /:\s+(\d+)%\s+\((\d+)\/(\d+)\)(.*)$/s;
const DONE_SUFFIX = ", done.";

/** Pass each bare-CR progress redraw as a separate line. */
export function parseGitCloneProgressLine(line: string): GitCloneProgressLine | null {
Expand All @@ -26,8 +29,11 @@ export function parseGitCloneProgressLine(line: string): GitCloneProgressLine |
const stage = stageEntry[1];
const match = PERCENT.exec(trimmed);
if (!match) return { stage, percent: null, detail: null };
let tail = (match[4] ?? "").trimEnd();
if (tail.endsWith(DONE_SUFFIX)) tail = tail.slice(0, -DONE_SUFFIX.length).trimEnd();
if (tail.length > 0 && !tail.startsWith(",")) return { stage, percent: null, detail: null };
const percent = Number(match[1]);
const rawDetail = match[4]?.trim() ?? "";
const rawDetail = tail.slice(1).trim();
const detail = rawDetail.length > 0 && rawDetail !== "done." ? rawDetail : null;
return {
stage,
Expand Down
9 changes: 7 additions & 2 deletions apps/server/src/vcs/GitVcsDriverCore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5392,7 +5392,8 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => {

const cwd = yield* makeTmpDir();
const { initialBranch } = yield* initRepoWithCommit(cwd);
yield* git(cwd, ["submodule", "add", submoduleRepo, "shared"]);
// A file URL transfers a pack like a network remote; a plain path hardlinks silently.
yield* git(cwd, ["submodule", "add", NodeURL.pathToFileURL(submoduleRepo).href, "shared"]);
yield* git(cwd, ["commit", "-m", "add submodule"]);
const recordedHead = yield* git(submoduleRepo, ["rev-parse", "HEAD"]);
yield* writeTextFile(submoduleRepo, "SHARED.md", "# newer remote default\n");
Expand Down Expand Up @@ -5467,7 +5468,11 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => {
yield* git(pathService.join(worktreePath, "shared"), ["status", "--porcelain"]),
"",
);
if (progress) assert.isNotEmpty(yield* Ref.get(lines));
if (progress) {
assert.isTrue(
(yield* Ref.get(lines)).some((line) => line.startsWith("Receiving objects")),
);
}
}).pipe(withGitFileProtocol),
);

Expand Down
14 changes: 11 additions & 3 deletions apps/server/src/vcs/GitVcsDriverCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4700,16 +4700,24 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
runGit(
"GitVcsDriver.createWorktree.updateSubmodules",
worktreePath,
submoduleMode.value === "recursive"
? ["submodule", "update", "--init", "--recursive"]
: ["submodule", "update", "--init"],
[
"submodule",
"update",
"--init",
...(submoduleMode.value === "recursive" ? ["--recursive"] : []),
// Git reports transfer progress to a pipe only when forced.
...(onSubmoduleLine ? ["--progress"] : []),
],
{
// Cloning scales with remote size; let it finish or be cancelled, like worktree add.
timeoutMs: null,
...(onSubmoduleLine
? {
env: { LANGUAGE: "C" },
progress: { onStdoutLine: onSubmoduleLine, onStderrLine: onSubmoduleLine },
// Progress for many submodules can pass the output cap; truncate instead of failing.
appendTruncationMarker: true,
keepLineCallbacksAfterTruncation: true,
}
: {}),
},
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/components/chat/WorktreeSetupCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ function StageRow({
trailing={
<>
{trailing ? (
<span className="min-w-0 truncate text-xs text-muted-foreground tabular-nums">
<span className="max-w-1/2 min-w-0 truncate text-xs text-muted-foreground tabular-nums">
{trailing}
</span>
) : null}
Expand Down
Loading