| author | |
| committer | |
| log | 079e4bccdcffc56c54bd69523ed77c006553ea21 |
| tree | 4bdb871a9f770b624f003e50a350f472d35ec53a |
| parent | a1830915ecf70e3ad4fbe8efb15ae9f18517eb54 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | } |
| 158 | 158 | ||
| 159 | export function projectScriptMenuLabel(script: ProjectScript): string { | ||
| 160 | return script.runOnWorktreeCreate ? `${script.name} (setup)` : script.name; | ||
| 161 | } | ||
| 162 | |||
| 163 | export function projectScriptMenuIcon(icon: ProjectScript["icon"]) { | 159 | export 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"; |
| 28 | import { projectScriptMenuLabel } from "@t3tools/shared/projectScripts"; | ||
| 29 | 29 | ||
| 30 | function truncateMiddle(value: string, maxLength: number): string { | 30 | function 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"; |
| 30 | import { TestClock } from "effect/testing"; | 30 | import { TestClock } from "effect/testing"; |
| 31 | 31 | ||
| 32 | import * as GitManager from "../git/GitManager.ts"; | 32 | import * as GitManager from "../git/GitManager.ts"; |
| 33 | import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; | ||
| 33 | import * as PullRequestService from "../pullRequest/PullRequestService.ts"; | 34 | import * as PullRequestService from "../pullRequest/PullRequestService.ts"; |
| 34 | import * as ServerActivation from "../serverActivation.ts"; | 35 | import * as ServerActivation from "../serverActivation.ts"; |
| 35 | import * as ServerSettings from "../serverSettings.ts"; | 36 | import * 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>(); | ||
| 517 | 526 | ||
| 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 | }); |
| 1090 | 1220 | ||
| 1091 | describe("ThreadSettlementServiceV2 single-thread sweeps", () => { | 1221 | describe("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"; |
| 20 | import * as Stream from "effect/Stream"; | 20 | import * as Stream from "effect/Stream"; |
| 21 | 21 | ||
| 22 | import * as GitManager from "../git/GitManager.ts"; | 22 | import * as GitManager from "../git/GitManager.ts"; |
| 23 | import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; | ||
| 23 | import * as PullRequestService from "../pullRequest/PullRequestService.ts"; | 24 | import * as PullRequestService from "../pullRequest/PullRequestService.ts"; |
| 24 | import * as ServerSettings from "../serverSettings.ts"; | 25 | import * as ServerSettings from "../serverSettings.ts"; |
| 25 | import { forkParked } from "../serverActivation.ts"; | 26 | import { 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>(); | ||
| 268 | 273 | ||
| 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* () { |
| 514 | 519 | ||
| 515 | // Settling closes the thread's shells that sit at an idle prompt, so they stop | 520 | // 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, an | 521 | // 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"; |
| 2 | import { assert, it, vi } from "@effect/vitest"; | 2 | import { assert, it, vi } from "@effect/vitest"; |
| 3 | import { ProjectId } from "@t3tools/contracts"; | 3 | import { ProjectId } from "@t3tools/contracts"; |
| 4 | import * as Effect from "effect/Effect"; | 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Fiber from "effect/Fiber"; | ||
| 5 | import * as Layer from "effect/Layer"; | 6 | import * as Layer from "effect/Layer"; |
| 6 | import * as Option from "effect/Option"; | 7 | import * as Option from "effect/Option"; |
| 7 | 8 | ||
| ... | @@ -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 |
| 3 | import { | 3 | import { |
| 4 | projectScriptRuntimeEnv, | 4 | projectScriptRuntimeEnv, |
| 5 | resolveProjectScripts, | 5 | resolveProjectScripts, |
| 6 | settleProjectScript, | ||
| 6 | setupProjectScript, | 7 | setupProjectScript, |
| 7 | } from "@t3tools/shared/projectScripts"; | 8 | } from "@t3tools/shared/projectScripts"; |
| 8 | 9 | ||
| ... | @@ -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; |
| 230 | 236 | ||
| ... | @@ -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 | }); |
| 288 | 303 | ||
| 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 | } |
| 357 | 377 | ||
| 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"; |
| 324 | import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; | 325 | import { newDraftId, newMessageId, newThreadId } from "~/lib/utils"; |
| 325 | import { registerFaviconProjectForThread } from "~/browserFaviconStore"; | 326 | import { 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.runOnWorktreeCreate | 5235 | 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]; | ||
| 5242 | 5239 | ||
| 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) { |
| 5266 | 5263 | ||
| 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 === scriptId | 5266 | script.id === scriptId ? updatedScript : releaseClaimedRoles(script, input), |
| 5270 | ? updatedScript | ||
| 5271 | : input.runOnWorktreeCreate | ||
| 5272 | ? { ...script, runOnWorktreeCreate: false } | ||
| 5273 | : script, | ||
| 5274 | ); | 5267 | ); |
| 5275 | 5268 | ||
| 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"; |
| 15 | import { projectScriptMenuLabel } from "@t3tools/shared/projectScripts"; | ||
| 15 | import { ChevronDownIcon, DownloadIcon, PlusIcon, SettingsIcon, WrenchIcon } from "lucide-react"; | 16 | import { ChevronDownIcon, DownloadIcon, PlusIcon, SettingsIcon, WrenchIcon } from "lucide-react"; |
| 16 | import React, { useCallback, useMemo, useState } from "react"; | 17 | import React, { useCallback, useMemo, useState } from "react"; |
| 17 | 18 | ||
| ... | @@ -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({ |
| 166 | 168 | ||
| 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 | <MenuSub | 270 | <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 | <div | 376 | <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) || changesKeybinding | 288 | ...((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 | <label | 459 | <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 | setup | 38 | 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 only | 48 | 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"; |
| 32 | import { useProjects } from "../../state/entities"; | 33 | import { useProjects } from "../../state/entities"; |
| 33 | import { serverEnvironment } from "../../state/server"; | 34 | import { 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 === id | 198 | 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"; |
| 4 | import { | 4 | import { |
| 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"; |
| 9 | 11 | ||
| ... | @@ -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"; |
| 17 | 20 | ||
| 18 | describe("projectScripts helpers", () => { | 21 | describe("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 | }); |
| 110 | 116 | ||
| 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", () => { |
| 128 | 142 | ||
| 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 | }); |
| 132 | 193 | ||
| 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 === null | 30 | ...(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 | } |
| 36 | 38 | ||
| 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 | */ | ||
| 43 | export 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 | |||
| 37 | function normalizeScriptId(value: string): string { | 57 | function normalizeScriptId(value: string): string { |
| 38 | const cleaned = value | 58 | 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 | } |
| 88 | 108 | ||
| 89 | export function primaryProjectScript(scripts: ReadonlyArray<ProjectScript>): ProjectScript | null { | 109 | export 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 |
| 134 | terminals that wait at an idle prompt, and keeps their output. A terminal that | 134 | terminals that wait at an idle prompt, and keeps their output. A terminal that |
| 135 | runs a command, such as a dev server, stays open. | 135 | runs a command, such as a dev server, stays open. |
| 136 | 136 | ||
| 137 | To reclaim disk space from settled work, turn on **Run in the thread's worktree when the | ||
| 138 | thread 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 | ||
| 140 | worktree settles, manually or automatically, even if a terminal there still runs a | ||
| 141 | command such as a dev server. Threads in the project's main checkout skip it. Its terminal | ||
| 142 | closes when the command succeeds and stays open when it fails. | ||
| 143 | |||
| 137 | On web and desktop, press a thread's **Settle** button and drag up or down to | 144 | On web and desktop, press a thread's **Settle** button and drag up or down to |
| 138 | settle every thread in that section between it and the one you release on. | 145 | settle every thread in that section between it and the one you release on. |
| 139 | The **Un-settle** and **Wake** buttons work the same way in their sections. | 146 | The **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( |
| 73 | export function setupProjectScript(scripts: readonly ProjectScript[]): ProjectScript | null { | 73 | export 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)". */ | ||
| 78 | export 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 | |||
| 86 | export 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 | }); |