authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-07 00:18:04-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-07 00:18:04-07:00
log079e4bccdcffc56c54bd69523ed77c006553ea21
tree4bdb871a9f770b624f003e50a350f472d35ec53a
parenta1830915ecf70e3ad4fbe8efb15ae9f18517eb54
signature Signed by PGP key B5690EEEBB952194

feat(server): run a project action when a worktree thread settles (#16290)

Co-authored-by: spoukyii <61633921+spoukyii@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

20 files changed, 409 insertions(+), 56 deletions(-)

apps/mobile/src/features/terminal/terminalMenu.ts-4
...@@ -156,10 +156,6 @@ export function resolveProjectScriptTerminalId(input: {...@@ -156,10 +156,6 @@ export function resolveProjectScriptTerminalId(input: {
156 return nextTerminalId(input.existingTerminalIds, input.uniqueSuffix);156 return nextTerminalId(input.existingTerminalIds, input.uniqueSuffix);
157}157}
158158
159export function projectScriptMenuLabel(script: ProjectScript): string {
160 return script.runOnWorktreeCreate ? `${script.name} (setup)` : script.name;
161}
162
163export function projectScriptMenuIcon(icon: ProjectScript["icon"]) {159export function projectScriptMenuIcon(icon: ProjectScript["icon"]) {
164 if (icon === "test") return "flask";160 if (icon === "test") return "flask";
165 if (icon === "lint") return "checklist";161 if (icon === "lint") return "checklist";
apps/mobile/src/features/threads/ThreadGitControls.tsx+1-1
...@@ -23,9 +23,9 @@ import {...@@ -23,9 +23,9 @@ import {
23 basename,23 basename,
24 getTerminalStatusLabel,24 getTerminalStatusLabel,
25 projectScriptMenuIcon,25 projectScriptMenuIcon,
26 projectScriptMenuLabel,
27 type TerminalMenuSession,26 type TerminalMenuSession,
28} from "../terminal/terminalMenu";27} from "../terminal/terminalMenu";
28import { projectScriptMenuLabel } from "@t3tools/shared/projectScripts";
2929
30function truncateMiddle(value: string, maxLength: number): string {30function truncateMiddle(value: string, maxLength: number): string {
31 if (value.length <= maxLength) {31 if (value.length <= maxLength) {
apps/server/src/orchestration-v2/ThreadSettlementService.test.ts+132-2
...@@ -30,6 +30,7 @@ import * as Stream from "effect/Stream";...@@ -30,6 +30,7 @@ import * as Stream from "effect/Stream";
30import { TestClock } from "effect/testing";30import { TestClock } from "effect/testing";
3131
32import * as GitManager from "../git/GitManager.ts";32import * as GitManager from "../git/GitManager.ts";
33import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts";
33import * as PullRequestService from "../pullRequest/PullRequestService.ts";34import * as PullRequestService from "../pullRequest/PullRequestService.ts";
34import * as ServerActivation from "../serverActivation.ts";35import * as ServerActivation from "../serverActivation.ts";
35import * as ServerSettings from "../serverSettings.ts";36import * as ServerSettings from "../serverSettings.ts";
...@@ -486,6 +487,12 @@ interface HarnessOptions {...@@ -486,6 +487,12 @@ interface HarnessOptions {
486 readonly pullRequestSummary?: PullRequestService.PullRequestService["Service"]["summary"];487 readonly pullRequestSummary?: PullRequestService.PullRequestService["Service"]["summary"];
487 readonly existingWorktreePaths?: ReadonlyArray<string>;488 readonly existingWorktreePaths?: ReadonlyArray<string>;
488 readonly onDispatch?: (command: AutoSettleCommand) => Effect.Effect<void>;489 readonly onDispatch?: (command: AutoSettleCommand) => Effect.Effect<void>;
490 /** Runs before each settle script start; a failure fails that start. */
491 readonly onScriptRun?: (
492 input: ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput,
493 ) => Effect.Effect<void>;
494 /** Runs inside each `closeIdle` call. */
495 readonly onCloseIdle?: () => Effect.Effect<void>;
489 /** Threads `getThread` returns when a `thread.settled` event is handled. */496 /** Threads `getThread` returns when a `thread.settled` event is handled. */
490 readonly currentThreads?: ReadonlyArray<OrchestrationV2AppThread>;497 readonly currentThreads?: ReadonlyArray<OrchestrationV2AppThread>;
491}498}
...@@ -514,6 +521,8 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:...@@ -514,6 +521,8 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:
514 const invalidatedCwds = yield* Ref.make<ReadonlyArray<string>>([]);521 const invalidatedCwds = yield* Ref.make<ReadonlyArray<string>>([]);
515 const domainEvents = yield* PubSub.unbounded<OrchestrationV2DomainEvent>();522 const domainEvents = yield* PubSub.unbounded<OrchestrationV2DomainEvent>();
516 const closedIdle = yield* Queue.unbounded<{ readonly threadId: string }>();523 const closedIdle = yield* Queue.unbounded<{ readonly threadId: string }>();
524 const scriptRuns =
525 yield* Queue.unbounded<ProjectSetupScriptRunner.ProjectSetupScriptRunnerInput>();
517526
518 const updateSettings = (patch: ServerSettingsPatch) =>527 const updateSettings = (patch: ServerSettingsPatch) =>
519 Effect.gen(function* () {528 Effect.gen(function* () {
...@@ -599,7 +608,15 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:...@@ -599,7 +608,15 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:
599 dispatch,608 dispatch,
600 }),609 }),
601 Layer.mock(TerminalManager.TerminalManager)({610 Layer.mock(TerminalManager.TerminalManager)({
602 closeIdle: (input) => Queue.offer(closedIdle, input).pipe(Effect.asVoid),611 closeIdle: (input) =>
612 Queue.offer(closedIdle, input).pipe(Effect.andThen(options.onCloseIdle?.() ?? Effect.void)),
613 }),
614 Layer.mock(ProjectSetupScriptRunner.ProjectSetupScriptRunner)({
615 runForThread: (input) =>
616 Queue.offer(scriptRuns, input).pipe(
617 Effect.andThen(options.onScriptRun?.(input) ?? Effect.void),
618 Effect.as({ status: "no-script" } as const),
619 ),
603 }),620 }),
604 Layer.mock(GitManager.GitManager)({621 Layer.mock(GitManager.GitManager)({
605 branchPullRequest,622 branchPullRequest,
...@@ -631,6 +648,7 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:...@@ -631,6 +648,7 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options:
631 summaryRecovery,648 summaryRecovery,
632 invalidatedCwds,649 invalidatedCwds,
633 closedIdle,650 closedIdle,
651 scriptRuns,
634 publishEvent: (event: OrchestrationV2DomainEvent) => PubSub.publish(domainEvents, event),652 publishEvent: (event: OrchestrationV2DomainEvent) => PubSub.publish(domainEvents, event),
635 updateSettings,653 updateSettings,
636 publishMerge: PubSub.publish(mergedPullRequests, {654 publishMerge: PubSub.publish(mergedPullRequests, {
...@@ -1010,6 +1028,7 @@ describe("ThreadSettlementServiceV2 terminals", () => {...@@ -1010,6 +1028,7 @@ describe("ThreadSettlementServiceV2 terminals", () => {
1010 const appThread = (1028 const appThread = (
1011 id: string,1029 id: string,
1012 settledOverride: OrchestrationV2AppThread["settledOverride"],1030 settledOverride: OrchestrationV2AppThread["settledOverride"],
1031 worktreePath: string | null = null,
1013 ): OrchestrationV2AppThread => {1032 ): OrchestrationV2AppThread => {
1014 const threadId = ThreadId.make(id);1033 const threadId = ThreadId.make(id);
1015 return {1034 return {
...@@ -1023,7 +1042,7 @@ describe("ThreadSettlementServiceV2 terminals", () => {...@@ -1023,7 +1042,7 @@ describe("ThreadSettlementServiceV2 terminals", () => {
1023 runtimeMode: "full-access",1042 runtimeMode: "full-access",
1024 interactionMode: "default",1043 interactionMode: "default",
1025 branch: null,1044 branch: null,
1026 worktreePath: null,1045 worktreePath,
1027 activeProviderThreadId: null,1046 activeProviderThreadId: null,
1028 lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId },1047 lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId },
1029 forkedFrom: null,1048 forkedFrom: null,
...@@ -1086,6 +1105,117 @@ describe("ThreadSettlementServiceV2 terminals", () => {...@@ -1086,6 +1105,117 @@ describe("ThreadSettlementServiceV2 terminals", () => {
1086 }),1105 }),
1087 ),1106 ),
1088 );1107 );
1108
1109 it.effect("runs the settle script in the thread's own worktree", () =>
1110 Effect.scoped(
1111 Effect.gen(function* () {
1112 const shared = appThread("shared-checkout-thread", "settled");
1113 const missing = appThread("removed-worktree-thread", "settled", "/worktrees/removed");
1114 const worktree = appThread("worktree-thread", "settled", "/worktrees/thread");
1115 const fixture = yield* makeHarness({
1116 snapshot: makeSnapshot([]),
1117 currentThreads: [shared, missing, worktree],
1118 existingWorktreePaths: ["/worktrees/thread"],
1119 });
1120
1121 yield* Effect.gen(function* () {
1122 const service = yield* ThreadSettlementService.ThreadSettlementServiceV2;
1123 yield* startHarness(service, fixture.activation, fixture.snapshotReads);
1124 // Events run in order, so the first run belongs to the last event
1125 // only if the shared checkout and the removed worktree were skipped.
1126 yield* fixture.publishEvent(settledEvent(shared));
1127 yield* fixture.publishEvent(settledEvent(missing));
1128 yield* fixture.publishEvent(settledEvent(worktree));
1129 const run = yield* Queue.take(fixture.scriptRuns);
1130 assert.strictEqual(run.threadId, worktree.id);
1131 assert.strictEqual(run.worktreePath, "/worktrees/thread");
1132 assert.strictEqual(run.trigger, "settle");
1133 }).pipe(Effect.provide(fixture.layer));
1134 }),
1135 ),
1136 );
1137
1138 it.effect("skips the settle script for a thread re-engaged while its shells close", () =>
1139 Effect.scoped(
1140 Effect.gen(function* () {
1141 const settled = appThread("reengaging-thread", "settled", "/worktrees/reengaging");
1142 const marker = appThread("marker-thread", "settled", "/worktrees/marker");
1143 const currentThreads = [settled, marker];
1144 const fixture = yield* makeHarness({
1145 snapshot: makeSnapshot([]),
1146 currentThreads,
1147 existingWorktreePaths: ["/worktrees/reengaging", "/worktrees/marker"],
1148 // The user sends a message while the first close checks processes.
1149 onCloseIdle: () =>
1150 Effect.sync(() => {
1151 currentThreads[0] = { ...settled, settledOverride: "active" };
1152 }),
1153 });
1154
1155 yield* Effect.gen(function* () {
1156 const service = yield* ThreadSettlementService.ThreadSettlementServiceV2;
1157 yield* startHarness(service, fixture.activation, fixture.snapshotReads);
1158 yield* fixture.publishEvent(settledEvent(settled));
1159 yield* fixture.publishEvent(settledEvent(marker));
1160 assert.strictEqual((yield* Queue.take(fixture.scriptRuns)).threadId, marker.id);
1161 }).pipe(Effect.provide(fixture.layer));
1162 }),
1163 ),
1164 );
1165
1166 it.effect("runs the settle script once when a settlement is re-emitted", () =>
1167 Effect.scoped(
1168 Effect.gen(function* () {
1169 const repeated = appThread("repeated-thread", "settled", "/worktrees/repeated");
1170 const marker = appThread("marker-thread", "settled", "/worktrees/marker");
1171 const fixture = yield* makeHarness({
1172 snapshot: makeSnapshot([]),
1173 currentThreads: [repeated, marker],
1174 existingWorktreePaths: ["/worktrees/repeated", "/worktrees/marker"],
1175 });
1176
1177 yield* Effect.gen(function* () {
1178 const service = yield* ThreadSettlementService.ThreadSettlementServiceV2;
1179 yield* startHarness(service, fixture.activation, fixture.snapshotReads);
1180 // Settling an already settled thread emits the same settlement again.
1181 yield* fixture.publishEvent(settledEvent(repeated));
1182 yield* fixture.publishEvent(settledEvent(repeated));
1183 yield* fixture.publishEvent(settledEvent(marker));
1184 assert.strictEqual((yield* Queue.take(fixture.scriptRuns)).threadId, repeated.id);
1185 assert.strictEqual((yield* Queue.take(fixture.scriptRuns)).threadId, marker.id);
1186 }).pipe(Effect.provide(fixture.layer));
1187 }),
1188 ),
1189 );
1190
1191 it.effect("retries the settle script on a re-emitted settlement after a failed start", () =>
1192 Effect.scoped(
1193 Effect.gen(function* () {
1194 const thread = appThread("failed-start-thread", "settled", "/worktrees/failed-start");
1195 const starts = yield* Ref.make(0);
1196 const fixture = yield* makeHarness({
1197 snapshot: makeSnapshot([]),
1198 currentThreads: [thread],
1199 existingWorktreePaths: ["/worktrees/failed-start"],
1200 onScriptRun: () =>
1201 Ref.updateAndGet(starts, (count) => count + 1).pipe(
1202 Effect.flatMap((count) =>
1203 count === 1 ? Effect.die(new Error("terminal failed to open")) : Effect.void,
1204 ),
1205 ),
1206 });
1207
1208 yield* Effect.gen(function* () {
1209 const service = yield* ThreadSettlementService.ThreadSettlementServiceV2;
1210 yield* startHarness(service, fixture.activation, fixture.snapshotReads);
1211 yield* fixture.publishEvent(settledEvent(thread));
1212 yield* fixture.publishEvent(settledEvent(thread));
1213 assert.strictEqual((yield* Queue.take(fixture.scriptRuns)).threadId, thread.id);
1214 assert.strictEqual((yield* Queue.take(fixture.scriptRuns)).threadId, thread.id);
1215 }).pipe(Effect.provide(fixture.layer));
1216 }),
1217 ),
1218 );
1089});1219});
10901220
1091describe("ThreadSettlementServiceV2 single-thread sweeps", () => {1221describe("ThreadSettlementServiceV2 single-thread sweeps", () => {
apps/server/src/orchestration-v2/ThreadSettlementService.ts+33-5
...@@ -20,6 +20,7 @@ import type * as Scope from "effect/Scope";...@@ -20,6 +20,7 @@ import type * as Scope from "effect/Scope";
20import * as Stream from "effect/Stream";20import * as Stream from "effect/Stream";
2121
22import * as GitManager from "../git/GitManager.ts";22import * as GitManager from "../git/GitManager.ts";
23import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts";
23import * as PullRequestService from "../pullRequest/PullRequestService.ts";24import * as PullRequestService from "../pullRequest/PullRequestService.ts";
24import * as ServerSettings from "../serverSettings.ts";25import * as ServerSettings from "../serverSettings.ts";
25import { forkParked } from "../serverActivation.ts";26import { forkParked } from "../serverActivation.ts";
...@@ -265,6 +266,10 @@ export const make = Effect.gen(function* () {...@@ -265,6 +266,10 @@ export const make = Effect.gen(function* () {
265 const crypto = yield* Crypto.Crypto;266 const crypto = yield* Crypto.Crypto;
266 const fileSystem = yield* FileSystem.FileSystem;267 const fileSystem = yield* FileSystem.FileSystem;
267 const terminals = yield* TerminalManager.TerminalManager;268 const terminals = yield* TerminalManager.TerminalManager;
269 const projectScripts = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner;
270 // Settling a settled thread re-emits thread.settled with the same settledAt,
271 // so this keeps the settle action to one run per settlement.
272 const settleActionRunAt = new Map<ThreadId, number>();
268273
269 const sweep = Effect.fn("ThreadSettlementServiceV2.sweep")(function* (274 const sweep = Effect.fn("ThreadSettlementServiceV2.sweep")(function* (
270 mergedPullRequest: PullRequestService.PullRequestMergeEvent | null,275 mergedPullRequest: PullRequestService.PullRequestMergeEvent | null,
...@@ -514,20 +519,43 @@ export const make = Effect.gen(function* () {...@@ -514,20 +519,43 @@ export const make = Effect.gen(function* () {
514519
515 // Settling closes the thread's shells that sit at an idle prompt, so they stop520 // Settling closes the thread's shells that sit at an idle prompt, so they stop
516 // holding the worktree. A terminal running a command (a dev server, an521 // holding the worktree. A terminal running a command (a dev server, an
517 // editor) stays for the user to close.522 // editor) stays for the user to close. Then the project's settle script runs
518 const closeIdleTerminals = Effect.fn("ThreadSettlementServiceV2.closeIdleTerminals")(523 // in the thread's own worktree; a thread in the shared checkout skips it,
524 // because other threads may still be working there.
525 const cleanUpSettledThread = Effect.fn("ThreadSettlementServiceV2.cleanUpSettledThread")(
519 function* (threadId: ThreadId) {526 function* (threadId: ThreadId) {
520 // A thread re-engaged before this event ran keeps its shells.527 // A thread re-engaged before this event ran keeps its shells.
528 const settled = yield* projections.getThread(threadId);
529 if (settled.settledOverride !== "settled") return;
530 yield* terminals.closeIdle({ threadId });
531 const worktreePath = settled.worktreePath;
532 if (worktreePath === null || !(yield* fileSystem.exists(worktreePath))) return;
533 // Closing and the worktree check wait on I/O. A thread re-engaged
534 // meanwhile is working again, so its worktree is no place for cleanup.
521 const thread = yield* projections.getThread(threadId);535 const thread = yield* projections.getThread(threadId);
522 if (thread.settledOverride !== "settled") return;536 if (thread.settledOverride !== "settled") return;
523 yield* terminals.closeIdle({ threadId });537 const settledAtMs = toMillis(thread.settledAt);
538 if (settledAtMs === null || settleActionRunAt.get(threadId) === settledAtMs) return;
539 const run = yield* projectScripts.runForThread({
540 threadId,
541 projectId: thread.projectId,
542 worktreePath,
543 trigger: "settle",
544 // A clean exit closes the script's shell so it does not hold the worktree.
545 observeCompletion: {},
546 });
547 // Recorded after a successful start, so a failed start retries on the next event.
548 settleActionRunAt.set(threadId, settledAtMs);
549 if (run.status === "started" && run.completion) {
550 yield* run.completion.pipe(Effect.forkDetach);
551 }
524 },552 },
525 (effect, threadId) =>553 (effect, threadId) =>
526 effect.pipe(554 effect.pipe(
527 Effect.catchCause((cause) =>555 Effect.catchCause((cause) =>
528 Cause.hasInterruptsOnly(cause)556 Cause.hasInterruptsOnly(cause)
529 ? Effect.failCause(cause)557 ? Effect.failCause(cause)
530 : Effect.logWarning("closing idle terminals after settlement failed", {558 : Effect.logWarning("cleaning up a settled thread failed", {
531 threadId,559 threadId,
532 cause: Cause.pretty(cause),560 cause: Cause.pretty(cause),
533 }),561 }),
...@@ -538,7 +566,7 @@ export const make = Effect.gen(function* () {...@@ -538,7 +566,7 @@ export const make = Effect.gen(function* () {
538 const processEvent = (event: OrchestrationV2DomainEvent) => {566 const processEvent = (event: OrchestrationV2DomainEvent) => {
539 switch (event.type) {567 switch (event.type) {
540 case "thread.settled":568 case "thread.settled":
541 return closeIdleTerminals(event.threadId);569 return cleanUpSettledThread(event.threadId);
542 case "thread.pull-request-synced":570 case "thread.pull-request-synced":
543 case "provider-session.detached":571 case "provider-session.detached":
544 return worker.enqueue(event.threadId);572 return worker.enqueue(event.threadId);
apps/server/src/project/ProjectSetupScriptRunner.test.ts+62-1
...@@ -2,6 +2,7 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto";...@@ -2,6 +2,7 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
2import { assert, it, vi } from "@effect/vitest";2import { assert, it, vi } from "@effect/vitest";
3import { ProjectId } from "@t3tools/contracts";3import { ProjectId } from "@t3tools/contracts";
4import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
5import * as Fiber from "effect/Fiber";
5import * as Layer from "effect/Layer";6import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";7import * as Option from "effect/Option";
78
...@@ -29,6 +30,9 @@ it.effect("resolves setup scripts through the standalone project service", () =>...@@ -29,6 +30,9 @@ it.effect("resolves setup scripts through the standalone project service", () =>
29 const write = vi.fn(30 const write = vi.fn(
30 (_input: Parameters<TerminalManager.TerminalManager["Service"]["write"]>[0]) => Effect.void,31 (_input: Parameters<TerminalManager.TerminalManager["Service"]["write"]>[0]) => Effect.void,
31 );32 );
33 const closeIdle = vi.fn(
34 (_input: Parameters<TerminalManager.TerminalManager["Service"]["closeIdle"]>[0]) => Effect.void,
35 );
32 const listeners: Array<Parameters<TerminalManager.TerminalManager["Service"]["subscribe"]>[0]> =36 const listeners: Array<Parameters<TerminalManager.TerminalManager["Service"]["subscribe"]>[0]> =
33 [];37 [];
34 const subscribe: TerminalManager.TerminalManager["Service"]["subscribe"] = (listener) =>38 const subscribe: TerminalManager.TerminalManager["Service"]["subscribe"] = (listener) =>
...@@ -52,6 +56,14 @@ it.effect("resolves setup scripts through the standalone project service", () =>...@@ -52,6 +56,14 @@ it.effect("resolves setup scripts through the standalone project service", () =>
52 icon: "configure" as const,56 icon: "configure" as const,
53 runOnWorktreeCreate: true,57 runOnWorktreeCreate: true,
54 },58 },
59 {
60 id: "clean",
61 name: "Clean",
62 command: "cargo clean",
63 icon: "build" as const,
64 runOnWorktreeCreate: false,
65 runOnSettle: true,
66 },
55 ],67 ],
56 createdAt: "2026-06-20T00:00:00.000Z",68 createdAt: "2026-06-20T00:00:00.000Z",
57 updatedAt: "2026-06-20T00:00:00.000Z",69 updatedAt: "2026-06-20T00:00:00.000Z",
...@@ -63,7 +75,7 @@ it.effect("resolves setup scripts through the standalone project service", () =>...@@ -63,7 +75,7 @@ it.effect("resolves setup scripts through the standalone project service", () =>
63 Layer.mock(ProjectService.ProjectService)({75 Layer.mock(ProjectService.ProjectService)({
64 getById: () => Effect.succeed(Option.some(project)),76 getById: () => Effect.succeed(Option.some(project)),
65 }),77 }),
66 Layer.mock(TerminalManager.TerminalManager)({ open, write, subscribe }),78 Layer.mock(TerminalManager.TerminalManager)({ open, write, subscribe, closeIdle }),
67 ServerSettings.layerTest(),79 ServerSettings.layerTest(),
68 NodeCrypto.layer,80 NodeCrypto.layer,
69 ),81 ),
...@@ -117,5 +129,54 @@ it.effect("resolves setup scripts through the standalone project service", () =>...@@ -117,5 +129,54 @@ it.effect("resolves setup scripts through the standalone project service", () =>
117 });129 });
118 assert.deepEqual(lines, ["Downloading 10%", "Downloading 20%", "Done"]);130 assert.deepEqual(lines, ["Downloading 10%", "Downloading 20%", "Done"]);
119 yield* listener({ type: "closed", threadId: "thread-1", terminalId: "setup-setup" });131 yield* listener({ type: "closed", threadId: "thread-1", terminalId: "setup-setup" });
132
133 const settle = yield* runner.runForThread({
134 threadId: "thread-1",
135 projectId,
136 worktreePath: "/repo-worktree",
137 trigger: "settle",
138 });
139 const settleTerminalId = settle.status === "started" ? settle.terminalId : "";
140 assert.match(settleTerminalId, /^settle-clean-/);
141 assert.equal(write.mock.calls.at(-1)?.[0].data, "cargo clean\r");
142
143 // A clean run closes its shell once the prompt is back, not at the
144 // sentinel, so the prompt redraw is not taken for new activity.
145 const observedSettle = yield* runner.runForThread({
146 threadId: "thread-1",
147 projectId,
148 worktreePath: "/repo-worktree",
149 trigger: "settle",
150 observeCompletion: {},
151 });
152 const observedTerminalId = observedSettle.status === "started" ? observedSettle.terminalId : "";
153 // Each settle gets its own shell, so a busy one is never typed into.
154 assert.notEqual(observedTerminalId, settleTerminalId);
155 const token = /__T3_SETUP_DONE___(\w+):/.exec(write.mock.calls.at(-1)?.[0].data ?? "")?.[1];
156 const settleListener = listeners.at(-1)!;
157 const completion = yield* Effect.forkChild(
158 observedSettle.status === "started" && observedSettle.completion
159 ? observedSettle.completion
160 : Effect.die("no completion"),
161 );
162 yield* settleListener({
163 type: "output",
164 threadId: "thread-1",
165 terminalId: observedTerminalId,
166 data: `\r\n__T3_SETUP_DONE___${token}:0\r\n`,
167 });
168 yield* Effect.yieldNow;
169 assert.equal(closeIdle.mock.calls.length, 0);
170 yield* settleListener({
171 type: "output",
172 threadId: "thread-1",
173 terminalId: observedTerminalId,
174 data: "$ ",
175 });
176 assert.deepEqual((yield* Fiber.join(completion)).exitCode, 0);
177 assert.deepEqual(closeIdle.mock.calls[0]?.[0], {
178 threadId: "thread-1",
179 terminalId: observedTerminalId,
180 });
120 }).pipe(Effect.provide(layer));181 }).pipe(Effect.provide(layer));
121});182});
apps/server/src/project/ProjectSetupScriptRunner.ts+30-4
...@@ -3,6 +3,7 @@ import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hos...@@ -3,6 +3,7 @@ import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hos
3import {3import {
4 projectScriptRuntimeEnv,4 projectScriptRuntimeEnv,
5 resolveProjectScripts,5 resolveProjectScripts,
6 settleProjectScript,
6 setupProjectScript,7 setupProjectScript,
7} from "@t3tools/shared/projectScripts";8} from "@t3tools/shared/projectScripts";
89
...@@ -60,6 +61,8 @@ export interface ProjectSetupScriptRunnerInput {...@@ -60,6 +61,8 @@ export interface ProjectSetupScriptRunnerInput {
60 readonly projectCwd?: string;61 readonly projectCwd?: string;
61 readonly worktreePath: string;62 readonly worktreePath: string;
62 readonly preferredTerminalId?: string;63 readonly preferredTerminalId?: string;
64 /** Which project script to run. Defaults to the worktree setup script. */
65 readonly trigger?: "setup" | "settle";
63 readonly project?: {66 readonly project?: {
64 readonly id: ProjectId;67 readonly id: ProjectId;
65 readonly workspaceRoot: string;68 readonly workspaceRoot: string;
...@@ -225,6 +228,9 @@ export const make = Effect.gen(function* () {...@@ -225,6 +228,9 @@ export const make = Effect.gen(function* () {
225 Effect.gen(function* () {228 Effect.gen(function* () {
226 const startedAtMs = yield* Clock.currentTimeMillis;229 const startedAtMs = yield* Clock.currentTimeMillis;
227 const done = yield* Deferred.make<ProjectSetupScriptCompletion>();230 const done = yield* Deferred.make<ProjectSetupScriptCompletion>();
231 // The shell redraws its prompt just after the sentinel. Closing before
232 // that would read the redraw as new activity and keep an idle shell.
233 const promptReturned = yield* Deferred.make<void>();
228 let lineBuffer = "";234 let lineBuffer = "";
229 let settled = false;235 let settled = false;
230236
...@@ -278,15 +284,26 @@ export const make = Effect.gen(function* () {...@@ -278,15 +284,26 @@ export const make = Effect.gen(function* () {
278 if (lineBuffer.length > PARTIAL_LINE_MAX_LENGTH) {284 if (lineBuffer.length > PARTIAL_LINE_MAX_LENGTH) {
279 lineBuffer = lineBuffer.slice(-PARTIAL_LINE_MAX_LENGTH);285 lineBuffer = lineBuffer.slice(-PARTIAL_LINE_MAX_LENGTH);
280 }286 }
281 return Effect.forEach(lines, handleLine, { discard: true });287 return Effect.forEach(lines, handleLine, { discard: true }).pipe(
288 // A prompt has no newline, so it is what remains once the sentinel is in.
289 Effect.andThen(
290 Effect.suspend(() =>
291 settled && lineBuffer.length > 0
292 ? Deferred.succeed(promptReturned, undefined).pipe(Effect.asVoid)
293 : Effect.void,
294 ),
295 ),
296 );
282 }297 }
283 if (event.type === "exited" || event.type === "closed") {298 if (event.type === "exited" || event.type === "closed") {
284 return settle(null);299 return settle(null).pipe(Effect.andThen(Deferred.succeed(promptReturned, undefined)));
285 }300 }
286 return Effect.void;301 return Effect.void;
287 });302 });
288303
289 const completion = Deferred.await(done).pipe(304 const completion = Deferred.await(done).pipe(
305 // A shell with an empty prompt never prints one; do not wait forever.
306 Effect.tap(() => Deferred.await(promptReturned).pipe(Effect.timeoutOption("1 second"))),
290 Effect.ensuring(Effect.sync(() => unsubscribe())),307 Effect.ensuring(Effect.sync(() => unsubscribe())),
291 );308 );
292 return { completion, unsubscribe };309 return { completion, unsubscribe };
...@@ -348,14 +365,23 @@ export const make = Effect.gen(function* () {...@@ -348,14 +365,23 @@ export const make = Effect.gen(function* () {
348 }),365 }),
349 ),366 ),
350 );367 );
351 const script = setupProjectScript(resolveProjectScripts(settings, project));368 const trigger = input.trigger ?? "setup";
369 const scripts = resolveProjectScripts(settings, project);
370 const script =
371 trigger === "settle" ? settleProjectScript(scripts) : setupProjectScript(scripts);
352 if (!script) {372 if (!script) {
353 return {373 return {
354 status: "no-script",374 status: "no-script",
355 } as const;375 } as const;
356 }376 }
357377
358 const terminalId = input.preferredTerminalId ?? `setup-${script.id}`;378 // A thread settles again after it is resumed, and an earlier settle shell
379 // may still be busy; typing into it would feed its foreground program.
380 const terminalId =
381 input.preferredTerminalId ??
382 (trigger === "settle"
383 ? `settle-${script.id}-${(yield* crypto.randomUUIDv4.pipe(Effect.orDie)).slice(0, 8)}`
384 : `setup-${script.id}`);
359 const cwd = input.worktreePath;385 const cwd = input.worktreePath;
360 const env = {386 const env = {
361 ...projectScriptRuntimeEnv({387 ...projectScriptRuntimeEnv({
apps/web/src/components/ChatView.tsx+6-13
...@@ -320,6 +320,7 @@ import {...@@ -320,6 +320,7 @@ import {
320 commandForProjectScript,320 commandForProjectScript,
321 nextProjectScriptId,321 nextProjectScriptId,
322 projectScriptIdFromCommand,322 projectScriptIdFromCommand,
323 releaseClaimedRoles,
323} from "~/projectScripts";324} from "~/projectScripts";
324import { newDraftId, newMessageId, newThreadId } from "~/lib/utils";325import { newDraftId, newMessageId, newThreadId } from "~/lib/utils";
325import { registerFaviconProjectForThread } from "~/browserFaviconStore";326import { registerFaviconProjectForThread } from "~/browserFaviconStore";
...@@ -5231,14 +5232,10 @@ export default function ChatView(props: ChatViewProps) {...@@ -5231,14 +5232,10 @@ export default function ChatView(props: ChatViewProps) {
5231 activeProjectScripts.map((script) => script.id),5232 activeProjectScripts.map((script) => script.id),
5232 );5233 );
5233 const nextScript = buildProjectScript(nextId, input);5234 const nextScript = buildProjectScript(nextId, input);
5234 const nextScripts = input.runOnWorktreeCreate5235 const nextScripts = [
5235 ? [5236 ...activeProjectScripts.map((script) => releaseClaimedRoles(script, input)),
5236 ...activeProjectScripts.map((script) =>5237 nextScript,
5237 script.runOnWorktreeCreate ? { ...script, runOnWorktreeCreate: false } : script,5238 ];
5238 ),
5239 nextScript,
5240 ]
5241 : [...activeProjectScripts, nextScript];
52425239
5243 return persistProjectScripts({5240 return persistProjectScripts({
5244 projectId: activeProject.id,5241 projectId: activeProject.id,
...@@ -5266,11 +5263,7 @@ export default function ChatView(props: ChatViewProps) {...@@ -5266,11 +5263,7 @@ export default function ChatView(props: ChatViewProps) {
52665263
5267 const updatedScript = buildProjectScript(existingScript.id, input);5264 const updatedScript = buildProjectScript(existingScript.id, input);
5268 const nextScripts = activeProjectScripts.map((script) =>5265 const nextScripts = activeProjectScripts.map((script) =>
5269 script.id === scriptId5266 script.id === scriptId ? updatedScript : releaseClaimedRoles(script, input),
5270 ? updatedScript
5271 : input.runOnWorktreeCreate
5272 ? { ...script, runOnWorktreeCreate: false }
5273 : script,
5274 );5267 );
52755268
5276 return persistProjectScripts({5269 return persistProjectScripts({
apps/web/src/components/ProjectScriptsControl.tsx+10-18
...@@ -12,6 +12,7 @@ import {...@@ -12,6 +12,7 @@ import {
12 isAtomCommandInterrupted,12 isAtomCommandInterrupted,
13 squashAtomCommandFailure,13 squashAtomCommandFailure,
14} from "@t3tools/client-runtime/state/runtime";14} from "@t3tools/client-runtime/state/runtime";
15import { projectScriptMenuLabel } from "@t3tools/shared/projectScripts";
15import { ChevronDownIcon, DownloadIcon, PlusIcon, SettingsIcon, WrenchIcon } from "lucide-react";16import { ChevronDownIcon, DownloadIcon, PlusIcon, SettingsIcon, WrenchIcon } from "lucide-react";
16import React, { useCallback, useMemo, useState } from "react";17import React, { useCallback, useMemo, useState } from "react";
1718
...@@ -147,6 +148,7 @@ export default function ProjectScriptsControl({...@@ -147,6 +148,7 @@ export default function ProjectScriptsControl({
147 icon: fileScript.icon ?? "play",148 icon: fileScript.icon ?? "play",
148 runOnWorktreeCreate: fileScript.runOnWorktreeCreate ?? false,149 runOnWorktreeCreate: fileScript.runOnWorktreeCreate ?? false,
149 waitForSetup: fileScript.runOnWorktreeCreate === true && fileScript.async === false,150 waitForSetup: fileScript.runOnWorktreeCreate === true && fileScript.async === false,
151 runOnSettle: fileScript.runOnSettle ?? false,
150 ...(readEnvironmentScope(environmentId, AuthSettingsWriteScope) ? { keybinding: null } : {}),152 ...(readEnvironmentScope(environmentId, AuthSettingsWriteScope) ? { keybinding: null } : {}),
151 previewUrl: fileScript.previewUrl ?? null,153 previewUrl: fileScript.previewUrl ?? null,
152 autoOpenPreview: fileScript.previewUrl ? (fileScript.autoOpenPreview ?? false) : false,154 autoOpenPreview: fileScript.previewUrl ? (fileScript.autoOpenPreview ?? false) : false,
...@@ -166,7 +168,7 @@ export default function ProjectScriptsControl({...@@ -166,7 +168,7 @@ export default function ProjectScriptsControl({
166168
167 const importMenuItems = importableScripts.length > 0 && (169 const importMenuItems = importableScripts.length > 0 && (
168 <>170 <>
169 {primaryScript && <MenuSeparator />}171 {scripts.length > 0 && <MenuSeparator />}
170 <MenuGroup>172 <MenuGroup>
171 <MenuGroupLabel>From t3.json</MenuGroupLabel>173 <MenuGroupLabel>From t3.json</MenuGroupLabel>
172 {importableScripts.map((fileScript) => (174 {importableScripts.map((fileScript) => (
...@@ -202,9 +204,7 @@ export default function ProjectScriptsControl({...@@ -202,9 +204,7 @@ export default function ProjectScriptsControl({
202 onClick={() => onRunScript?.(script)}204 onClick={() => onRunScript?.(script)}
203 >205 >
204 <ScriptIcon icon={script.icon} className="size-4" />206 <ScriptIcon icon={script.icon} className="size-4" />
205 <MenuItemLabel>207 <MenuItemLabel>{projectScriptMenuLabel(script)}</MenuItemLabel>
206 {script.runOnWorktreeCreate ? `${script.name} (setup)` : script.name}
207 </MenuItemLabel>
208 <span className="relative ms-auto flex h-6 min-w-6 items-center justify-end">208 <span className="relative ms-auto flex h-6 min-w-6 items-center justify-end">
209 {shortcutLabel &&209 {shortcutLabel &&
210 (presentation === "menu" ? (210 (presentation === "menu" ? (
...@@ -266,7 +266,7 @@ export default function ProjectScriptsControl({...@@ -266,7 +266,7 @@ export default function ProjectScriptsControl({
266 </MenuShortcut>266 </MenuShortcut>
267 </MenuItem>267 </MenuItem>
268 )}268 )}
269 {primaryScript || importableScripts.length > 0 ? (269 {scripts.length > 0 || importableScripts.length > 0 ? (
270 <MenuSub270 <MenuSub
271 open={actionsMenuOpen.scripts}271 open={actionsMenuOpen.scripts}
272 onOpenChange={(open) =>272 onOpenChange={(open) =>
...@@ -369,7 +369,9 @@ export default function ProjectScriptsControl({...@@ -369,7 +369,9 @@ export default function ProjectScriptsControl({
369 </MenuPopup>369 </MenuPopup>
370 </Menu>370 </Menu>
371 </ActionGroup>371 </ActionGroup>
372 ) : importableScripts.length > 0 ? (372 ) : scripts.length > 0 || importableScripts.length > 0 ? (
373 // No one-click action (only a settle action, or only t3.json imports),
374 // so the saved actions stay reachable through this menu.
373 isPanel ? (375 isPanel ? (
374 <div376 <div
375 role="group"377 role="group"
...@@ -408,11 +410,7 @@ export default function ProjectScriptsControl({...@@ -408,11 +410,7 @@ export default function ProjectScriptsControl({
408 <ChevronDownIcon className={THREAD_DETAILS_PANEL_CHEVRON_CLASS} />410 <ChevronDownIcon className={THREAD_DETAILS_PANEL_CHEVRON_CLASS} />
409 </MenuTrigger>411 </MenuTrigger>
410 <MenuPopup align="end" anchor={panelAnchorRef} className="w-(--anchor-width)">412 <MenuPopup align="end" anchor={panelAnchorRef} className="w-(--anchor-width)">
411 {importMenuItems}413 {scriptItems}
412 <MenuItem onClick={openAddDialog}>
413 <PlusIcon className="size-4" />
414 Add action
415 </MenuItem>
416 </MenuPopup>414 </MenuPopup>
417 </Menu>415 </Menu>
418 </div>416 </div>
...@@ -433,13 +431,7 @@ export default function ProjectScriptsControl({...@@ -433,13 +431,7 @@ export default function ProjectScriptsControl({
433 </span>431 </span>
434 <ChevronDownIcon className="size-3.5" />432 <ChevronDownIcon className="size-3.5" />
435 </MenuTrigger>433 </MenuTrigger>
436 <MenuPopup align="end">434 <MenuPopup align="end">{scriptItems}</MenuPopup>
437 {importMenuItems}
438 <MenuItem onClick={openAddDialog}>
439 <PlusIcon className="size-4" />
440 Add action
441 </MenuItem>
442 </MenuPopup>
443 </Menu>435 </Menu>
444 )436 )
445 ) : (437 ) : (
apps/web/src/components/projectScriptEditor.permissions.test.tsx+1
...@@ -64,6 +64,7 @@ const request: ProjectScriptEditorRequest = {...@@ -64,6 +64,7 @@ const request: ProjectScriptEditorRequest = {
64 icon: "test",64 icon: "test",
65 runOnWorktreeCreate: false,65 runOnWorktreeCreate: false,
66 waitForSetup: false,66 waitForSetup: false,
67 runOnSettle: false,
67 keybinding: "mod+k",68 keybinding: "mod+k",
68 previewUrl: null,69 previewUrl: null,
69 autoOpenPreview: false,70 autoOpenPreview: false,
apps/web/src/components/projectScriptEditor.tsx+13
...@@ -92,6 +92,7 @@ export interface NewProjectScriptInput {...@@ -92,6 +92,7 @@ export interface NewProjectScriptInput {
92 runOnWorktreeCreate: boolean;92 runOnWorktreeCreate: boolean;
93 /** Setup scripts only: hold the agent until the script exits. */93 /** Setup scripts only: hold the agent until the script exits. */
94 waitForSetup: boolean;94 waitForSetup: boolean;
95 runOnSettle: boolean;
95 /** Omit to preserve the current shortcut when the form did not edit it. */96 /** Omit to preserve the current shortcut when the form did not edit it. */
96 keybinding?: string | null;97 keybinding?: string | null;
97 /** Optional URL to open in the in-app preview when this script runs. */98 /** Optional URL to open in the in-app preview when this script runs. */
...@@ -108,6 +109,7 @@ export const EMPTY_PROJECT_SCRIPT_INPUT: NewProjectScriptInput = {...@@ -108,6 +109,7 @@ export const EMPTY_PROJECT_SCRIPT_INPUT: NewProjectScriptInput = {
108 icon: "play",109 icon: "play",
109 runOnWorktreeCreate: false,110 runOnWorktreeCreate: false,
110 waitForSetup: false,111 waitForSetup: false,
112 runOnSettle: false,
111 keybinding: null,113 keybinding: null,
112 previewUrl: null,114 previewUrl: null,
113 autoOpenPreview: false,115 autoOpenPreview: false,
...@@ -133,6 +135,7 @@ export function editorRequestForScript(...@@ -133,6 +135,7 @@ export function editorRequestForScript(
133 icon: script.icon,135 icon: script.icon,
134 runOnWorktreeCreate: script.runOnWorktreeCreate,136 runOnWorktreeCreate: script.runOnWorktreeCreate,
135 waitForSetup: script.runOnWorktreeCreate && script.async === false,137 waitForSetup: script.runOnWorktreeCreate && script.async === false,
138 runOnSettle: script.runOnSettle ?? false,
136 keybinding: keybindingValueForCommand(keybindings, commandForProjectScript(script.id)),139 keybinding: keybindingValueForCommand(keybindings, commandForProjectScript(script.id)),
137 previewUrl: script.previewUrl ?? null,140 previewUrl: script.previewUrl ?? null,
138 autoOpenPreview: script.autoOpenPreview ?? false,141 autoOpenPreview: script.autoOpenPreview ?? false,
...@@ -175,6 +178,7 @@ export function ProjectScriptEditorDialog({...@@ -175,6 +178,7 @@ export function ProjectScriptEditorDialog({
175 const [iconPickerOpen, setIconPickerOpen] = useComposerMenuState(!canEditActions);178 const [iconPickerOpen, setIconPickerOpen] = useComposerMenuState(!canEditActions);
176 const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false);179 const [runOnWorktreeCreate, setRunOnWorktreeCreate] = useState(false);
177 const [waitForSetup, setWaitForSetup] = useState(false);180 const [waitForSetup, setWaitForSetup] = useState(false);
181 const [runOnSettle, setRunOnSettle] = useState(false);
178 const [keybinding, setKeybinding] = useState("");182 const [keybinding, setKeybinding] = useState("");
179 const [previewUrl, setPreviewUrl] = useState("");183 const [previewUrl, setPreviewUrl] = useState("");
180 const [autoOpenPreview, setAutoOpenPreview] = useState(false);184 const [autoOpenPreview, setAutoOpenPreview] = useState(false);
...@@ -206,6 +210,7 @@ export function ProjectScriptEditorDialog({...@@ -206,6 +210,7 @@ export function ProjectScriptEditorDialog({
206 setIconPickerOpen(false);210 setIconPickerOpen(false);
207 setRunOnWorktreeCreate(request.initial.runOnWorktreeCreate);211 setRunOnWorktreeCreate(request.initial.runOnWorktreeCreate);
208 setWaitForSetup(request.initial.waitForSetup);212 setWaitForSetup(request.initial.waitForSetup);
213 setRunOnSettle(request.initial.runOnSettle);
209 setKeybinding(request.initial.keybinding ?? "");214 setKeybinding(request.initial.keybinding ?? "");
210 setPreviewUrl(request.initial.previewUrl ?? "");215 setPreviewUrl(request.initial.previewUrl ?? "");
211 setAutoOpenPreview(request.initial.autoOpenPreview);216 setAutoOpenPreview(request.initial.autoOpenPreview);
...@@ -279,6 +284,7 @@ export function ProjectScriptEditorDialog({...@@ -279,6 +284,7 @@ export function ProjectScriptEditorDialog({
279 icon,284 icon,
280 runOnWorktreeCreate,285 runOnWorktreeCreate,
281 waitForSetup: runOnWorktreeCreate && waitForSetup,286 waitForSetup: runOnWorktreeCreate && waitForSetup,
287 runOnSettle,
282 ...((request.scriptId === null && canChangeKeybinding) || changesKeybinding288 ...((request.scriptId === null && canChangeKeybinding) || changesKeybinding
283 ? { keybinding: keybindingRule?.key ?? null }289 ? { keybinding: keybindingRule?.key ?? null }
284 : {}),290 : {}),
...@@ -443,6 +449,13 @@ export function ProjectScriptEditorDialog({...@@ -443,6 +449,13 @@ export function ProjectScriptEditorDialog({
443 onCheckedChange={(checked) => setWaitForSetup(Boolean(checked))}449 onCheckedChange={(checked) => setWaitForSetup(Boolean(checked))}
444 />450 />
445 </label>451 </label>
452 <label className="flex items-center justify-between gap-3 rounded-md border border-border/70 px-3 py-2 text-sm dark:border-transparent dark:bg-white/[0.035]">
453 <span>Run in the thread's worktree when the thread settles</span>
454 <Switch
455 checked={runOnSettle}
456 onCheckedChange={(checked) => setRunOnSettle(Boolean(checked))}
457 />
458 </label>
446 <label459 <label
447 className={`flex items-center justify-between gap-3 rounded-md border border-border/70 px-3 py-2 text-sm dark:border-transparent dark:bg-white/[0.035] ${460 className={`flex items-center justify-between gap-3 rounded-md border border-border/70 px-3 py-2 text-sm dark:border-transparent dark:bg-white/[0.035] ${
448 previewUrl.trim().length === 0 ? "opacity-60" : ""461 previewUrl.trim().length === 0 ? "opacity-60" : ""
apps/web/src/components/settings/ProjectActionsList.tsx+5
...@@ -38,6 +38,11 @@ export function ProjectActionsList({...@@ -38,6 +38,11 @@ export function ProjectActionsList({
38 setup38 setup
39 </span>39 </span>
40 ) : null}40 ) : null}
41 {script.runOnSettle ? (
42 <span className="shrink-0 rounded-sm border border-border/60 px-1.5 py-px text-2xs font-normal text-muted-foreground">
43 on settle
44 </span>
45 ) : null}
41 {script.previewUrl ? (46 {script.previewUrl ? (
42 <span className="shrink-0 rounded-sm border border-border/60 px-1.5 py-px text-2xs font-normal text-muted-foreground max-sm:hidden">47 <span className="shrink-0 rounded-sm border border-border/60 px-1.5 py-px text-2xs font-normal text-muted-foreground max-sm:hidden">
43 preview · desktop only48 preview · desktop only
apps/web/src/components/settings/ProjectActionsSettings.tsx+1
...@@ -115,6 +115,7 @@ export function ProjectActionsSettings() {...@@ -115,6 +115,7 @@ export function ProjectActionsSettings() {
115 icon: fileScript.icon ?? "play",115 icon: fileScript.icon ?? "play",
116 runOnWorktreeCreate: fileScript.runOnWorktreeCreate ?? false,116 runOnWorktreeCreate: fileScript.runOnWorktreeCreate ?? false,
117 waitForSetup: fileScript.runOnWorktreeCreate === true && fileScript.async === false,117 waitForSetup: fileScript.runOnWorktreeCreate === true && fileScript.async === false,
118 runOnSettle: fileScript.runOnSettle ?? false,
118 keybinding: null,119 keybinding: null,
119 previewUrl: fileScript.previewUrl ?? null,120 previewUrl: fileScript.previewUrl ?? null,
120 autoOpenPreview: fileScript.previewUrl ? (fileScript.autoOpenPreview ?? false) : false,121 autoOpenPreview: fileScript.previewUrl ? (fileScript.autoOpenPreview ?? false) : false,
apps/web/src/components/settings/useProjectScriptSettings.ts+2-5
...@@ -28,6 +28,7 @@ import {...@@ -28,6 +28,7 @@ import {
28 buildProjectScript,28 buildProjectScript,
29 commandForProjectScript,29 commandForProjectScript,
30 nextProjectScriptId,30 nextProjectScriptId,
31 releaseClaimedRoles,
31} from "../../projectScripts";32} from "../../projectScripts";
32import { useProjects } from "../../state/entities";33import { useProjects } from "../../state/entities";
33import { serverEnvironment } from "../../state/server";34import { serverEnvironment } from "../../state/server";
...@@ -194,11 +195,7 @@ export function useProjectScriptSettings(...@@ -194,11 +195,7 @@ export function useProjectScriptSettings(
194 return persist(195 return persist(
195 (current) => {196 (current) => {
196 const updated = current.map((script) =>197 const updated = current.map((script) =>
197 script.id === id198 script.id === id ? next : releaseClaimedRoles(script, input),
198 ? next
199 : input.runOnWorktreeCreate
200 ? { ...script, runOnWorktreeCreate: false }
201 : script,
202 );199 );
203 return scriptId === null ? [...updated, next] : updated;200 return scriptId === null ? [...updated, next] : updated;
204 },201 },
apps/web/src/projectScripts.test.ts+62-1
...@@ -4,6 +4,8 @@ import { describe, expect, it } from "vite-plus/test";...@@ -4,6 +4,8 @@ import { describe, expect, it } from "vite-plus/test";
4import {4import {
5 projectScriptCwd,5 projectScriptCwd,
6 projectScriptRuntimeEnv,6 projectScriptRuntimeEnv,
7 projectScriptMenuLabel,
8 settleProjectScript,
7 setupProjectScript,9 setupProjectScript,
8} from "@t3tools/shared/projectScripts";10} from "@t3tools/shared/projectScripts";
911
...@@ -13,6 +15,7 @@ import {...@@ -13,6 +15,7 @@ import {
13 nextProjectScriptId,15 nextProjectScriptId,
14 primaryProjectScript,16 primaryProjectScript,
15 projectScriptIdFromCommand,17 projectScriptIdFromCommand,
18 releaseClaimedRoles,
16} from "./projectScripts";19} from "./projectScripts";
1720
18describe("projectScripts helpers", () => {21describe("projectScripts helpers", () => {
...@@ -24,6 +27,7 @@ describe("projectScripts helpers", () => {...@@ -24,6 +27,7 @@ describe("projectScripts helpers", () => {
24 icon: "debug",27 icon: "debug",
25 runOnWorktreeCreate: false,28 runOnWorktreeCreate: false,
26 waitForSetup: false,29 waitForSetup: false,
30 runOnSettle: false,
27 previewUrl: "http://localhost:5733",31 previewUrl: "http://localhost:5733",
28 autoOpenPreview: true,32 autoOpenPreview: true,
29 }),33 }),
...@@ -46,6 +50,7 @@ describe("projectScripts helpers", () => {...@@ -46,6 +50,7 @@ describe("projectScripts helpers", () => {
46 icon: "test",50 icon: "test",
47 runOnWorktreeCreate: false,51 runOnWorktreeCreate: false,
48 waitForSetup: false,52 waitForSetup: false,
53 runOnSettle: false,
49 previewUrl: null,54 previewUrl: null,
50 autoOpenPreview: false,55 autoOpenPreview: false,
51 }),56 }),
...@@ -65,6 +70,7 @@ describe("projectScripts helpers", () => {...@@ -65,6 +70,7 @@ describe("projectScripts helpers", () => {
65 icon: "configure",70 icon: "configure",
66 previewUrl: null,71 previewUrl: null,
67 autoOpenPreview: false,72 autoOpenPreview: false,
73 runOnSettle: false,
68 } as const;74 } as const;
69 expect(75 expect(
70 buildProjectScript("setup", { ...input, runOnWorktreeCreate: true, waitForSetup: true }),76 buildProjectScript("setup", { ...input, runOnWorktreeCreate: true, waitForSetup: true }),
...@@ -108,7 +114,7 @@ describe("projectScripts helpers", () => {...@@ -108,7 +114,7 @@ describe("projectScripts helpers", () => {
108 expect(nextProjectScriptId("!!!", [])).toBe("script");114 expect(nextProjectScriptId("!!!", [])).toBe("script");
109 });115 });
110116
111 it("resolves primary and setup scripts", () => {117 it("resolves primary, setup, and settle scripts", () => {
112 const scripts = [118 const scripts = [
113 {119 {
114 id: "setup",120 id: "setup",
...@@ -117,6 +123,14 @@ describe("projectScripts helpers", () => {...@@ -117,6 +123,14 @@ describe("projectScripts helpers", () => {
117 icon: "configure" as const,123 icon: "configure" as const,
118 runOnWorktreeCreate: true,124 runOnWorktreeCreate: true,
119 },125 },
126 {
127 id: "clean",
128 name: "Clean",
129 command: "cargo clean",
130 icon: "build" as const,
131 runOnWorktreeCreate: false,
132 runOnSettle: true,
133 },
120 {134 {
121 id: "test",135 id: "test",
122 name: "Test",136 name: "Test",
...@@ -128,6 +142,53 @@ describe("projectScripts helpers", () => {...@@ -128,6 +142,53 @@ describe("projectScripts helpers", () => {
128142
129 expect(primaryProjectScript(scripts)?.id).toBe("test");143 expect(primaryProjectScript(scripts)?.id).toBe("test");
130 expect(setupProjectScript(scripts)?.id).toBe("setup");144 expect(setupProjectScript(scripts)?.id).toBe("setup");
145 expect(settleProjectScript(scripts)?.id).toBe("clean");
146 // Cleanup is never the one-click run button, even when it is all there is.
147 expect(primaryProjectScript(scripts.filter((script) => script.id === "clean"))).toBeNull();
148 });
149
150 it("labels every lifecycle role a script runs in", () => {
151 const script = {
152 id: "both",
153 name: "Both",
154 command: "true",
155 icon: "play" as const,
156 runOnWorktreeCreate: true,
157 };
158 expect(projectScriptMenuLabel(script)).toBe("Both (setup)");
159 expect(projectScriptMenuLabel({ ...script, runOnSettle: true })).toBe(
160 "Both (setup, on settle)",
161 );
162 expect(projectScriptMenuLabel({ ...script, runOnWorktreeCreate: false })).toBe("Both");
163 });
164
165 it("moves the setup and settle roles to the script that claims them", () => {
166 const input = {
167 name: "Next",
168 command: "true",
169 icon: "play",
170 waitForSetup: false,
171 previewUrl: null,
172 autoOpenPreview: false,
173 } as const;
174 const holder = {
175 id: "holder",
176 name: "Holder",
177 command: "true",
178 icon: "play" as const,
179 runOnWorktreeCreate: true,
180 runOnSettle: true,
181 };
182
183 expect(
184 releaseClaimedRoles(holder, { ...input, runOnWorktreeCreate: false, runOnSettle: true }),
185 ).toMatchObject({ runOnWorktreeCreate: true, runOnSettle: false });
186 expect(
187 releaseClaimedRoles(holder, { ...input, runOnWorktreeCreate: true, runOnSettle: false }),
188 ).toMatchObject({ runOnWorktreeCreate: false, runOnSettle: true });
189 expect(
190 releaseClaimedRoles(holder, { ...input, runOnWorktreeCreate: false, runOnSettle: false }),
191 ).toBe(holder);
131 });192 });
132193
133 it("builds default runtime env for scripts", () => {194 it("builds default runtime env for scripts", () => {
apps/web/src/projectScripts.ts+22-2
...@@ -13,6 +13,7 @@ export interface ProjectScriptInput {...@@ -13,6 +13,7 @@ export interface ProjectScriptInput {
13 readonly icon: ProjectScript["icon"];13 readonly icon: ProjectScript["icon"];
14 readonly runOnWorktreeCreate: ProjectScript["runOnWorktreeCreate"];14 readonly runOnWorktreeCreate: ProjectScript["runOnWorktreeCreate"];
15 readonly waitForSetup: boolean;15 readonly waitForSetup: boolean;
16 readonly runOnSettle: boolean;
16 readonly previewUrl: Exclude<ProjectScript["previewUrl"], undefined> | null;17 readonly previewUrl: Exclude<ProjectScript["previewUrl"], undefined> | null;
17 readonly autoOpenPreview: boolean;18 readonly autoOpenPreview: boolean;
18}19}
...@@ -25,6 +26,7 @@ export function buildProjectScript(id: string, input: ProjectScriptInput): Proje...@@ -25,6 +26,7 @@ export function buildProjectScript(id: string, input: ProjectScriptInput): Proje
25 icon: input.icon,26 icon: input.icon,
26 runOnWorktreeCreate: input.runOnWorktreeCreate,27 runOnWorktreeCreate: input.runOnWorktreeCreate,
27 ...(input.runOnWorktreeCreate && input.waitForSetup ? { async: false } : {}),28 ...(input.runOnWorktreeCreate && input.waitForSetup ? { async: false } : {}),
29 ...(input.runOnSettle ? { runOnSettle: true } : {}),
28 ...(input.previewUrl === null30 ...(input.previewUrl === null
29 ? {}31 ? {}
30 : {32 : {
...@@ -34,6 +36,24 @@ export function buildProjectScript(id: string, input: ProjectScriptInput): Proje...@@ -34,6 +36,24 @@ export function buildProjectScript(id: string, input: ProjectScriptInput): Proje
34 };36 };
35}37}
3638
39/**
40 * A project runs at most one setup script and one settle script, so saving a
41 * script that claims either role takes it from the script that held it.
42 */
43export function releaseClaimedRoles(
44 script: ProjectScript,
45 saved: ProjectScriptInput,
46): ProjectScript {
47 const releaseSetup = saved.runOnWorktreeCreate && script.runOnWorktreeCreate;
48 const releaseSettle = saved.runOnSettle && script.runOnSettle === true;
49 if (!releaseSetup && !releaseSettle) return script;
50 return {
51 ...script,
52 ...(releaseSetup ? { runOnWorktreeCreate: false } : {}),
53 ...(releaseSettle ? { runOnSettle: false } : {}),
54 };
55}
56
37function normalizeScriptId(value: string): string {57function normalizeScriptId(value: string): string {
38 const cleaned = value58 const cleaned = value
39 .trim()59 .trim()
...@@ -87,6 +107,6 @@ export function nextProjectScriptId(name: string, existingIds: Iterable<string>)...@@ -87,6 +107,6 @@ export function nextProjectScriptId(name: string, existingIds: Iterable<string>)
87}107}
88108
89export function primaryProjectScript(scripts: ReadonlyArray<ProjectScript>): ProjectScript | null {109export function primaryProjectScript(scripts: ReadonlyArray<ProjectScript>): ProjectScript | null {
90 const regular = scripts.find((script) => !script.runOnWorktreeCreate);110 const regular = scripts.find((script) => !script.runOnWorktreeCreate && !script.runOnSettle);
91 return regular ?? scripts[0] ?? null;111 return regular ?? scripts.find((script) => !script.runOnSettle) ?? null;
92}112}
docs/user/thread-sidebar.md+7
...@@ -134,6 +134,13 @@ sending an answer or restarting the agent. Settling also closes the thread's...@@ -134,6 +134,13 @@ sending an answer or restarting the agent. Settling also closes the thread's
134terminals that wait at an idle prompt, and keeps their output. A terminal that134terminals that wait at an idle prompt, and keeps their output. A terminal that
135runs a command, such as a dev server, stays open.135runs a command, such as a dev server, stays open.
136136
137To reclaim disk space from settled work, turn on **Run in the thread's worktree when the
138thread settles** for one of the project's actions, or set `"runOnSettle": true` on a
139`t3.json` script, for example `cargo clean`. It runs each time a thread in its own
140worktree settles, manually or automatically, even if a terminal there still runs a
141command such as a dev server. Threads in the project's main checkout skip it. Its terminal
142closes when the command succeeds and stays open when it fails.
143
137On web and desktop, press a thread's **Settle** button and drag up or down to144On web and desktop, press a thread's **Settle** button and drag up or down to
138settle every thread in that section between it and the one you release on.145settle every thread in that section between it and the one you release on.
139The **Un-settle** and **Wake** buttons work the same way in their sections.146The **Un-settle** and **Wake** buttons work the same way in their sections.
packages/contracts/src/project.ts+2
...@@ -36,6 +36,8 @@ export const ProjectScript = Schema.Struct({...@@ -36,6 +36,8 @@ export const ProjectScript = Schema.Struct({
36 command: TrimmedNonEmptyString,36 command: TrimmedNonEmptyString,
37 icon: ProjectScriptIcon,37 icon: ProjectScriptIcon,
38 runOnWorktreeCreate: Schema.Boolean,38 runOnWorktreeCreate: Schema.Boolean,
39 /** Run in the thread's worktree each time the thread settles. */
40 runOnSettle: Schema.optional(Schema.Boolean),
39 /** Start the agent while setup runs unless explicitly disabled. */41 /** Start the agent while setup runs unless explicitly disabled. */
40 async: Schema.optional(Schema.Boolean),42 async: Schema.optional(Schema.Boolean),
41 previewUrl: Schema.optional(TrimmedNonEmptyString),43 previewUrl: Schema.optional(TrimmedNonEmptyString),
packages/contracts/src/t3ProjectFile.ts+6
...@@ -43,6 +43,12 @@ export const T3ProjectFileScript = Schema.Struct({...@@ -43,6 +43,12 @@ export const T3ProjectFileScript = Schema.Struct({
43 "When true, the script runs automatically after a worktree is created for a new thread.",43 "When true, the script runs automatically after a worktree is created for a new thread.",
44 }),44 }),
45 ),45 ),
46 runOnSettle: Schema.optionalKey(
47 Schema.Boolean.annotate({
48 description:
49 "When true, the script runs in the thread's worktree each time the thread settles, for example to delete build output. Threads without their own worktree skip it.",
50 }),
51 ),
46 async: Schema.optionalKey(52 async: Schema.optionalKey(
47 Schema.Boolean.annotate({53 Schema.Boolean.annotate({
48 description:54 description:
packages/shared/src/projectScripts.ts+13
...@@ -73,3 +73,16 @@ export function projectScriptRuntimeEnv(...@@ -73,3 +73,16 @@ export function projectScriptRuntimeEnv(
73export function setupProjectScript(scripts: readonly ProjectScript[]): ProjectScript | null {73export function setupProjectScript(scripts: readonly ProjectScript[]): ProjectScript | null {
74 return scripts.find((script) => script.runOnWorktreeCreate) ?? null;74 return scripts.find((script) => script.runOnWorktreeCreate) ?? null;
75}75}
76
77/** Menu label naming the lifecycle roles a script runs in, e.g. "Clean (on settle)". */
78export function projectScriptMenuLabel(script: ProjectScript): string {
79 const roles = [
80 ...(script.runOnWorktreeCreate ? ["setup"] : []),
81 ...(script.runOnSettle ? ["on settle"] : []),
82 ];
83 return roles.length === 0 ? script.name : `${script.name} (${roles.join(", ")})`;
84}
85
86export function settleProjectScript(scripts: readonly ProjectScript[]): ProjectScript | null {
87 return scripts.find((script) => script.runOnSettle === true) ?? null;
88}
packages/shared/src/t3ProjectFile.test.ts+1
...@@ -51,6 +51,7 @@ describe("buildT3ProjectFileJsonSchema", () => {...@@ -51,6 +51,7 @@ describe("buildT3ProjectFileJsonSchema", () => {
51 "icon",51 "icon",
52 "name",52 "name",
53 "previewUrl",53 "previewUrl",
54 "runOnSettle",
54 "runOnWorktreeCreate",55 "runOnWorktreeCreate",
55 ]);56 ]);
56 });57 });