From c5707171c0043916cccb6a2db1009ed36e3eccb9 Mon Sep 17 00:00:00 2001 From: "omegent-app[bot]" <306514130+omegent-app[bot]@users.noreply.github.com> Date: Sat, 1 Aug 2026 18:32:59 +0000 Subject: [PATCH 01/13] feat(mobile): render settled threads as slim history rows (#283) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The classic mobile thread lists (phone Home and the iPad navigation sidebar) rendered settled threads with the same three-line chrome as active work — favicon, title, status pill, branch/server subtitle, PR badge, chevron — so the "Settled" shelf read as more inbox rather than history. Web has had a distinct slim settled row in both list modes (Sidebar.tsx settled shelf, SidebarV2 slim variant), and mobile's own Thread List v2 settled tail already ships one; only the classic mobile path was missing it. Settled rows now collapse to a single dimmed line — dimmed favicon, muted one-line title, draft dot, trailing settle time — in both the compact and sidebar variants, matching thread-list-v2-items.tsx. The row keeps its tap target (44pt), swipe actions, and long-press settle/unsettle menu. The trailing label uses the settle stamp when the server recorded one and falls back to last activity, so the shelf reads in the order it is sorted (resolveSettledRowTimestamp mirrors web's resolveSettledTimestamp). Settled rows lead with a favicon only where the row already carries project context (recency / flat / Needs attention); project-grouped lists already show one in the group header. Co-authored-by: omegent-app[bot] <306514130+omegent-app[bot]@users.noreply.github.com> --- .../features/threads/thread-list-items.tsx | 117 +++++++++++++++++- .../threads/threadPresentation.test.ts | 48 +++++++ .../features/threads/threadPresentation.ts | 15 +++ .../mobile/src/mobileSurfaceExistence.test.ts | 16 +++ 4 files changed, 193 insertions(+), 3 deletions(-) create mode 100644 apps/mobile/src/features/threads/threadPresentation.test.ts diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index 63ed299eeed1..e9120ff460ac 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -7,7 +7,7 @@ import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state import type { MenuAction } from "@react-native-menu/menu"; import { SymbolView } from "../../components/AppSymbol"; import { memo, useCallback, useMemo, type ComponentProps } from "react"; -import { Pressable, useColorScheme, useWindowDimensions, View } from "react-native"; +import { Platform, Pressable, useColorScheme, useWindowDimensions, View } from "react-native"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; import Svg, { Circle, Path } from "react-native-svg"; @@ -28,7 +28,7 @@ import { useThreadPr, type ThreadPr } from "../../state/use-thread-pr"; import { composerDraftsAtom, hasComposerDraftMessage } from "../../state/use-composer-drafts"; import type { HomeGroupDisplayAction } from "../home/homeListItems"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; -import { resolveThreadStatus } from "./threadPresentation"; +import { resolveSettledRowTimestamp, resolveThreadStatus } from "./threadPresentation"; import { ThreadSearchMatchExcerpt } from "./thread-search-match"; import { hasUsageMarker, @@ -49,6 +49,12 @@ export type ThreadListVariant = "compact" | "sidebar"; export const THREAD_LIST_COMPACT_INSET = HOME_HORIZONTAL_INSET; const SIDEBAR_ROW_RADIUS = 12; +const MONO_FONT = Platform.select({ + ios: "Menlo", + android: "monospace", + default: "monospace", +}); + function pullRequestTintColor( state: ThreadPr["state"], colorScheme: ReturnType, @@ -551,6 +557,8 @@ export const ThreadListRow = memo(function ThreadListRow(props: { const timestamp = relativeTime( thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt, ); + // Settled rows label by when the work ENDED, matching the shelf sort. + const settledTimestamp = relativeTime(resolveSettledRowTimestamp(thread)); const threadAccessibilityLabel = pr ? `${thread.title}, ${pr.accessibilityLabel}` : thread.title; const subtitleParts = [props.projectTitle, props.environmentLabel, thread.branch].filter( (part): part is string => Boolean(part), @@ -654,8 +662,111 @@ export const ThreadListRow = memo(function ThreadListRow(props: { ) : null; + // Project-grouped lists already show a favicon in the group header, so the + // slim row only leads with one where it also carries project context + // (recency / flat / Needs attention) — the same rule the subtitle uses. + const showSettledFavicon = Boolean(props.projectTitle) && props.projectCwd !== null; + + /** + * Settled threads are history, not inbox: they collapse to a single dimmed + * line so the active work above stays scannable. Status pill, subtitle, + * PR badge, provider icon and chevron all drop — a settled row is a title, + * a time, and a way back in. Matches the Thread List v2 settled tail + * (thread-list-v2-items.tsx) and web's settled shelf (Sidebar.tsx), so + * settled history reads the same in every list mode on every client. + */ + const settledRowContent = (close: () => void) => ( + setHovered(true)} + onHoverOut={compact ? undefined : () => setHovered(false)} + onPressIn={() => { + prefetchEnvironmentThread(thread.environmentId, thread.id); + }} + onPress={() => { + close(); + onSelectThread(thread); + }} + style={ + compact + ? ({ pressed }) => ({ opacity: pressed ? 0.7 : 1 }) + : ({ pressed }) => ({ + backgroundColor: selected + ? selectedBackgroundColor + : pressed || hovered + ? effectivePressedBackground + : backgroundColor, + borderRadius: SIDEBAR_ROW_RADIUS, + cursor: "pointer", + }) + } + > + + {showSettledFavicon ? ( + + + + ) : null} + + + + {thread.title} + + {hasDraft ? ( + + ) : null} + + {props.searchMatch ? ( + + ) : null} + + + {settledTimestamp} + + + + ); + const rowContent = (close: () => void) => - compact ? ( + isSettled ? ( + settledRowContent(close) + ) : compact ? ( { + it("prefers the explicit settle stamp", () => { + expect( + resolveSettledRowTimestamp({ + ...base, + settledAt: "2026-02-02T00:00:00.000Z", + latestUserMessageAt: "2026-01-15T00:00:00.000Z", + }), + ).toBe("2026-02-02T00:00:00.000Z"); + }); + + it("falls back to last user activity for auto-settled threads", () => { + expect( + resolveSettledRowTimestamp({ ...base, latestUserMessageAt: "2026-01-15T00:00:00.000Z" }), + ).toBe("2026-01-15T00:00:00.000Z"); + }); + + it("falls back to updatedAt when the thread has no user message", () => { + expect(resolveSettledRowTimestamp(base)).toBe("2026-01-01T00:00:00.000Z"); + }); + + it("orders rows the same way the settled shelf sorts them", () => { + // The shelf sorts by settledAt ?? latestUserMessageAt ?? updatedAt, so a + // freshly settled old thread must label ahead of a stale newer one. + const settledRecently = { + ...base, + settledAt: "2026-03-01T00:00:00.000Z", + latestUserMessageAt: "2025-06-01T00:00:00.000Z", + }; + const touchedRecently = { ...base, latestUserMessageAt: "2026-02-01T00:00:00.000Z" }; + + expect( + Date.parse(resolveSettledRowTimestamp(settledRecently)) > + Date.parse(resolveSettledRowTimestamp(touchedRecently)), + ).toBe(true); + }); +}); diff --git a/apps/mobile/src/features/threads/threadPresentation.ts b/apps/mobile/src/features/threads/threadPresentation.ts index ab1b293a2dd1..39b2c72726f0 100644 --- a/apps/mobile/src/features/threads/threadPresentation.ts +++ b/apps/mobile/src/features/threads/threadPresentation.ts @@ -6,6 +6,21 @@ export function threadSortValue(thread: EnvironmentThreadShell): number { return Number.isNaN(candidate) ? 0 : candidate; } +/** + * The timestamp a settled row labels by: the settle stamp when the server + * recorded one (explicit settles), otherwise last activity. Mirrors the + * settled-shelf sort in HomeScreen / ThreadNavigationSidebar (and web's + * `resolveSettledTimestamp`) so a shelf reads in the order it is sorted. + */ +export function resolveSettledRowTimestamp( + thread: Pick< + EnvironmentThreadShell, + "settledAt" | "latestUserMessageAt" | "updatedAt" | "createdAt" + >, +): string { + return thread.settledAt ?? thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt; +} + export type ThreadStatusKind = | "pending-approval" | "awaiting-input" diff --git a/apps/mobile/src/mobileSurfaceExistence.test.ts b/apps/mobile/src/mobileSurfaceExistence.test.ts index 6380e5aea1c6..a202eaa410d3 100644 --- a/apps/mobile/src/mobileSurfaceExistence.test.ts +++ b/apps/mobile/src/mobileSurfaceExistence.test.ts @@ -27,6 +27,22 @@ describe("mobile surface existence (anti stack-drop)", () => { ); }); + it("renders settled threads as slim history rows in the classic thread lists", () => { + const listItems = readSrc("features/threads/thread-list-items.tsx"); + + // The settled branch must stay wired into the shared row renderer: a + // whole-file conflict resolve that keeps the helper but drops the branch + // would silently restore full-size settled rows. + expect(listItems).toContain('testID="thread-list-row-settled"'); + expect(listItems).toMatch(/isSettled \? \(\s*settledRowContent\(close\)/); + expect(listItems).toContain("resolveSettledRowTimestamp"); + // Slim chrome: dimmed favicon, one muted title line, no status pill. + expect(listItems).toMatch( + /testID="thread-list-row-settled"[\s\S]*?text-foreground-muted[\s\S]*?<\/Pressable>/, + ); + expect(listItems).toMatch(/settledRowContent[\s\S]*?opacity-40[\s\S]*?ProjectFavicon/); + }); + it("keys markdown nodes uniquely even when parser spans collide", () => { const nodeKey = NodeFS.readFileSync( NodePath.join(root, "../modules/t3-markdown-text/src/markdownNodeKey.ts"), From 80be0abc62c115c8ab00c64c097a4d62a51e9f39 Mon Sep 17 00:00:00 2001 From: "omegent-app[bot]" <306514130+omegent-app[bot]@users.noreply.github.com> Date: Sun, 2 Aug 2026 07:08:52 +0000 Subject: [PATCH 02/13] feat(server): blocking lifecycle scripts before worktree removal (#284) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(server): run blocking lifecycle scripts before worktree removal Add runOnWorktreeRemove and runOnPrMerged project script hooks so process and data reaping can finish before git worktree remove continues. Non-zero exit blocks removal. Also expose the flags in t3.json, scripts UI, and docs. Co-authored-by: Patrick Roza <42661+patroza@users.noreply.github.com> * fix(server): trigger runOnPrMerged on open→merged, not on remove Decouple PR-merge lifecycle from worktree removal. Fire runOnPrMerged when VCS remote status observes the same PR transition open→merged (once per cwd+PR). Worktree remove only runs runOnWorktreeRemove and waits for exit. Co-authored-by: Patrick Roza <42661+patroza@users.noreply.github.com> * feat(server): pass linked PR association env to lifecycle scripts Expose T3CODE_PR (URL preferred) plus number/url/title/base/head/state for the change request associated with the worktree branch. Populate on PR merge and on worktree remove when status knows the linked PR. Co-authored-by: Patrick Roza <42661+patroza@users.noreply.github.com> --------- Co-authored-by: omegent-app[bot] <306514130+omegent-app[bot]@users.noreply.github.com> Co-authored-by: Patrick Roza <42661+patroza@users.noreply.github.com> --- .../src/features/terminal/terminalMenu.ts | 6 +- .../server/src/git/GitWorkflowService.test.ts | 10 + apps/server/src/git/GitWorkflowService.ts | 72 ++++- .../ProjectLifecycleScriptRunner.test.ts | 302 ++++++++++++++++++ .../project/ProjectLifecycleScriptRunner.ts | 271 ++++++++++++++++ apps/server/src/server.test.ts | 10 + apps/server/src/server.ts | 6 + .../src/vcs/VcsStatusBroadcaster.test.ts | 128 ++++++++ apps/server/src/vcs/VcsStatusBroadcaster.ts | 108 ++++++- apps/web/src/components/ChatView.tsx | 19 +- .../src/components/ProjectScriptsControl.tsx | 31 +- apps/web/src/projectScripts.test.ts | 108 ++++++- apps/web/src/projectScripts.ts | 45 ++- docs/user/source-control.md | 20 ++ packages/contracts/src/orchestration.ts | 11 + packages/contracts/src/t3ProjectFile.test.ts | 2 + packages/contracts/src/t3ProjectFile.ts | 12 + packages/shared/src/projectScripts.ts | 88 +++++ packages/shared/src/t3ProjectFile.test.ts | 2 + 19 files changed, 1226 insertions(+), 25 deletions(-) create mode 100644 apps/server/src/project/ProjectLifecycleScriptRunner.test.ts create mode 100644 apps/server/src/project/ProjectLifecycleScriptRunner.ts diff --git a/apps/mobile/src/features/terminal/terminalMenu.ts b/apps/mobile/src/features/terminal/terminalMenu.ts index 06cb74e9467d..9d5c7c0ee833 100644 --- a/apps/mobile/src/features/terminal/terminalMenu.ts +++ b/apps/mobile/src/features/terminal/terminalMenu.ts @@ -155,7 +155,11 @@ export function resolveProjectScriptTerminalId(input: { } export function projectScriptMenuLabel(script: ProjectScript): string { - return script.runOnWorktreeCreate ? `${script.name} (setup)` : script.name; + const tags: string[] = []; + if (script.runOnWorktreeCreate) tags.push("setup"); + if (script.runOnWorktreeRemove === true) tags.push("teardown"); + if (script.runOnPrMerged === true) tags.push("pr-merged"); + return tags.length > 0 ? `${script.name} (${tags.join(", ")})` : script.name; } export function projectScriptMenuIcon(icon: ProjectScript["icon"]) { diff --git a/apps/server/src/git/GitWorkflowService.test.ts b/apps/server/src/git/GitWorkflowService.test.ts index 2ea14b951fe2..28b4f33f97ca 100644 --- a/apps/server/src/git/GitWorkflowService.test.ts +++ b/apps/server/src/git/GitWorkflowService.test.ts @@ -6,9 +6,17 @@ import { VcsRepositoryDetectionError } from "@t3tools/contracts"; import * as GitManager from "./GitManager.ts"; import * as GitWorkflowService from "./GitWorkflowService.ts"; +import * as ProjectLifecycleScriptRunner from "../project/ProjectLifecycleScriptRunner.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; +const lifecycleScriptRunnerMock = Layer.mock( + ProjectLifecycleScriptRunner.ProjectLifecycleScriptRunner, +)({ + runWorktreeRemove: () => Effect.succeed({ status: "no-script" as const }), + runPrMerged: () => Effect.succeed({ status: "no-script" as const }), +}); + function makeLayer(input: { readonly detect: VcsDriverRegistry.VcsDriverRegistry["Service"]["detect"]; }) { @@ -20,6 +28,7 @@ function makeLayer(input: { ), Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})), Layer.provide(Layer.mock(GitManager.GitManager)({})), + Layer.provide(lifecycleScriptRunnerMock), ); } @@ -100,6 +109,7 @@ describe("GitWorkflowService", () => { status, }), ), + Layer.provide(lifecycleScriptRunnerMock), ); return Effect.gen(function* () { diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index c5446943bd10..d8c210da3a1e 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -31,6 +31,7 @@ import { } from "@t3tools/contracts"; import * as GitManager from "./GitManager.ts"; +import * as ProjectLifecycleScriptRunner from "../project/ProjectLifecycleScriptRunner.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; @@ -135,10 +136,42 @@ function nonRepositoryListRefs(): VcsListRefsResult { }; } +function lifecycleScriptToGitCommandError( + operation: string, + input: VcsRemoveWorktreeInput, + error: ProjectLifecycleScriptRunner.ProjectLifecycleScriptRunnerError, +): GitCommandError { + if (error._tag === "ProjectLifecycleScriptFailedError") { + const detailParts = [ + error.message, + error.stderr.trim().length > 0 ? error.stderr.trim() : null, + error.stdout.trim().length > 0 ? error.stdout.trim() : null, + ].filter((part): part is string => part !== null); + return new GitCommandError({ + operation, + command: `lifecycle:${error.lifecycle}`, + cwd: input.path, + exitCode: error.exitCode ?? undefined, + failureKind: "unknown", + detail: detailParts.join("\n"), + cause: error, + }); + } + return new GitCommandError({ + operation, + command: `lifecycle:${error.lifecycle}`, + cwd: input.path, + failureKind: "unknown", + detail: error.message, + cause: error, + }); +} + export const make = Effect.gen(function* () { const registry = yield* VcsDriverRegistry.VcsDriverRegistry; const git = yield* GitVcsDriver.GitVcsDriver; const gitManager = yield* GitManager.GitManager; + const lifecycleScriptRunner = yield* ProjectLifecycleScriptRunner.ProjectLifecycleScriptRunner; const ensureGit = Effect.fn("GitWorkflowService.ensureGit")(function* ( operation: string, @@ -322,7 +355,44 @@ export const make = Effect.gen(function* () { ), removeWorktree: (input) => ensureGitCommand("GitWorkflowService.removeWorktree", input.cwd).pipe( - Effect.andThen(git.removeWorktree(input)), + Effect.andThen( + Effect.gen(function* () { + // Prefer the PR associated with the worktree branch (status cwd = worktree path). + const associatedPr = yield* gitManager.remoteStatus({ cwd: input.path }).pipe( + Effect.map((remote) => remote?.pr ?? null), + Effect.orElseSucceed(() => null), + ); + + // Teardown must finish successfully before the worktree directory is removed. + // PR-merged lifecycle is a separate trigger (status transition), not part of remove. + yield* lifecycleScriptRunner + .runWorktreeRemove({ + projectCwd: input.cwd, + worktreePath: input.path, + pr: associatedPr + ? { + number: associatedPr.number, + url: associatedPr.url, + title: associatedPr.title, + baseRef: associatedPr.baseRef, + headRef: associatedPr.headRef, + state: associatedPr.state, + } + : null, + }) + .pipe( + Effect.mapError((error) => + lifecycleScriptToGitCommandError( + "GitWorkflowService.removeWorktree", + input, + error, + ), + ), + ); + + yield* git.removeWorktree(input); + }), + ), ), createRef: (input) => ensureGitCommand("GitWorkflowService.createRef", input.cwd).pipe( diff --git a/apps/server/src/project/ProjectLifecycleScriptRunner.test.ts b/apps/server/src/project/ProjectLifecycleScriptRunner.test.ts new file mode 100644 index 000000000000..398270a2e17d --- /dev/null +++ b/apps/server/src/project/ProjectLifecycleScriptRunner.test.ts @@ -0,0 +1,302 @@ +import { describe, expect, it, vi } from "@effect/vitest"; +import { type OrchestrationProject, ProjectId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; + +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ProcessRunner from "../processRunner.ts"; +import * as ProjectLifecycleScriptRunner from "./ProjectLifecycleScriptRunner.ts"; + +const okProcessOutput = ( + overrides: Partial = {}, +): ProcessRunner.ProcessRunOutput => ({ + stdout: "", + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + ...overrides, +}); + +const isLifecycleFailed = Schema.is(ProjectLifecycleScriptRunner.ProjectLifecycleScriptFailedError); + +const makeProject = (scripts: OrchestrationProject["scripts"]): OrchestrationProject => ({ + id: ProjectId.make("project-1"), + title: "Project", + workspaceRoot: "/repo/project", + defaultModelSelection: null, + scripts, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + deletedAt: null, +}); + +const makeProjectionSnapshotQueryLayer = ( + project: OrchestrationProject | null, + options?: { readonly worktreePath?: string }, +) => + Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { + getCommandReadModel: () => Effect.die("unused"), + getThreadActivitiesPage: () => Effect.die("unused"), + getSnapshot: () => Effect.die("unused"), + getShellSnapshot: () => + Effect.succeed({ + snapshotSequence: 1, + projects: project ? [project] : [], + threads: + project && options?.worktreePath + ? [ + { + id: "thread-1" as never, + projectId: project.id, + title: "Thread", + modelSelection: { + instanceId: "codex" as never, + model: "gpt", + }, + runtimeMode: "full-access" as never, + interactionMode: "default" as never, + branch: null, + worktreePath: options.worktreePath, + latestTurn: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + archivedAt: null, + settledOverride: null, + settledAt: null, + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + }, + ] + : [], + updatedAt: "2026-01-01T00:00:00.000Z", + }), + getArchivedShellSnapshot: () => Effect.die("unused"), + getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 1 }), + getCounts: () => Effect.die("unused"), + getActiveProjectByWorkspaceRoot: (workspaceRoot) => + Effect.succeed( + project && workspaceRoot === project.workspaceRoot ? Option.some(project) : Option.none(), + ), + getProjectShellById: (projectId) => + Effect.succeed(project && projectId === project.id ? Option.some(project) : Option.none()), + getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), + getThreadCheckpointContext: () => Effect.die("unused"), + getFullThreadDiffContext: () => Effect.die("unused"), + getThreadShellById: () => Effect.die("unused"), + getSessionStopContextById: () => Effect.die("unused"), + getThreadDetailById: () => Effect.die("unused"), + getThreadDetailSnapshot: () => Effect.die("unused"), + searchThreads: () => Effect.succeed({ matches: [] }), + getThreadLifecycleById: () => Effect.die("unused"), + }); + +const makeProcessRunnerLayer = (run: ProcessRunner.ProcessRunner["Service"]["run"]) => + Layer.succeed(ProcessRunner.ProcessRunner, { run }); + +const testLayer = ( + project: OrchestrationProject | null, + run: ProcessRunner.ProcessRunner["Service"]["run"], + options?: { readonly worktreePath?: string }, +) => + ProjectLifecycleScriptRunner.layer.pipe( + Layer.provideMerge(makeProjectionSnapshotQueryLayer(project, options)), + Layer.provideMerge(makeProcessRunnerLayer(run)), + ); + +describe("ProjectLifecycleScriptRunner", () => { + it.effect("returns no-script when no teardown script exists", () => { + const run = vi.fn(() => Effect.die("unexpected run")); + const project = makeProject([]); + + return Effect.gen(function* () { + const runner = yield* ProjectLifecycleScriptRunner.ProjectLifecycleScriptRunner; + const result = yield* runner.runWorktreeRemove({ + projectCwd: "/repo/project", + worktreePath: "/repo/worktrees/a", + }); + expect(result).toEqual({ status: "no-script" }); + expect(run).not.toHaveBeenCalled(); + }).pipe(Effect.provide(testLayer(project, run))); + }); + + it.effect("returns no-script when no project can be resolved", () => { + const run = vi.fn(() => Effect.die("unexpected run")); + return Effect.gen(function* () { + const runner = yield* ProjectLifecycleScriptRunner.ProjectLifecycleScriptRunner; + const result = yield* runner.runWorktreeRemove({ + projectCwd: "/missing", + worktreePath: "/repo/worktrees/a", + }); + expect(result).toEqual({ status: "no-script" }); + expect(run).not.toHaveBeenCalled(); + }).pipe(Effect.provide(testLayer(null, run))); + }); + + it.effect("runs the worktree-remove script and waits for a successful exit", () => { + const run = vi.fn(() => Effect.succeed(okProcessOutput({ stdout: "cleaned\n" }))); + const project = makeProject([ + { + id: "teardown", + name: "Teardown", + command: "echo cleaned", + icon: "configure", + runOnWorktreeCreate: false, + runOnWorktreeRemove: true, + }, + ]); + + return Effect.gen(function* () { + const runner = yield* ProjectLifecycleScriptRunner.ProjectLifecycleScriptRunner; + const result = yield* runner.runWorktreeRemove({ + projectCwd: "/repo/project", + worktreePath: "/repo/worktrees/a", + }); + + expect(result).toMatchObject({ + status: "completed", + scriptId: "teardown", + scriptName: "Teardown", + lifecycle: "worktree-remove", + exitCode: 0, + }); + expect(run).toHaveBeenCalledWith( + expect.objectContaining({ + command: "sh", + args: ["-c", "echo cleaned"], + cwd: "/repo/worktrees/a", + env: expect.objectContaining({ + T3CODE_PROJECT_ROOT: "/repo/project", + T3CODE_WORKTREE_PATH: "/repo/worktrees/a", + T3CODE_LIFECYCLE: "worktree-remove", + }), + }), + ); + }).pipe(Effect.provide(testLayer(project, run))); + }); + + it.effect("fails when the lifecycle script exits non-zero", () => { + const run = vi.fn(() => + Effect.succeed( + okProcessOutput({ + stderr: "still running\n", + code: ChildProcessSpawner.ExitCode(2), + }), + ), + ); + const project = makeProject([ + { + id: "teardown", + name: "Teardown", + command: "exit 2", + icon: "configure", + runOnWorktreeCreate: false, + runOnWorktreeRemove: true, + }, + ]); + + return Effect.gen(function* () { + const runner = yield* ProjectLifecycleScriptRunner.ProjectLifecycleScriptRunner; + const error = yield* runner + .runWorktreeRemove({ + projectCwd: "/repo/project", + worktreePath: "/repo/worktrees/a", + }) + .pipe(Effect.flip); + + expect(isLifecycleFailed(error)).toBe(true); + if (isLifecycleFailed(error)) { + expect(error.exitCode).toBe(2); + expect(error.scriptName).toBe("Teardown"); + expect(error.stderr).toContain("still running"); + } + }).pipe(Effect.provide(testLayer(project, run))); + }); + + it.effect("runs the pr-merged lifecycle script when requested", () => { + const run = vi.fn(() => Effect.succeed(okProcessOutput())); + const project = makeProject([ + { + id: "merged", + name: "On merge", + command: "echo reaped", + icon: "configure", + runOnWorktreeCreate: false, + runOnPrMerged: true, + }, + ]); + + return Effect.gen(function* () { + const runner = yield* ProjectLifecycleScriptRunner.ProjectLifecycleScriptRunner; + const result = yield* runner.runPrMerged({ + projectCwd: "/repo/project", + worktreePath: "/repo/worktrees/a", + pr: { + number: 42, + url: "https://github.com/org/repo/pull/42", + title: "Ship it", + baseRef: "main", + headRef: "feature/x", + state: "merged", + }, + }); + expect(result.status).toBe("completed"); + expect(run).toHaveBeenCalledWith( + expect.objectContaining({ + env: expect.objectContaining({ + T3CODE_LIFECYCLE: "pr-merged", + T3CODE_PR: "https://github.com/org/repo/pull/42", + T3CODE_PR_NUMBER: "42", + T3CODE_PR_URL: "https://github.com/org/repo/pull/42", + T3CODE_PR_TITLE: "Ship it", + T3CODE_PR_BASE_REF: "main", + T3CODE_PR_HEAD_REF: "feature/x", + T3CODE_PR_STATE: "merged", + }), + }), + ); + }).pipe(Effect.provide(testLayer(project, run))); + }); + + it.effect( + "resolves the project via thread worktree path when cwd is not the workspace root", + () => { + const run = vi.fn(() => Effect.succeed(okProcessOutput())); + const project = makeProject([ + { + id: "merged", + name: "On merge", + command: "echo reaped", + icon: "configure", + runOnWorktreeCreate: false, + runOnPrMerged: true, + }, + ]); + const worktreePath = "/repo/worktrees/a"; + + return Effect.gen(function* () { + const runner = yield* ProjectLifecycleScriptRunner.ProjectLifecycleScriptRunner; + const result = yield* runner.runPrMerged({ + worktreePath, + }); + expect(result.status).toBe("completed"); + expect(run).toHaveBeenCalledWith( + expect.objectContaining({ + cwd: worktreePath, + env: expect.objectContaining({ + T3CODE_PROJECT_ROOT: "/repo/project", + T3CODE_WORKTREE_PATH: worktreePath, + }), + }), + ); + }).pipe(Effect.provide(testLayer(project, run, { worktreePath }))); + }, + ); +}); diff --git a/apps/server/src/project/ProjectLifecycleScriptRunner.ts b/apps/server/src/project/ProjectLifecycleScriptRunner.ts new file mode 100644 index 000000000000..1d7f3702809c --- /dev/null +++ b/apps/server/src/project/ProjectLifecycleScriptRunner.ts @@ -0,0 +1,271 @@ +/** + * ProjectLifecycleScriptRunner - runs blocking project lifecycle scripts + * (worktree remove / PR merged) via a real process, waiting for exit before + * the caller continues (e.g. `git worktree remove`). + * + * Unlike setup scripts (fire-and-forget in a terminal), teardown must finish + * and succeed before filesystem teardown proceeds. + * + * @module ProjectLifecycleScriptRunner + */ +import { type ProjectScript, ProjectId } from "@t3tools/contracts"; +import { + prMergedProjectScript, + projectLifecycleRuntimeEnv, + type ProjectLifecycleKind, + type ProjectLifecyclePrAssociation, + worktreeRemoveProjectScript, +} from "@t3tools/shared/projectScripts"; +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ProcessRunner from "../processRunner.ts"; + +const LIFECYCLE_SCRIPT_TIMEOUT = "10 minutes"; +const LIFECYCLE_SCRIPT_MAX_OUTPUT_BYTES = 1_024 * 1_024; + +export interface ProjectLifecycleScriptRunnerResultNoScript { + readonly status: "no-script"; +} + +export interface ProjectLifecycleScriptRunnerResultCompleted { + readonly status: "completed"; + readonly scriptId: string; + readonly scriptName: string; + readonly lifecycle: ProjectLifecycleKind; + readonly exitCode: number | null; + readonly stdout: string; + readonly stderr: string; +} + +export type ProjectLifecycleScriptRunnerResult = + | ProjectLifecycleScriptRunnerResultNoScript + | ProjectLifecycleScriptRunnerResultCompleted; + +export interface ProjectLifecycleScriptRunnerInput { + readonly projectId?: string; + readonly projectCwd?: string; + readonly worktreePath: string; + /** Linked/associated PR or MR for this lifecycle run, when known. */ + readonly pr?: ProjectLifecyclePrAssociation | null; +} + +export class ProjectLifecycleScriptOperationError extends Schema.TaggedErrorClass()( + "ProjectLifecycleScriptOperationError", + { + lifecycle: Schema.Literals(["worktree-remove", "pr-merged"]), + projectId: Schema.optional(Schema.String), + projectCwd: Schema.optional(Schema.String), + worktreePath: Schema.String, + operation: Schema.Literals(["resolveProject", "runScript"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Project lifecycle script operation '${this.operation}' failed for '${this.lifecycle}' in '${this.worktreePath}'.`; + } +} + +export class ProjectLifecycleScriptFailedError extends Schema.TaggedErrorClass()( + "ProjectLifecycleScriptFailedError", + { + lifecycle: Schema.Literals(["worktree-remove", "pr-merged"]), + projectId: Schema.optional(Schema.String), + projectCwd: Schema.optional(Schema.String), + worktreePath: Schema.String, + scriptId: Schema.String, + scriptName: Schema.String, + exitCode: Schema.NullOr(Schema.Number), + timedOut: Schema.Boolean, + stdout: Schema.String, + stderr: Schema.String, + }, +) { + override get message(): string { + if (this.timedOut) { + return `Lifecycle script '${this.scriptName}' (${this.lifecycle}) timed out in '${this.worktreePath}'.`; + } + const code = this.exitCode === null ? "unknown" : String(this.exitCode); + return `Lifecycle script '${this.scriptName}' (${this.lifecycle}) exited with code ${code} in '${this.worktreePath}'.`; + } +} + +export const ProjectLifecycleScriptRunnerError = Schema.Union([ + ProjectLifecycleScriptOperationError, + ProjectLifecycleScriptFailedError, +]); +export type ProjectLifecycleScriptRunnerError = typeof ProjectLifecycleScriptRunnerError.Type; + +export class ProjectLifecycleScriptRunner extends Context.Service< + ProjectLifecycleScriptRunner, + { + readonly runWorktreeRemove: ( + input: ProjectLifecycleScriptRunnerInput, + ) => Effect.Effect; + readonly runPrMerged: ( + input: ProjectLifecycleScriptRunnerInput, + ) => Effect.Effect; + } +>()("t3/project/ProjectLifecycleScriptRunner") {} + +const selectScript = ( + lifecycle: ProjectLifecycleKind, + scripts: readonly ProjectScript[], +): ProjectScript | null => + lifecycle === "worktree-remove" + ? worktreeRemoveProjectScript(scripts) + : prMergedProjectScript(scripts); + +export const make = Effect.gen(function* () { + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const processRunner = yield* ProcessRunner.ProcessRunner; + const platform = yield* HostProcessPlatform; + const hostEnvironment = yield* HostProcessEnvironment; + + const resolveProject = Effect.fn("ProjectLifecycleScriptRunner.resolveProject")(function* ( + input: ProjectLifecycleScriptRunnerInput, + lifecycle: ProjectLifecycleKind, + ) { + const errorContext = { + lifecycle, + worktreePath: input.worktreePath, + ...(input.projectId === undefined ? {} : { projectId: input.projectId }), + ...(input.projectCwd === undefined ? {} : { projectCwd: input.projectCwd }), + }; + const mapResolveError = (cause: unknown) => + new ProjectLifecycleScriptOperationError({ + ...errorContext, + operation: "resolveProject", + cause, + }); + + if (input.projectId) { + const projectById = yield* projectionSnapshotQuery + .getProjectShellById(ProjectId.make(input.projectId)) + .pipe(Effect.map(Option.getOrUndefined), Effect.mapError(mapResolveError)); + if (projectById) { + return projectById; + } + } + + const candidateRoots = [input.projectCwd, input.worktreePath].filter( + (value): value is string => typeof value === "string" && value.length > 0, + ); + for (const root of candidateRoots) { + const projectByRoot = yield* projectionSnapshotQuery + .getActiveProjectByWorkspaceRoot(root) + .pipe(Effect.map(Option.getOrUndefined), Effect.mapError(mapResolveError)); + if (projectByRoot) { + return projectByRoot; + } + } + + // Status cwd is often a worktree path that is not the project workspace root. + // Resolve via thread shells that link this path. + const shell = yield* projectionSnapshotQuery + .getShellSnapshot() + .pipe(Effect.mapError(mapResolveError)); + const thread = shell.threads.find( + (entry) => entry.worktreePath !== null && entry.worktreePath === input.worktreePath, + ); + if (!thread) { + return null; + } + return ( + shell.projects.find((project) => project.id === thread.projectId) ?? + (yield* projectionSnapshotQuery + .getProjectShellById(thread.projectId) + .pipe(Effect.map(Option.getOrUndefined), Effect.mapError(mapResolveError))) + ); + }); + + const runLifecycle = Effect.fn("ProjectLifecycleScriptRunner.runLifecycle")(function* ( + input: ProjectLifecycleScriptRunnerInput, + lifecycle: ProjectLifecycleKind, + ) { + const errorContext = { + lifecycle, + worktreePath: input.worktreePath, + ...(input.projectId === undefined ? {} : { projectId: input.projectId }), + ...(input.projectCwd === undefined ? {} : { projectCwd: input.projectCwd }), + }; + + const project = yield* resolveProject(input, lifecycle); + if (!project) { + // Bare VCS removes (no T3 project) skip lifecycle scripts rather than failing. + return { status: "no-script" } as const; + } + const script = selectScript(lifecycle, project.scripts); + if (!script) { + return { status: "no-script" } as const; + } + + const lifecycleEnv = projectLifecycleRuntimeEnv({ + project: { cwd: project.workspaceRoot }, + worktreePath: input.worktreePath, + lifecycle, + pr: input.pr ?? null, + }); + const env: NodeJS.ProcessEnv = { + ...hostEnvironment, + ...lifecycleEnv, + }; + + const isWindows = platform === "win32"; + const output = yield* processRunner + .run({ + command: isWindows ? "cmd.exe" : "sh", + args: isWindows ? ["/d", "/s", "/c", script.command] : ["-c", script.command], + cwd: input.worktreePath, + env, + timeout: LIFECYCLE_SCRIPT_TIMEOUT, + outputMode: "truncate", + maxOutputBytes: LIFECYCLE_SCRIPT_MAX_OUTPUT_BYTES, + truncatedMarker: "\n…[truncated]\n", + }) + .pipe( + Effect.mapError( + (cause) => + new ProjectLifecycleScriptOperationError({ + ...errorContext, + operation: "runScript", + cause, + }), + ), + ); + + if (output.timedOut || output.code === null || output.code !== 0) { + return yield* new ProjectLifecycleScriptFailedError({ + ...errorContext, + scriptId: script.id, + scriptName: script.name, + exitCode: output.code, + timedOut: output.timedOut, + stdout: output.stdout, + stderr: output.stderr, + }); + } + + return { + status: "completed", + scriptId: script.id, + scriptName: script.name, + lifecycle, + exitCode: output.code, + stdout: output.stdout, + stderr: output.stderr, + } as const; + }); + + return ProjectLifecycleScriptRunner.of({ + runWorktreeRemove: (input) => runLifecycle(input, "worktree-remove"), + runPrMerged: (input) => runLifecycle(input, "pr-merged"), + }); +}); + +export const layer = Layer.effect(ProjectLifecycleScriptRunner, make); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 1e5339d89cf3..ca66d97b0798 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -107,6 +107,7 @@ import * as PortScanner from "./preview/PortScanner.ts"; import * as BrowserTraceCollector from "./observability/BrowserTraceCollector.ts"; import * as ProjectFaviconResolver from "./project/ProjectFaviconResolver.ts"; import * as T3ProjectFileLoader from "./project/T3ProjectFileLoader.ts"; +import * as ProjectLifecycleScriptRunner from "./project/ProjectLifecycleScriptRunner.ts"; import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; import * as RepositoryIdentityResolver from "./project/RepositoryIdentityResolver.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; @@ -535,10 +536,19 @@ const buildAppUnderTest = (options?: { Layer.provide(T3ProjectFileLoader.layer), ), ); + const projectLifecycleScriptRunnerLayer = Layer.mock( + ProjectLifecycleScriptRunner.ProjectLifecycleScriptRunner, + )({ + runWorktreeRemove: () => Effect.succeed({ status: "no-script" as const }), + runPrMerged: () => Effect.succeed({ status: "no-script" as const }), + }); const gitWorkflowLayer = GitWorkflowService.layer.pipe( Layer.provideMerge(vcsDriverRegistryLayer), Layer.provideMerge(gitVcsDriverLayer), Layer.provideMerge(gitManagerLayer), + // Merge so VcsStatusBroadcaster (which also depends on this service) can + // be provided from the same gitWorkflowLayer output. + Layer.provideMerge(projectLifecycleScriptRunnerLayer), ); const vcsProvisioningLayer = VcsProvisioningService.layer.pipe( Layer.provide(vcsDriverRegistryLayer), diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index d89279a14cf7..03c1444fb02a 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -77,6 +77,7 @@ import * as GitWorkflowService from "./git/GitWorkflowService.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as SourceControlProviderRegistry from "./sourceControl/SourceControlProviderRegistry.ts"; import * as SourceControlRepositoryService from "./sourceControl/SourceControlRepositoryService.ts"; +import * as ProjectLifecycleScriptRunner from "./project/ProjectLifecycleScriptRunner.ts"; import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; import { ObservabilityLive } from "./observability/Layers/Observability.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; @@ -276,6 +277,10 @@ const GitManagerLayerLive = GitManager.layer.pipe( Layer.provideMerge(TextGeneration.layer), ); +const ProjectLifecycleScriptRunnerLayerLive = ProjectLifecycleScriptRunner.layer.pipe( + Layer.provide(ProcessRunner.layer), +); + const GitLayerLive = Layer.empty.pipe( Layer.provideMerge(GitManagerLayerLive), Layer.provideMerge(GitVcsDriver.layer), @@ -284,6 +289,7 @@ const GitLayerLive = Layer.empty.pipe( const GitWorkflowLayerLive = GitWorkflowService.layer.pipe( Layer.provideMerge(VcsDriverRegistryLayerLive), Layer.provideMerge(GitLayerLive), + Layer.provideMerge(ProjectLifecycleScriptRunnerLayerLive), ); const SourceControlRepositoryServiceLayerLive = SourceControlRepositoryService.layer.pipe( diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts index 4ea2885dc72a..9fbf9767d7f6 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.test.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.test.ts @@ -26,6 +26,14 @@ import { GitManagerError } from "@t3tools/contracts"; import * as VcsStatusBroadcaster from "./VcsStatusBroadcaster.ts"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as GitWorkflowService from "../git/GitWorkflowService.ts"; +import * as ProjectLifecycleScriptRunner from "../project/ProjectLifecycleScriptRunner.ts"; + +const lifecycleScriptRunnerMock = Layer.mock( + ProjectLifecycleScriptRunner.ProjectLifecycleScriptRunner, +)({ + runWorktreeRemove: () => Effect.succeed({ status: "no-script" as const }), + runPrMerged: () => Effect.succeed({ status: "no-script" as const }), +}); const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); @@ -79,6 +87,7 @@ function makeTestLayer(state: { return VcsStatusBroadcaster.layer.pipe( Layer.provideMerge(NodeServices.layer), Layer.provide(makeBackgroundPolicyLayer(() => true)), + Layer.provide(lifecycleScriptRunnerMock), Layer.provide( Layer.mock(GitWorkflowService.GitWorkflowService)({ localStatus: () => @@ -221,6 +230,7 @@ describe("VcsStatusBroadcaster", () => { const testLayer = VcsStatusBroadcaster.layer.pipe( Layer.provideMerge(NodeServices.layer), Layer.provide(makeBackgroundPolicyLayer(() => true)), + Layer.provide(lifecycleScriptRunnerMock), Layer.provide( Layer.mock(GitWorkflowService.GitWorkflowService)({ localStatus: () => @@ -329,6 +339,7 @@ describe("VcsStatusBroadcaster", () => { const testLayer = VcsStatusBroadcaster.layer.pipe( Layer.provideMerge(NodeServices.layer), Layer.provide(makeBackgroundPolicyLayer(() => true)), + Layer.provide(lifecycleScriptRunnerMock), Layer.provide( Layer.mock(GitWorkflowService.GitWorkflowService)({ localStatus: (input) => @@ -492,6 +503,7 @@ describe("VcsStatusBroadcaster", () => { const testLayer = VcsStatusBroadcaster.layer.pipe( Layer.provideMerge(NodeServices.layer), Layer.provide(makeBackgroundPolicyLayer(() => true)), + Layer.provide(lifecycleScriptRunnerMock), Layer.provide( Layer.mock(GitWorkflowService.GitWorkflowService)({ localStatus: () => @@ -696,6 +708,120 @@ describe("VcsStatusBroadcaster", () => { }).pipe(Effect.provide(Layer.merge(makeTestLayer(state), TestClock.layer()))); }); + it("detects open→merged PR transitions only", () => { + const open = remoteStatusWithPr; + const merged = { + ...remoteStatusWithPr, + pr: { ...remoteStatusWithPr.pr!, state: "merged" as const }, + }; + assert.isTrue(VcsStatusBroadcaster.didChangeRequestBecomeMerged(open, merged)); + assert.isFalse(VcsStatusBroadcaster.didChangeRequestBecomeMerged(null, merged)); + assert.isFalse(VcsStatusBroadcaster.didChangeRequestBecomeMerged(merged, merged)); + assert.isFalse(VcsStatusBroadcaster.didChangeRequestBecomeMerged(open, open)); + assert.isFalse( + VcsStatusBroadcaster.didChangeRequestBecomeMerged(open, { + ...merged, + pr: { ...merged.pr!, number: 9999 }, + }), + ); + }); + + it.effect("runs pr-merged lifecycle once when remote status transitions open→merged", () => { + const state = { + currentLocalStatus: baseLocalStatus, + currentRemoteStatus: remoteStatusWithPr as VcsStatusRemoteResult | null, + localStatusCalls: 0, + remoteStatusCalls: 0, + localInvalidationCalls: 0, + remoteInvalidationCalls: 0, + }; + const prMergedCalls: Array<{ + cwd: string; + prNumber?: number; + prUrl?: string; + prTitle?: string; + }> = []; + + return Effect.gen(function* () { + const prMergedRan = yield* Deferred.make(); + const testLayer = VcsStatusBroadcaster.layer.pipe( + Layer.provideMerge(NodeServices.layer), + Layer.provide(makeBackgroundPolicyLayer(() => true)), + Layer.provide( + Layer.mock(ProjectLifecycleScriptRunner.ProjectLifecycleScriptRunner)({ + runWorktreeRemove: () => Effect.succeed({ status: "no-script" as const }), + runPrMerged: (input) => + Effect.gen(function* () { + prMergedCalls.push({ + cwd: input.worktreePath, + ...(input.pr + ? { + prNumber: input.pr.number, + prUrl: input.pr.url, + ...(input.pr.title ? { prTitle: input.pr.title } : {}), + } + : {}), + }); + yield* Deferred.succeed(prMergedRan, undefined).pipe(Effect.ignore); + return { status: "no-script" as const }; + }), + }), + ), + Layer.provide( + Layer.mock(GitWorkflowService.GitWorkflowService)({ + localStatus: () => + Effect.sync(() => { + state.localStatusCalls += 1; + return state.currentLocalStatus; + }), + remoteStatus: () => + Effect.sync(() => { + state.remoteStatusCalls += 1; + return state.currentRemoteStatus; + }), + invalidateLocalStatus: () => + Effect.sync(() => { + state.localInvalidationCalls += 1; + }), + invalidateRemoteStatus: () => + Effect.sync(() => { + state.remoteInvalidationCalls += 1; + }), + invalidateStatus: () => + Effect.sync(() => { + state.localInvalidationCalls += 1; + state.remoteInvalidationCalls += 1; + }), + }), + ), + ); + + yield* Effect.gen(function* () { + const broadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + yield* broadcaster.refreshStatus("/repo"); + assert.equal(prMergedCalls.length, 0); + + state.currentRemoteStatus = { + ...remoteStatusWithPr, + pr: { ...remoteStatusWithPr.pr!, state: "merged" }, + }; + yield* broadcaster.refreshStatus("/repo"); + yield* Deferred.await(prMergedRan); + assert.equal(prMergedCalls.length, 1); + assert.deepStrictEqual(prMergedCalls[0], { + cwd: "/repo", + prNumber: 2978, + prUrl: "https://github.com/pingdotgg/t3code/pull/2978", + prTitle: "[codex] Rewrite client connection architecture", + }); + + // Stay merged — do not re-fire. + yield* broadcaster.refreshStatus("/repo"); + assert.equal(prMergedCalls.length, 1); + }).pipe(Effect.provide(testLayer)); + }); + }); + it("backs off remote refresh failures exponentially and honors larger configured intervals", () => { assert.equal( Duration.toMillis(VcsStatusBroadcaster.remoteRefreshFailureDelay(1, Duration.seconds(1))), @@ -752,6 +878,7 @@ describe("VcsStatusBroadcaster", () => { const testLayer = VcsStatusBroadcaster.layer.pipe( Layer.provideMerge(NodeServices.layer), Layer.provide(makeBackgroundPolicyLayer(() => false)), + Layer.provide(lifecycleScriptRunnerMock), Layer.provide( Layer.mock(GitWorkflowService.GitWorkflowService)({ localStatus: () => @@ -805,6 +932,7 @@ describe("VcsStatusBroadcaster", () => { const testLayer = VcsStatusBroadcaster.layer.pipe( Layer.provideMerge(NodeServices.layer), Layer.provide(makeBackgroundPolicyLayer(() => true)), + Layer.provide(lifecycleScriptRunnerMock), Layer.provide( Layer.mock(GitWorkflowService.GitWorkflowService)({ localStatus: () => diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index d8233ca321d1..14b219bc2d88 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -23,6 +23,7 @@ import type { import { mergeGitStatusParts } from "@t3tools/shared/git"; import * as GitWorkflowService from "../git/GitWorkflowService.ts"; +import * as ProjectLifecycleScriptRunner from "../project/ProjectLifecycleScriptRunner.ts"; const DEFAULT_VCS_STATUS_REFRESH_INTERVAL = Duration.seconds(30); /** @@ -163,6 +164,27 @@ export function remoteRefreshFailureDelay( return Duration.max(configuredInterval, cappedBackoff); } +/** + * True only when we observed an open PR on this cwd and it later became merged. + * Skips first-seen already-merged PRs so server restarts / initial polls do not re-fire. + */ +export function didChangeRequestBecomeMerged( + previous: VcsStatusRemoteResult | null | undefined, + next: VcsStatusRemoteResult | null, +): boolean { + if (next?.pr?.state !== "merged") { + return false; + } + if (previous?.pr?.state !== "open") { + return false; + } + return previous.pr.number === next.pr.number; +} + +function prMergedLifecycleKey(cwd: string, prNumber: number): string { + return `${cwd}::${prNumber}`; +} + export class VcsStatusBroadcaster extends Context.Service< VcsStatusBroadcaster, { @@ -192,6 +214,7 @@ const normalizeCwd = (cwd: string) => export const make = Effect.gen(function* () { const workflow = yield* GitWorkflowService.GitWorkflowService; + const lifecycleScriptRunner = yield* ProjectLifecycleScriptRunner.ProjectLifecycleScriptRunner; const fs = yield* FileSystem.FileSystem; const changesPubSub = yield* Effect.acquireRelease( PubSub.unbounded(), @@ -212,6 +235,8 @@ export const make = Effect.gen(function* () { * this so remotes never pile up on top of an in-flight sweep. */ const listSweepMutex = yield* Semaphore.make(1); + // One fire per cwd+PR number for this server process. + const prMergedLifecycleFiredRef = yield* Ref.make(new Set()); const getCachedStatus = Effect.fn("VcsStatusBroadcaster.getCachedStatus")(function* ( cwd: string, @@ -219,6 +244,65 @@ export const make = Effect.gen(function* () { return yield* Ref.get(cacheRef).pipe(Effect.map((cache) => cache.get(cwd) ?? null)); }); + const maybeRunPrMergedLifecycle = Effect.fn("VcsStatusBroadcaster.maybeRunPrMergedLifecycle")( + function* ( + cwd: string, + previousRemote: VcsStatusRemoteResult | null | undefined, + nextRemote: VcsStatusRemoteResult | null, + ) { + if (!didChangeRequestBecomeMerged(previousRemote, nextRemote) || !nextRemote?.pr) { + return; + } + const pr = nextRemote.pr; + const fireKey = prMergedLifecycleKey(cwd, pr.number); + const shouldFire = yield* Ref.modify(prMergedLifecycleFiredRef, (fired) => { + if (fired.has(fireKey)) { + return [false, fired] as const; + } + const next = new Set(fired); + next.add(fireKey); + return [true, next] as const; + }); + if (!shouldFire) { + return; + } + + yield* lifecycleScriptRunner + .runPrMerged({ + projectCwd: cwd, + worktreePath: cwd, + pr: { + number: pr.number, + url: pr.url, + title: pr.title, + baseRef: pr.baseRef, + headRef: pr.headRef, + state: pr.state, + }, + }) + .pipe( + Effect.tap((result) => + result.status === "completed" + ? Effect.logInfo("VcsStatusBroadcaster pr-merged lifecycle completed", { + cwd, + prNumber: pr.number, + scriptId: result.scriptId, + scriptName: result.scriptName, + }) + : Effect.void, + ), + Effect.catch((error) => + Effect.logWarning("VcsStatusBroadcaster pr-merged lifecycle failed", { + cwd, + prNumber: pr.number, + cause: error, + }), + ), + Effect.forkIn(broadcasterScope), + ); + }, + ); + const updateCachedLocalStatus = Effect.fn("VcsStatusBroadcaster.updateCachedLocalStatus")( function* (cwd: string, local: VcsStatusLocalResult, options?: { publish?: boolean }) { const nextLocal = { @@ -255,16 +339,24 @@ export const make = Effect.gen(function* () { fingerprint: fingerprintStatusPart(remote), value: remote, } satisfies CachedValue; - const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { + const { previousRemote, shouldPublish } = yield* Ref.modify(cacheRef, (cache) => { const previous = cache.get(cwd) ?? { local: null, remote: null }; const nextCache = new Map(cache); nextCache.set(cwd, { ...previous, remote: nextRemote, }); - return [previous.remote?.fingerprint !== nextRemote.fingerprint, nextCache] as const; + return [ + { + previousRemote: previous.remote?.value, + shouldPublish: previous.remote?.fingerprint !== nextRemote.fingerprint, + }, + nextCache, + ] as const; }); + yield* maybeRunPrMergedLifecycle(cwd, previousRemote, remote); + if (options?.publish && shouldPublish) { yield* PubSub.publish(changesPubSub, { cwd, @@ -293,7 +385,7 @@ export const make = Effect.gen(function* () { fingerprint: fingerprintStatusPart(remote), value: remote, } satisfies CachedValue; - const shouldPublish = yield* Ref.modify(cacheRef, (cache) => { + const { previousRemote, shouldPublish } = yield* Ref.modify(cacheRef, (cache) => { const previous = cache.get(cwd) ?? { local: null, remote: null }; const nextCache = new Map(cache); nextCache.set(cwd, { @@ -301,12 +393,18 @@ export const make = Effect.gen(function* () { remote: nextRemote, }); return [ - previous.local?.fingerprint !== nextLocal.fingerprint || - previous.remote?.fingerprint !== nextRemote.fingerprint, + { + previousRemote: previous.remote?.value, + shouldPublish: + previous.local?.fingerprint !== nextLocal.fingerprint || + previous.remote?.fingerprint !== nextRemote.fingerprint, + }, nextCache, ] as const; }); + yield* maybeRunPrMergedLifecycle(cwd, previousRemote, remote); + if (options?.publish && shouldPublish) { yield* PubSub.publish(changesPubSub, { cwd, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 744eeb52e688..5a1368b6e855 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -167,6 +167,7 @@ import { decodeProjectScriptKeybindingRule } from "~/lib/projectScriptKeybinding import { type NewProjectScriptInput } from "./ProjectScriptsControl"; import { buildProjectScript, + clearConflictingLifecycleFlags, commandForProjectScript, nextProjectScriptId, projectScriptIdFromCommand, @@ -3139,14 +3140,10 @@ function ChatViewContent(props: ChatViewProps) { activeProject.scripts.map((script) => script.id), ); const nextScript = buildProjectScript(nextId, input); - const nextScripts = input.runOnWorktreeCreate - ? [ - ...activeProject.scripts.map((script) => - script.runOnWorktreeCreate ? { ...script, runOnWorktreeCreate: false } : script, - ), - nextScript, - ] - : [...activeProject.scripts, nextScript]; + const nextScripts = [ + ...activeProject.scripts.map((script) => clearConflictingLifecycleFlags(script, input)), + nextScript, + ]; return persistProjectScripts({ projectId: activeProject.id, @@ -3174,11 +3171,7 @@ function ChatViewContent(props: ChatViewProps) { const updatedScript = buildProjectScript(existingScript.id, input); const nextScripts = activeProject.scripts.map((script) => - script.id === scriptId - ? updatedScript - : input.runOnWorktreeCreate - ? { ...script, runOnWorktreeCreate: false } - : script, + script.id === scriptId ? updatedScript : clearConflictingLifecycleFlags(script, input), ); return persistProjectScripts({ diff --git a/apps/web/src/components/ProjectScriptsControl.tsx b/apps/web/src/components/ProjectScriptsControl.tsx index 3a1d71150988..4f40b0b81efd 100644 --- a/apps/web/src/components/ProjectScriptsControl.tsx +++ b/apps/web/src/components/ProjectScriptsControl.tsx @@ -32,6 +32,7 @@ import { commandForProjectScript, nextProjectScriptId, primaryProjectScript, + projectScriptMenuLabel, } from "~/projectScripts"; import { shortcutLabelForCommand } from "~/keybindings"; import { @@ -100,6 +101,8 @@ export interface NewProjectScriptInput { command: string; icon: ProjectScriptIcon; runOnWorktreeCreate: boolean; + runOnWorktreeRemove: boolean; + runOnPrMerged: boolean; keybinding: string | null; /** Optional URL to open in the in-app preview when this script runs. */ previewUrl: string | null; @@ -148,6 +151,8 @@ export default function ProjectScriptsControl({ const [icon, setIcon] = useState("play"); const [iconPickerOpen, setIconPickerOpen] = useState(false); const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false); + const [runOnWorktreeRemove, setRunOnWorktreeRemove] = useState(false); + const [runOnPrMerged, setRunOnPrMerged] = useState(false); const [keybinding, setKeybinding] = useState(""); const [previewUrl, setPreviewUrl] = useState(""); const [autoOpenPreview, setAutoOpenPreview] = useState(false); @@ -221,6 +226,8 @@ export default function ProjectScriptsControl({ command: trimmedCommand, icon, runOnWorktreeCreate, + runOnWorktreeRemove, + runOnPrMerged, keybinding: keybindingRule?.key ?? null, previewUrl: trimmedPreviewUrl.length > 0 ? trimmedPreviewUrl : null, autoOpenPreview: trimmedPreviewUrl.length > 0 ? autoOpenPreview : false, @@ -251,6 +258,8 @@ export default function ProjectScriptsControl({ setIcon("play"); setIconPickerOpen(false); setRunOnWorktreeCreate(false); + setRunOnWorktreeRemove(false); + setRunOnPrMerged(false); setKeybinding(""); setPreviewUrl(""); setAutoOpenPreview(false); @@ -266,6 +275,8 @@ export default function ProjectScriptsControl({ setIcon(script.icon); setIconPickerOpen(false); setRunOnWorktreeCreate(script.runOnWorktreeCreate); + setRunOnWorktreeRemove(script.runOnWorktreeRemove === true); + setRunOnPrMerged(script.runOnPrMerged === true); setKeybinding(keybindingValueForCommand(keybindings, commandForProjectScript(script.id)) ?? ""); setPreviewUrl(script.previewUrl ?? ""); setAutoOpenPreview(script.autoOpenPreview ?? false); @@ -286,6 +297,8 @@ export default function ProjectScriptsControl({ command: fileScript.command, icon: fileScript.icon ?? "play", runOnWorktreeCreate: fileScript.runOnWorktreeCreate ?? false, + runOnWorktreeRemove: fileScript.runOnWorktreeRemove ?? false, + runOnPrMerged: fileScript.runOnPrMerged ?? false, keybinding: null, previewUrl: fileScript.previewUrl ?? null, autoOpenPreview: fileScript.previewUrl ? (fileScript.autoOpenPreview ?? false) : false, @@ -378,9 +391,7 @@ export default function ProjectScriptsControl({ onClick={() => onRunScript(script)} > - - {script.runOnWorktreeCreate ? `${script.name} (setup)` : script.name} - + {projectScriptMenuLabel(script)} {shortcutLabel && ( @@ -582,6 +593,20 @@ export default function ProjectScriptsControl({ onCheckedChange={(checked) => setRunOnWorktreeCreate(Boolean(checked))} /> + +