From 6d4ef05a331e142031c3683a8f28029b7141297e Mon Sep 17 00:00:00 2001 From: Andrey170170 Date: Fri, 2 Oct 2026 16:56:27 -0600 Subject: [PATCH 1/6] =?UTF-8?q?feat(trellis):=20History=20section=20in=20S?= =?UTF-8?q?ettings=20=E2=86=92=20Trellis?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shows and edits Trellis's history settings (snapshot timer, turn and timer snapshot retention, trash and incoming expiry) through two new RPCs, trellis.getHistorySettings and trellis.updateHistorySettings, backed by GET/PUT /v1/settings/history. Each value commits on blur, Enter or the step buttons and sends only that key; Trellis's refusal shows under the row, and a save that the next thinning acts on says how many snapshots go. Live snapshot counts per kind and the last thinning are shown read only. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/server/src/auth/RpcAuthorization.ts | 2 + apps/server/src/trellis/Trellis.test.ts | 89 +++++++++ apps/server/src/trellis/Trellis.ts | 93 ++++++++- apps/server/src/trellis/TrellisCatalog.ts | 12 ++ .../src/trellis/TrellisIdeaPromotion.test.ts | 2 + .../server/src/trellis/TrellisWorkers.test.ts | 2 + apps/server/src/ws.ts | 10 + .../settings/TrellisSettings.logic.test.ts | 63 ++++++- .../settings/TrellisSettings.logic.ts | 143 +++++++++++++- .../components/settings/TrellisSettings.tsx | 178 +++++++++++++++++- .../src/components/settings/settingsSearch.ts | 9 + apps/web/src/state/trellis.ts | 11 ++ docs/user/trellis.md | 4 + packages/contracts/src/rpc.ts | 20 ++ packages/contracts/src/trellis.ts | 36 ++++ 15 files changed, 668 insertions(+), 6 deletions(-) diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 1fb0b55a2b56..7413a89f7e32 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -153,6 +153,8 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.trellisGraduate]: AuthOrchestrationOperateScope, [WS_METHODS.trellisListBases]: AuthOrchestrationReadScope, [WS_METHODS.trellisGetDetails]: AuthOrchestrationReadScope, + [WS_METHODS.trellisGetHistorySettings]: AuthOrchestrationReadScope, + [WS_METHODS.trellisUpdateHistorySettings]: AuthOrchestrationOperateScope, [WS_METHODS.trellisFind]: AuthOrchestrationReadScope, [WS_METHODS.trellisRestoreConflicts]: AuthOrchestrationReadScope, // Publishes a workspace port, like preview.open. diff --git a/apps/server/src/trellis/Trellis.test.ts b/apps/server/src/trellis/Trellis.test.ts index 060c4e17fa4c..02399aa652f4 100644 --- a/apps/server/src/trellis/Trellis.test.ts +++ b/apps/server/src/trellis/Trellis.test.ts @@ -403,6 +403,95 @@ describe("Trellis client", () => { expect(result.podmanFailed).toEqual(unknown); }), ); + + it.effect("reads and changes the history settings, passing on Trellis's refusals", () => + Effect.gen(function* () { + const values = { + timer_minutes: 1, + turn_keep_all_days: 7, + turn_keep_daily_days: 90, + timer_keep_all_hours: 2, + timer_keep_hourly_days: 7, + idea_trash_days: 30, + fork_trash_days: 30, + incoming_days: 30, + }; + let historyRoute = true; + const harness = setup(({ method, url, body }) => { + if (url === "/v1/status") return { body: { root: "/trellis", role: "user" } }; + if (url !== "/v1/settings/history" || !historyRoute) { + return { status: 404, body: null, raw: "" }; + } + if (method === "GET") { + return { + body: { + // A key this T3 does not know is ignored; a missing one stays absent. + values: { ...values, idea_trash_days: 14, future_key: 3, incoming_days: undefined }, + defaults: values, + snapshots: { timer: 12, turn: 40 }, + last_thinning: { at: 1_700_000_000, removed: 5 }, + free_space: 1, + }, + }; + } + const patch = JSON.parse(body) as Record; + if ((patch.turn_keep_daily_days ?? 90) < 7) { + return { + status: 400, + body: { + error: `turn_keep_daily_days (${patch.turn_keep_daily_days}) must not be shorter than turn_keep_all_days (7)`, + }, + }; + } + return { body: { values: { ...values, ...patch }, would_remove: 3 } }; + }); + const layer = yield* Effect.promise(() => harness.listen()); + const result = yield* Effect.gen(function* () { + const trellis = yield* Trellis.Trellis; + const settings = yield* trellis.historySettings; + const updated = yield* trellis.updateHistorySettings({ turnKeepAllDays: 3 }); + const refused = yield* trellis + .updateHistorySettings({ turnKeepDailyDays: 2 }) + .pipe(Effect.flip); + historyRoute = false; + const older = yield* trellis.historySettings.pipe(Effect.flip); + return { settings, updated, refused, older }; + }).pipe(Effect.provide(layer)); + const defaults = { + timerMinutes: 1, + turnKeepAllDays: 7, + turnKeepDailyDays: 90, + timerKeepAllHours: 2, + timerKeepHourlyDays: 7, + ideaTrashDays: 30, + forkTrashDays: 30, + incomingDays: 30, + }; + const { incomingDays: _, ...withoutIncoming } = defaults; + expect(result.settings).toEqual({ + values: { ...withoutIncoming, ideaTrashDays: 14 }, + defaults, + snapshots: { timer: 12, turn: 40 }, + lastThinning: { at: 1_700_000_000, removed: 5 }, + }); + // Only the changed key goes out, in Trellis's name. + expect( + harness.requests + .filter((request) => request.method === "PUT") + .map((request) => JSON.parse(request.body)), + ).toEqual([{ turn_keep_all_days: 3 }, { turn_keep_daily_days: 2 }]); + expect(result.updated).toEqual({ + values: { ...defaults, turnKeepAllDays: 3 }, + wouldRemove: 3, + }); + expect(result.refused.message).toBe( + "turn_keep_daily_days (2) must not be shorter than turn_keep_all_days (7)", + ); + expect(result.older.message).toBe( + "This Trellis does not support GET /v1/settings/history, which T3 needs here. Update Trellis to main at or after PR #40 (records/history-settings, 0bed1e8).", + ); + }), + ); }); describe("parseKnownRoots", () => { diff --git a/apps/server/src/trellis/Trellis.ts b/apps/server/src/trellis/Trellis.ts index c47ef8fc0326..2584c0eee0db 100644 --- a/apps/server/src/trellis/Trellis.ts +++ b/apps/server/src/trellis/Trellis.ts @@ -19,7 +19,14 @@ import * as NodeHttp from "node:http"; import * as NodePath from "node:path"; -import { type TrellisDetails, TrellisError, type TrellisStatus } from "@t3tools/contracts"; +import { + type TrellisDetails, + TrellisError, + type TrellisHistorySettings, + type TrellisHistorySettingsUpdateResult, + type TrellisHistoryValues, + type TrellisStatus, +} from "@t3tools/contracts"; import { isTrellisManagedPath as isSharedTrellisManagedPath, trellisWorkspaceIdOf, @@ -347,6 +354,58 @@ const toDetails = (view: typeof TrellisDetailsView.Type): TrellisDetails => ({ : { freeBytes: view.disk.free_bytes, totalBytes: view.disk.total_bytes }, }); +/** T3's history setting names and Trellis's. */ +const HISTORY_KEYS = { + timerMinutes: "timer_minutes", + turnKeepAllDays: "turn_keep_all_days", + turnKeepDailyDays: "turn_keep_daily_days", + timerKeepAllHours: "timer_keep_all_hours", + timerKeepHourlyDays: "timer_keep_hourly_days", + ideaTrashDays: "idea_trash_days", + forkTrashDays: "fork_trash_days", + incomingDays: "incoming_days", +} as const satisfies Record; + +const HistoryValuesView = Schema.Record(Schema.String, Schema.Unknown); + +/** + * `GET /v1/settings/history`. Values are read key by key (see + * `toHistoryValues`), so a key T3 does not know is ignored. + */ +const TrellisHistorySettingsView = Schema.Struct({ + values: HistoryValuesView, + defaults: HistoryValuesView, + snapshots: Schema.optional(Schema.Record(Schema.String, Schema.Finite)), + last_thinning: Schema.optional( + Schema.NullOr(Schema.Struct({ at: Schema.Finite, removed: Schema.Finite })), + ), +}); + +const TrellisHistoryUpdateView = Schema.Struct({ + values: HistoryValuesView, + would_remove: Schema.optional(Schema.Finite), +}); + +/** The history values T3 knows, skipping any that are absent or not whole numbers. */ +function toHistoryValues(raw: Readonly>): TrellisHistoryValues { + const values: { -readonly [K in keyof TrellisHistoryValues]: number } = {}; + for (const key of Object.keys(HISTORY_KEYS) as Array) { + const value = raw[HISTORY_KEYS[key]]; + if (typeof value === "number" && Number.isInteger(value) && value >= 0) values[key] = value; + } + return values; +} + +/** A history settings update in Trellis's names. */ +function toHistoryPatch(values: TrellisHistoryValues): Record { + const patch: Record = {}; + for (const key of Object.keys(HISTORY_KEYS) as Array) { + const value = values[key]; + if (value !== undefined) patch[HISTORY_KEYS[key]] = value; + } + return patch; +} + const sameAgentHomes = (a: TrellisAgentHomes | undefined, b: TrellisAgentHomes | undefined) => a?.claude === b?.claude && a?.codex === b?.codex && (a === undefined) === (b === undefined); @@ -626,6 +685,15 @@ export class Trellis extends Context.Service< >; /** Everything `/v1/status` reports, for display; workspace names are left null. */ readonly details: Effect.Effect; + /** The history settings in force, their defaults, snapshot counts and the last thinning. */ + readonly historySettings: Effect.Effect; + /** + * Changes the given history settings (user socket). Trellis refuses + * values out of bounds or a daily turn horizon shorter than the keep-all one. + */ + readonly updateHistorySettings: ( + values: TrellisHistoryValues, + ) => Effect.Effect; } >()("t3/trellis/Trellis") {} @@ -708,6 +776,10 @@ const ROUTE_MINIMUM: ReadonlyArray<{ readonly route: string; readonly since: str route: "POST /v1/trash/purge-requests", since: "main at or after PR #26 (core/stage-b-forks, 90c1ab6)", }, + ...["GET /v1/settings/history", "PUT /v1/settings/history"].map((route) => ({ + route, + since: "main at or after PR #40 (records/history-settings, 0bed1e8)", + })), ]; /** The error for a failed response whose body is not Trellis' `{error}`. */ @@ -1242,6 +1314,23 @@ const make = Effect.gen(function* () { Effect.map((view) => ({ bases: view.bases ?? [], defaultBase: view.default_base ?? null })), ), details: call(TrellisDetailsView, "GET", "/v1/status").pipe(Effect.map(toDetails)), + historySettings: call(TrellisHistorySettingsView, "GET", "/v1/settings/history").pipe( + Effect.map((view) => ({ + values: toHistoryValues(view.values), + defaults: toHistoryValues(view.defaults), + snapshots: view.snapshots ?? {}, + lastThinning: view.last_thinning ?? null, + })), + ), + updateHistorySettings: (values) => + call(TrellisHistoryUpdateView, "PUT", "/v1/settings/history", { + body: toHistoryPatch(values), + }).pipe( + Effect.map((view) => ({ + values: toHistoryValues(view.values), + wouldRemove: view.would_remove ?? 0, + })), + ), }); }); @@ -1357,6 +1446,8 @@ export function makeTestTrellis( getProject: unused, bases: Effect.die(new Error("unused Trellis operation")), details: Effect.die(new Error("unused Trellis operation")), + historySettings: Effect.die(new Error("unused Trellis operation")), + updateHistorySettings: unused, ...rest, }); } diff --git a/apps/server/src/trellis/TrellisCatalog.ts b/apps/server/src/trellis/TrellisCatalog.ts index 06f3ceb8ed9c..c3177bceff28 100644 --- a/apps/server/src/trellis/TrellisCatalog.ts +++ b/apps/server/src/trellis/TrellisCatalog.ts @@ -29,6 +29,9 @@ import { TrellisError, type TrellisBasesResult, type TrellisDetails, + type TrellisHistorySettings, + type TrellisHistorySettingsUpdateResult, + type TrellisHistoryValues, type TrellisCreateResult, type TrellisFindHit, type TrellisFindResult, @@ -749,6 +752,12 @@ export class TrellisCatalog extends Context.Service< readonly listBases: Effect.Effect; /** The Trellis service's status in full, workspaces named from the last sync. */ readonly details: Effect.Effect; + /** Trellis's snapshot timer, retention and expiry settings, for Settings. */ + readonly historySettings: Effect.Effect; + /** Changes the given history settings; Trellis validates them. */ + readonly updateHistorySettings: ( + values: TrellisHistoryValues, + ) => Effect.Effect; /** The T3 project for a Trellis item just created (a graduation's), made now rather than at the next poll. */ readonly projectFor: ( item: TrellisProjectView, @@ -1893,6 +1902,9 @@ const make = Effect.gen(function* () { Effect.andThen(Effect.all([trellis.details, Ref.get(lastApplied)])), Effect.map(([details, applied]) => nameDetailsWorkspaces(details, applied?.items ?? [])), ), + historySettings: requireReady.pipe(Effect.andThen(trellis.historySettings)), + updateHistorySettings: (values) => + requireReady.pipe(Effect.andThen(trellis.updateHistorySettings(values))), }); }); diff --git a/apps/server/src/trellis/TrellisIdeaPromotion.test.ts b/apps/server/src/trellis/TrellisIdeaPromotion.test.ts index 12f00151de06..fd1bfe4ec2ce 100644 --- a/apps/server/src/trellis/TrellisIdeaPromotion.test.ts +++ b/apps/server/src/trellis/TrellisIdeaPromotion.test.ts @@ -76,6 +76,8 @@ const FakeCatalog = Layer.effect( projectFor: unused, listBases: Effect.die("unused catalog operation"), details: Effect.die("unused catalog operation"), + historySettings: Effect.die("unused catalog operation"), + updateHistorySettings: unused, }); }), ); diff --git a/apps/server/src/trellis/TrellisWorkers.test.ts b/apps/server/src/trellis/TrellisWorkers.test.ts index 3c6044d83a3e..499119e021c6 100644 --- a/apps/server/src/trellis/TrellisWorkers.test.ts +++ b/apps/server/src/trellis/TrellisWorkers.test.ts @@ -233,6 +233,8 @@ function fakeCatalog(fake: ReturnType) { purge: unused, listBases: Effect.die("unused"), details: Effect.die("unused"), + historySettings: Effect.die("unused"), + updateHistorySettings: unused, projectFor: unused, }); }), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 06c95f502555..43b03f8b4580 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -3206,6 +3206,16 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.trellisGetDetails, trellisCatalog.details, { "rpc.aggregate": "trellis", }), + [WS_METHODS.trellisGetHistorySettings]: () => + observeRpcEffect(WS_METHODS.trellisGetHistorySettings, trellisCatalog.historySettings, { + "rpc.aggregate": "trellis", + }), + [WS_METHODS.trellisUpdateHistorySettings]: (input) => + observeRpcEffect( + WS_METHODS.trellisUpdateHistorySettings, + trellisCatalog.updateHistorySettings(input), + { "rpc.aggregate": "trellis" }, + ), [WS_METHODS.filesystemBrowse]: (input) => observeRpcEffect( WS_METHODS.filesystemBrowse, diff --git a/apps/web/src/components/settings/TrellisSettings.logic.test.ts b/apps/web/src/components/settings/TrellisSettings.logic.test.ts index 5376670780d7..89ec9de44491 100644 --- a/apps/web/src/components/settings/TrellisSettings.logic.test.ts +++ b/apps/web/src/components/settings/TrellisSettings.logic.test.ts @@ -1,6 +1,15 @@ import { describe, expect, it } from "vite-plus/test"; -import { formatBytes, staleDetailsNotice, trellisVersionText } from "./TrellisSettings.logic"; +import { + formatBytes, + historyDefaultNote, + historyEdit, + lastThinningText, + snapshotCountsText, + staleDetailsNotice, + trellisVersionText, + wouldRemoveText, +} from "./TrellisSettings.logic"; describe("formatBytes", () => { it("uses the largest decimal unit, with one decimal below ten", () => { @@ -43,3 +52,55 @@ describe("trellisVersionText", () => { expect(trellisVersionText({ version: null, commit: null })).toBeNull(); }); }); + +describe("historyEdit", () => { + it("sends only the edited key, and nothing when the value did not change", () => { + expect(historyEdit("ideaTrashDays", 14, 30)).toEqual({ + kind: "change", + patch: { ideaTrashDays: 14 }, + }); + expect(historyEdit("timerMinutes", 0, 1)).toEqual({ + kind: "change", + patch: { timerMinutes: 0 }, + }); + expect(historyEdit("ideaTrashDays", 30, 30)).toEqual({ kind: "unchanged" }); + }); + + it("refuses an empty field, a fraction or a negative number before sending", () => { + for (const input of [null, 2.5, -1, Number.NaN]) { + expect(historyEdit("turnKeepAllDays", input, 7)).toEqual({ + kind: "invalid", + message: "Enter a whole number, 0 or more.", + }); + } + }); +}); + +describe("history display", () => { + it("names the default only when the value differs from a known one", () => { + expect(historyDefaultNote(14, 30, "day")).toBe("Default: 30 days"); + expect(historyDefaultNote(0, 1, "minute")).toBe("Default: 1 minute"); + expect(historyDefaultNote(30, 30, "day")).toBeNull(); + expect(historyDefaultNote(14, undefined, "day")).toBeNull(); + }); + + it("mentions removals only when the next thinning would remove some", () => { + expect(wouldRemoveText(0)).toBeNull(); + expect(wouldRemoveText(1)).toBe("The next thinning removes 1 snapshot."); + expect(wouldRemoveText(12)).toBe("The next thinning removes 12 snapshots."); + }); + + it("totals snapshots and lists kinds, most first", () => { + expect(snapshotCountsText({ timer: 12, turn: 40, label: 1 })).toEqual({ + total: 53, + byKind: "40 turn · 12 timer · 1 label", + }); + expect(snapshotCountsText({})).toEqual({ total: 0, byKind: null }); + }); + + it("describes the last thinning, or its absence", () => { + const formatTime = (unixSeconds: number) => `t=${unixSeconds}`; + expect(lastThinningText(null, formatTime)).toBe("Not since Trellis started"); + expect(lastThinningText({ at: 5, removed: 1 }, formatTime)).toBe("t=5, removed 1 snapshot"); + }); +}); diff --git a/apps/web/src/components/settings/TrellisSettings.logic.ts b/apps/web/src/components/settings/TrellisSettings.logic.ts index 1ba5ada0d97a..d6654f0efe93 100644 --- a/apps/web/src/components/settings/TrellisSettings.logic.ts +++ b/apps/web/src/components/settings/TrellisSettings.logic.ts @@ -1,4 +1,9 @@ -import type { TrellisDetails, TrellisWorkspaceRef } from "@t3tools/contracts"; +import type { + TrellisDetails, + TrellisHistorySettings, + TrellisHistoryValues, + TrellisWorkspaceRef, +} from "@t3tools/contracts"; const BYTE_UNITS = ["B", "KB", "MB", "GB", "TB", "PB"] as const; @@ -42,3 +47,139 @@ export function trellisVersionText( /** The workspace's project name when T3 knows it, else its id. */ export const workspaceLabel = (workspace: TrellisWorkspaceRef): string => workspace.name ?? workspace.id; + +export type HistoryKey = keyof TrellisHistoryValues; +type HistoryUnit = "minute" | "hour" | "day"; + +/** `1 day`, `30 days`. */ +const formatAmount = (value: number, unit: HistoryUnit): string => + `${value} ${unit}${value === 1 ? "" : "s"}`; + +const KEPT_ANYWAY = "checkpoints, pinned snapshots and each workspace's latest"; + +/** + * The history settings Settings shows, in display order: the timer, turn + * snapshots, timer snapshots, then trash and incoming expiry. Each + * description says what 0 means for that setting. + */ +export const HISTORY_SETTINGS: ReadonlyArray<{ + readonly key: HistoryKey; + readonly title: string; + readonly unit: HistoryUnit; + readonly description: string; +}> = [ + { + key: "timerMinutes", + title: "Snapshot timer", + unit: "minute", + description: + "How often running workspaces are snapshotted when their files changed. 0 turns the timer off.", + }, + { + key: "turnKeepAllDays", + title: "Turn snapshots: keep all", + unit: "day", + description: + "Every snapshot taken at a turn is kept this long. 0 starts thinning them at once.", + }, + { + key: "turnKeepDailyDays", + title: "Turn snapshots: then one a day", + unit: "day", + description: `Then one a day is kept until this age; no shorter than keep all. 0 keeps none but ${KEPT_ANYWAY}.`, + }, + { + key: "timerKeepAllHours", + title: "Timer snapshots: keep all", + unit: "hour", + description: + "Every timer snapshot is kept this long, then one per 15 minutes up to a day. 0 starts thinning them at once.", + }, + { + key: "timerKeepHourlyDays", + title: "Timer snapshots: then one an hour", + unit: "day", + description: `Then one an hour is kept until this age. 0 keeps none but ${KEPT_ANYWAY}.`, + }, + { + key: "ideaTrashDays", + title: "Trashed ideas expire after", + unit: "day", + description: "Ideas in the trash are removed for good after this. 0 keeps them until purged.", + }, + { + key: "forkTrashDays", + title: "Discarded forks expire after", + unit: "day", + description: + "Discarded forks with nothing unmerged are removed for good after this. 0 keeps them until purged.", + }, + { + key: "incomingDays", + title: "Incoming copies expire after", + unit: "day", + description: + "Copies of fork work neither merged nor discarded are removed after this. 0 keeps them.", + }, +]; + +/** `Default: 30 days` when the value differs from a known default, else null. */ +export function historyDefaultNote( + value: number | undefined, + defaultValue: number | undefined, + unit: HistoryUnit, +): string | null { + if (value === undefined || defaultValue === undefined || value === defaultValue) return null; + return `Default: ${formatAmount(defaultValue, unit)}`; +} + +/** + * What committing `input` to the setting `key` does: nothing when it equals + * the current value, a refusal unless it is a whole number of at least 0, else + * the update to send (that key only). Bounds are Trellis's to check. + */ +export function historyEdit( + key: HistoryKey, + input: number | null, + current: number | undefined, +): + | { readonly kind: "unchanged" } + | { readonly kind: "invalid"; readonly message: string } + | { readonly kind: "change"; readonly patch: TrellisHistoryValues } { + if (input === null || !Number.isInteger(input) || input < 0) { + return { kind: "invalid", message: "Enter a whole number, 0 or more." }; + } + if (input === current) return { kind: "unchanged" }; + return { kind: "change", patch: { [key]: input } }; +} + +/** The notice after a save that shortens retention; null when nothing would go. */ +export function wouldRemoveText(wouldRemove: number): string | null { + if (wouldRemove <= 0) return null; + return `The next thinning removes ${wouldRemove} snapshot${wouldRemove === 1 ? "" : "s"}.`; +} + +/** The live snapshot total and its kinds, most first: `40 turn · 12 timer`. */ +export function snapshotCountsText(snapshots: TrellisHistorySettings["snapshots"]): { + readonly total: number; + readonly byKind: string | null; +} { + const entries = Object.entries(snapshots).toSorted( + ([a, x], [b, y]) => y - x || a.localeCompare(b), + ); + return { + total: entries.reduce((sum, [, count]) => sum + count, 0), + byKind: + entries.length === 0 ? null : entries.map(([kind, count]) => `${count} ${kind}`).join(" · "), + }; +} + +/** The last thinning run in words. */ +export function lastThinningText( + lastThinning: TrellisHistorySettings["lastThinning"], + formatTime: (unixSeconds: number) => string, +): string { + if (lastThinning === null) return "Not since Trellis started"; + const removed = `${lastThinning.removed} snapshot${lastThinning.removed === 1 ? "" : "s"}`; + return `${formatTime(lastThinning.at)}, removed ${removed}`; +} diff --git a/apps/web/src/components/settings/TrellisSettings.tsx b/apps/web/src/components/settings/TrellisSettings.tsx index 2e62da189bae..2b630605d22f 100644 --- a/apps/web/src/components/settings/TrellisSettings.tsx +++ b/apps/web/src/components/settings/TrellisSettings.tsx @@ -8,6 +8,7 @@ import { ActivityIcon, AlertTriangleIcon, BoxesIcon, + HistoryIcon, LightbulbIcon, SproutIcon, TrashIcon, @@ -24,16 +25,35 @@ import { refreshTrellisStatus, trellisEnvironment } from "../../state/trellis"; import { useAtomCommand } from "../../state/use-atom-command"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; +import { + NumberField, + NumberFieldDecrement, + NumberFieldGroup, + NumberFieldIncrement, + NumberFieldInput, +} from "../ui/number-field"; import { RefreshIcon } from "../ui/refresh-icon"; import { Spinner } from "../ui/spinner"; import { Switch } from "../ui/switch"; import { toastManager } from "../ui/toast"; -import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout"; +import { + SettingResetButton, + SettingsPageContainer, + SettingsRow, + SettingsSection, +} from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; import { formatBytes, + HISTORY_SETTINGS, + type HistoryKey, + historyDefaultNote, + historyEdit, + lastThinningText, + snapshotCountsText, staleDetailsNotice, trellisVersionText, + wouldRemoveText, workspaceLabel, } from "./TrellisSettings.logic"; import { SettingsScopeNotice } from "./SettingsScopeNotice"; @@ -46,6 +66,8 @@ function failureMessage(result: Parameters[0], const dateFormat = new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }); const formatDay = (unixSeconds: number) => dateFormat.format(new Date(unixSeconds * 1000)); +const timeFormat = new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }); +const formatTime = (unixSeconds: number) => timeFormat.format(new Date(unixSeconds * 1000)); /** When a trashed item goes away for good, in words. */ function trashExpiryText(item: Pick): string { @@ -86,8 +108,8 @@ const KIND_LABELS: Readonly> = { /** * Trellis settings of each selected environment: the integration switch, the - * service's status, its bases, and the Trellis trash with restore. Each topic - * is its own section, so more (previews, history) slot in beside them. + * service's status, its bases, its history settings, and the Trellis trash + * with restore. Each topic is its own section, so more slot in beside them. */ export function TrellisSettingsPanel() { const { connectedEnvironments, scope } = useSettingsScope(); @@ -204,6 +226,7 @@ function TrellisEnvironmentSettings(props: { statusId={anchor("trellis-status")} basesId={anchor("trellis-bases")} /> + ); } + +/** + * Trellis's snapshot timer, retention and expiry settings, one number per + * row. A value is sent when its field commits (blur, Enter, the step + * buttons), only that key; Trellis's refusal shows under the row and the + * field goes back to the value in force. + */ +function TrellisHistorySection(props: { + readonly environmentId: EnvironmentId; + readonly id: string | undefined; +}) { + const { environmentId } = props; + const historyQuery = useEnvironmentQuery( + trellisEnvironment.historySettings({ environmentId, input: {} }), + ); + const update = useAtomCommand(trellisEnvironment.updateHistorySettings, { + reportFailure: false, + }); + const settings = historyQuery.data; + // The last edit's refusal, shown under its row until the next commit. + const [error, setError] = useState<{ readonly key: HistoryKey; readonly message: string } | null>( + null, + ); + // Bumped to reset a field to the value in force after a refused edit. + const [revisions, setRevisions] = useState>>({}); + + const resetField = (key: HistoryKey) => + setRevisions((current) => ({ ...current, [key]: (current[key] ?? 0) + 1 })); + + const commit = async (key: HistoryKey, input: number | null) => { + const edit = historyEdit(key, input, settings?.values[key]); + setError(null); + if (edit.kind === "unchanged") return; + if (edit.kind === "invalid") { + setError({ key, message: edit.message }); + resetField(key); + return; + } + const result = await update({ environmentId, input: edit.patch }); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + setError({ key, message: failureMessage(result, "Trellis did not respond.") }); + } + resetField(key); + return; + } + const notice = wouldRemoveText(result.value.wouldRemove); + if (notice !== null) { + toastManager.add({ type: "info", title: "History settings saved", description: notice }); + } + historyQuery.refresh(); + }; + + const counts = settings === null ? null : snapshotCountsText(settings.snapshots); + + return ( + } + > + {settings === null ? ( + + {historyQuery.isPending ? : null} + {historyQuery.isPending + ? "Loading history settings" + : "Could not read the history settings"} + + } + description={historyQuery.error ?? undefined} + /> + ) : ( + <> + {HISTORY_SETTINGS.map((setting) => { + const value = settings.values[setting.key]; + if (value === undefined) return null; + const defaultValue = settings.defaults[setting.key]; + const defaultNote = historyDefaultNote(value, defaultValue, setting.unit); + const message = error?.key === setting.key ? error.message : undefined; + return ( + + {defaultNote === null ? null : {defaultNote}} + {message === undefined ? null : ( + + {message} + + )} + + ) + } + resetAction={ + defaultNote === null || defaultValue === undefined ? undefined : ( + void commit(setting.key, defaultValue)} + /> + ) + } + control={ +
+ void commit(setting.key, next)} + > + + + { + if (event.key === "Enter") event.currentTarget.blur(); + }} + /> + + + + {`${setting.unit}s`} +
+ } + /> + ); + })} + {counts?.total ?? 0}} + /> + {lastThinningText(settings.lastThinning, formatTime)}} + /> + + )} +
+ ); +} diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 0562035e474f..4b43306ae545 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -713,6 +713,15 @@ export const SETTINGS_SEARCH_ITEMS = [ scope: "environment-defaults", searchTerms: ["trellis trash restore deleted ideas projects forks empty purge"], }, + { + id: "trellis-history", + title: "Trellis history", + to: "/settings/trellis", + scope: "environment-defaults", + searchTerms: [ + "trellis history snapshots timer retention keep thinning expiry expire trash incoming days", + ], + }, { id: "automatic-pull", title: "Automatically pull", diff --git a/apps/web/src/state/trellis.ts b/apps/web/src/state/trellis.ts index 047c36213799..a4213f0af2c0 100644 --- a/apps/web/src/state/trellis.ts +++ b/apps/web/src/state/trellis.ts @@ -104,6 +104,17 @@ export const trellisEnvironment = { idleTtlMs: 60_000, refreshIntervalMs: 60_000, }), + /** Trellis's snapshot timer, retention and expiry settings, with snapshot counts. */ + historySettings: createEnvironmentRpcQueryAtomFamily(connectionAtomRuntime, { + label: "environment-data:trellis:history-settings", + tag: WS_METHODS.trellisGetHistorySettings, + staleTimeMs: 30_000, + idleTtlMs: 60_000, + }), + updateHistorySettings: createEnvironmentRpcCommand(connectionAtomRuntime, { + label: "environment-data:trellis:update-history-settings", + tag: WS_METHODS.trellisUpdateHistorySettings, + }), graduate: createEnvironmentRpcCommand(connectionAtomRuntime, { label: "environment-data:trellis:graduate", tag: WS_METHODS.trellisGraduate, diff --git a/docs/user/trellis.md b/docs/user/trellis.md index 19aff804c5e9..85289ed76d84 100644 --- a/docs/user/trellis.md +++ b/docs/user/trellis.md @@ -101,6 +101,10 @@ A discarded fork that holds work its parent workspace lacks (uncommitted changes merged back) is kept until you purge it; the trash says why. Agents never purge: they can ask you to, and the trash shows the request with a **Purge** button. +How long snapshots are kept, how often running workspaces are snapshotted, and when trashed ideas, +discarded forks and incoming fork copies expire are set under **History** in **Settings** > **Trellis**; +each row says what 0 means for it. + ## Restoring files from a turn Reverting a turn in a Trellis project can restore its files from Trellis' snapshot of that turn. An diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 800b8cfbf49b..038b0f013a0a 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -19,6 +19,9 @@ import { TrellisGraduateResult, TrellisBasesResult, TrellisDetails, + TrellisHistorySettings, + TrellisHistorySettingsUpdateResult, + TrellisHistoryValues, TrellisRestoreConflicts, TrellisRestoreConflictsInput, TrellisRestoreInput, @@ -403,6 +406,8 @@ export const WS_METHODS = { trellisGraduate: "trellis.graduate", trellisListBases: "trellis.listBases", trellisGetDetails: "trellis.getDetails", + trellisGetHistorySettings: "trellis.getHistorySettings", + trellisUpdateHistorySettings: "trellis.updateHistorySettings", assetsCreateUrl: "assets.createUrl", assetsPersistChatAttachments: "assets.persistChatAttachments", attachmentsCreateUploadUrl: "attachments.createUploadUrl", @@ -1310,6 +1315,19 @@ const WsTrellisGetDetailsRpc = Rpc.make(WS_METHODS.trellisGetDetails, { error: TrellisRpcError, }); +const WsTrellisGetHistorySettingsRpc = Rpc.make(WS_METHODS.trellisGetHistorySettings, { + payload: Schema.Struct({}), + success: TrellisHistorySettings, + error: TrellisRpcError, +}); + +/** Changes the given history settings; Trellis validates the merged result. */ +const WsTrellisUpdateHistorySettingsRpc = Rpc.make(WS_METHODS.trellisUpdateHistorySettings, { + payload: TrellisHistoryValues, + success: TrellisHistorySettingsUpdateResult, + error: TrellisRpcError, +}); + const WsAgentSessionsScanRpc = Rpc.make(WS_METHODS.agentSessionsScan, { payload: AgentSessionScanInput, success: AgentSessionScanResult, @@ -1940,6 +1958,8 @@ export const WsRpcGroup = RpcGroup.make( WsTrellisGraduateRpc, WsTrellisListBasesRpc, WsTrellisGetDetailsRpc, + WsTrellisGetHistorySettingsRpc, + WsTrellisUpdateHistorySettingsRpc, WsAgentSessionsScanRpc, WsAgentSessionsImportRpc, WsAssetsCreateUrlRpc, diff --git a/packages/contracts/src/trellis.ts b/packages/contracts/src/trellis.ts index b5136c2d6a62..ccf8cb175b0e 100644 --- a/packages/contracts/src/trellis.ts +++ b/packages/contracts/src/trellis.ts @@ -529,3 +529,39 @@ export const TrellisDetails = Schema.Struct({ disk: Schema.NullOr(Schema.Struct({ freeBytes: Schema.Finite, totalBytes: Schema.Finite })), }); export type TrellisDetails = typeof TrellisDetails.Type; + +/** + * Trellis's history settings (`GET /v1/settings/history`): the snapshot + * timer, how long turn and timer snapshots are kept, and when trashed ideas, + * discarded forks and incoming copies expire. Whole numbers; 0 turns the timer + * off, keeps nothing past the head rules for a retention, and means never for + * an expiry. A key an older or newer Trellis lacks is absent. + */ +export const TrellisHistoryValues = Schema.Struct({ + timerMinutes: Schema.optionalKey(NonNegativeInt), + turnKeepAllDays: Schema.optionalKey(NonNegativeInt), + turnKeepDailyDays: Schema.optionalKey(NonNegativeInt), + timerKeepAllHours: Schema.optionalKey(NonNegativeInt), + timerKeepHourlyDays: Schema.optionalKey(NonNegativeInt), + ideaTrashDays: Schema.optionalKey(NonNegativeInt), + forkTrashDays: Schema.optionalKey(NonNegativeInt), + incomingDays: Schema.optionalKey(NonNegativeInt), +}); +export type TrellisHistoryValues = typeof TrellisHistoryValues.Type; + +export const TrellisHistorySettings = Schema.Struct({ + values: TrellisHistoryValues, + defaults: TrellisHistoryValues, + /** Live snapshots per kind (`turn`, `timer`, ...) across live workspaces. */ + snapshots: Schema.Record(Schema.String, Schema.Finite), + /** The last thinning run (`at` in Unix seconds); null before the first. */ + lastThinning: Schema.NullOr(Schema.Struct({ at: Schema.Finite, removed: Schema.Finite })), +}); +export type TrellisHistorySettings = typeof TrellisHistorySettings.Type; + +export const TrellisHistorySettingsUpdateResult = Schema.Struct({ + values: TrellisHistoryValues, + /** Snapshots the next thinning would remove under the new values. */ + wouldRemove: Schema.Finite, +}); +export type TrellisHistorySettingsUpdateResult = typeof TrellisHistorySettingsUpdateResult.Type; From 8d753f372293e86bf9d433ae78dee244b09dc250 Mon Sep 17 00:00:00 2001 From: Andrey170170 Date: Fri, 2 Oct 2026 17:06:03 -0600 Subject: [PATCH 2/6] fix(trellis): order History writes and settle each field by its latest write - Unchanged-detection compares with the field's pending value, else the value in force, so 30 -> 14 -> 30 before the answer still sends 30. - updateHistorySettings runs serially per environment; each write carries a per-field revision, and only a field's latest write clears its draft or shows its refusal, so a stale failure never undoes a newer edit. - A successful write's returned values show at once (until a newer read), and a failed refresh shows a stale notice beside the cached values. The edit state lives in TrellisSettings.logic.ts with unit tests; the server test also maps all eight keys out in one PUT. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/server/src/trellis/Trellis.test.ts | 37 ++++++- .../settings/TrellisSettings.logic.test.ts | 67 +++++++++++++ .../settings/TrellisSettings.logic.ts | 99 ++++++++++++++++++- .../components/settings/TrellisSettings.tsx | 82 ++++++++++----- apps/web/src/state/trellis.ts | 2 + 5 files changed, 259 insertions(+), 28 deletions(-) diff --git a/apps/server/src/trellis/Trellis.test.ts b/apps/server/src/trellis/Trellis.test.ts index 02399aa652f4..0153bc8a5b40 100644 --- a/apps/server/src/trellis/Trellis.test.ts +++ b/apps/server/src/trellis/Trellis.test.ts @@ -450,12 +450,22 @@ describe("Trellis client", () => { const trellis = yield* Trellis.Trellis; const settings = yield* trellis.historySettings; const updated = yield* trellis.updateHistorySettings({ turnKeepAllDays: 3 }); + const everyKey = yield* trellis.updateHistorySettings({ + timerMinutes: 2, + turnKeepAllDays: 8, + turnKeepDailyDays: 91, + timerKeepAllHours: 3, + timerKeepHourlyDays: 9, + ideaTrashDays: 10, + forkTrashDays: 11, + incomingDays: 0, + }); const refused = yield* trellis .updateHistorySettings({ turnKeepDailyDays: 2 }) .pipe(Effect.flip); historyRoute = false; const older = yield* trellis.historySettings.pipe(Effect.flip); - return { settings, updated, refused, older }; + return { settings, updated, everyKey, refused, older }; }).pipe(Effect.provide(layer)); const defaults = { timerMinutes: 1, @@ -479,7 +489,30 @@ describe("Trellis client", () => { harness.requests .filter((request) => request.method === "PUT") .map((request) => JSON.parse(request.body)), - ).toEqual([{ turn_keep_all_days: 3 }, { turn_keep_daily_days: 2 }]); + ).toEqual([ + { turn_keep_all_days: 3 }, + { + timer_minutes: 2, + turn_keep_all_days: 8, + turn_keep_daily_days: 91, + timer_keep_all_hours: 3, + timer_keep_hourly_days: 9, + idea_trash_days: 10, + fork_trash_days: 11, + incoming_days: 0, + }, + { turn_keep_daily_days: 2 }, + ]); + expect(result.everyKey.values).toEqual({ + timerMinutes: 2, + turnKeepAllDays: 8, + turnKeepDailyDays: 91, + timerKeepAllHours: 3, + timerKeepHourlyDays: 9, + ideaTrashDays: 10, + forkTrashDays: 11, + incomingDays: 0, + }); expect(result.updated).toEqual({ values: { ...defaults, turnKeepAllDays: 3 }, wouldRemove: 3, diff --git a/apps/web/src/components/settings/TrellisSettings.logic.test.ts b/apps/web/src/components/settings/TrellisSettings.logic.test.ts index 89ec9de44491..75574e636520 100644 --- a/apps/web/src/components/settings/TrellisSettings.logic.test.ts +++ b/apps/web/src/components/settings/TrellisSettings.logic.test.ts @@ -1,9 +1,14 @@ import { describe, expect, it } from "vite-plus/test"; import { + finishHistoryWrite, formatBytes, historyDefaultNote, historyEdit, + historyFieldValue, + historyValuesInForce, + NO_HISTORY_EDITS, + startHistoryWrite, lastThinningText, snapshotCountsText, staleDetailsNotice, @@ -57,10 +62,12 @@ describe("historyEdit", () => { it("sends only the edited key, and nothing when the value did not change", () => { expect(historyEdit("ideaTrashDays", 14, 30)).toEqual({ kind: "change", + value: 14, patch: { ideaTrashDays: 14 }, }); expect(historyEdit("timerMinutes", 0, 1)).toEqual({ kind: "change", + value: 0, patch: { timerMinutes: 0 }, }); expect(historyEdit("ideaTrashDays", 30, 30)).toEqual({ kind: "unchanged" }); @@ -104,3 +111,63 @@ describe("history display", () => { expect(lastThinningText({ at: 5, removed: 1 }, formatTime)).toBe("t=5, removed 1 snapshot"); }); }); + +describe("history writes", () => { + const read = { ideaTrashDays: 30, forkTrashDays: 30 }; + const field = (edits: typeof NO_HISTORY_EDITS, readAt = 0) => + historyFieldValue("ideaTrashDays", historyValuesInForce(read, readAt, edits), edits); + + it("compares an edit with the pending value, so going back before the answer is sent", () => { + const sent = startHistoryWrite(NO_HISTORY_EDITS, "ideaTrashDays", 14, 1); + expect(field(sent)).toBe(14); + expect(historyEdit("ideaTrashDays", 30, field(sent))).toMatchObject({ kind: "change" }); + expect(historyEdit("ideaTrashDays", 14, field(sent))).toEqual({ kind: "unchanged" }); + }); + + it("shows a write's answer at once, until a newer read replaces it", () => { + const sent = startHistoryWrite(NO_HISTORY_EDITS, "ideaTrashDays", 14, 1); + const done = finishHistoryWrite(sent, "ideaTrashDays", 1, { + ok: true, + values: { ...read, ideaTrashDays: 14 }, + at: 100, + }); + expect(done.pending).toEqual({}); + expect(field(done, 50)).toBe(14); + expect(historyValuesInForce({ ideaTrashDays: 21 }, 200, done)).toEqual({ ideaTrashDays: 21 }); + }); + + it("lets only a field's latest write settle it", () => { + let edits = startHistoryWrite(NO_HISTORY_EDITS, "ideaTrashDays", 14, 1); + edits = startHistoryWrite(edits, "ideaTrashDays", 20, 2); + // The older write fails after the newer edit: the newer draft stays, no error. + edits = finishHistoryWrite(edits, "ideaTrashDays", 1, { ok: false, message: "refused" }); + expect(field(edits)).toBe(20); + expect(edits.error).toBeNull(); + expect(edits.resets).toEqual({}); + edits = finishHistoryWrite(edits, "ideaTrashDays", 2, { + ok: true, + values: { ...read, ideaTrashDays: 20 }, + at: 1, + }); + expect(field(edits)).toBe(20); + expect(edits.pending).toEqual({}); + }); + + it("puts a field back and says why when its latest write is refused", () => { + const sent = startHistoryWrite(NO_HISTORY_EDITS, "ideaTrashDays", 9999, 1); + const refused = finishHistoryWrite(sent, "ideaTrashDays", 1, { + ok: false, + message: "idea_trash_days must be at most 3650, not 9999", + }); + expect(field(refused)).toBe(30); + expect(refused.error).toEqual({ + key: "ideaTrashDays", + message: "idea_trash_days must be at most 3650, not 9999", + }); + expect(refused.resets).toEqual({ ideaTrashDays: 1 }); + // An interrupted write reverts the field without a message. + const interrupted = finishHistoryWrite(sent, "ideaTrashDays", 1, { ok: false, message: null }); + expect(interrupted.error).toBeNull(); + expect(interrupted.resets).toEqual({ ideaTrashDays: 1 }); + }); +}); diff --git a/apps/web/src/components/settings/TrellisSettings.logic.ts b/apps/web/src/components/settings/TrellisSettings.logic.ts index d6654f0efe93..0a866fc2c260 100644 --- a/apps/web/src/components/settings/TrellisSettings.logic.ts +++ b/apps/web/src/components/settings/TrellisSettings.logic.ts @@ -135,7 +135,7 @@ export function historyDefaultNote( /** * What committing `input` to the setting `key` does: nothing when it equals - * the current value, a refusal unless it is a whole number of at least 0, else + * `current` (its pending value, else the value in force; see `historyFieldValue`), a refusal unless it is a whole number of at least 0, else * the update to send (that key only). Bounds are Trellis's to check. */ export function historyEdit( @@ -145,12 +145,105 @@ export function historyEdit( ): | { readonly kind: "unchanged" } | { readonly kind: "invalid"; readonly message: string } - | { readonly kind: "change"; readonly patch: TrellisHistoryValues } { + | { readonly kind: "change"; readonly value: number; readonly patch: TrellisHistoryValues } { if (input === null || !Number.isInteger(input) || input < 0) { return { kind: "invalid", message: "Enter a whole number, 0 or more." }; } if (input === current) return { kind: "unchanged" }; - return { kind: "change", patch: { [key]: input } }; + return { kind: "change", value: input, patch: { [key]: input } }; +} + +/** + * The History section's edits on top of the last read. Writes go out one at + * a time in order; each carries a revision so only a field's latest write + * settles its draft and error, and an older answer never undoes a newer edit. + */ +export interface HistoryEdits { + /** The values the last successful write returned, and when (epoch ms). */ + readonly confirmed: { readonly values: TrellisHistoryValues; readonly at: number } | null; + /** Per field, the latest value sent and not yet answered. */ + readonly pending: Partial< + Record + >; + /** Bumped to put a field back to the value in force after a refused edit. */ + readonly resets: Partial>; + /** The last refused edit, shown under its row until the next commit. */ + readonly error: { readonly key: HistoryKey; readonly message: string } | null; +} + +export const NO_HISTORY_EDITS: HistoryEdits = { + confirmed: null, + pending: {}, + resets: {}, + error: null, +}; + +/** + * The values in force as this client knows them: the last read, or the last + * write's answer when it is newer than that read. + */ +export function historyValuesInForce( + read: TrellisHistoryValues, + readAt: number, + edits: HistoryEdits, +): TrellisHistoryValues { + return edits.confirmed === null || readAt > edits.confirmed.at ? read : edits.confirmed.values; +} + +/** What a field shows and what an edit compares against: its pending value, else the value in force. */ +export function historyFieldValue( + key: HistoryKey, + inForce: TrellisHistoryValues, + edits: HistoryEdits, +): number | undefined { + return edits.pending[key]?.value ?? inForce[key]; +} + +/** Records a write of `value` to `key` sent as `revision`. */ +export function startHistoryWrite( + edits: HistoryEdits, + key: HistoryKey, + value: number, + revision: number, +): HistoryEdits { + return { ...edits, error: null, pending: { ...edits.pending, [key]: { value, revision } } }; +} + +/** Records an edit refused before sending: the field goes back and says why. */ +export function rejectHistoryEdit( + edits: HistoryEdits, + key: HistoryKey, + message: string, +): HistoryEdits { + return { + ...edits, + error: { key, message }, + resets: { ...edits.resets, [key]: (edits.resets[key] ?? 0) + 1 }, + }; +} + +/** + * Settles the write `revision` of `key`. Answers arrive in order, so a + * success's values are the newest in force. Only the field's latest write + * clears its pending value, and only its failure reverts the field and shows + * the refusal (`message` null: interrupted, nothing to show). + */ +export function finishHistoryWrite( + edits: HistoryEdits, + key: HistoryKey, + revision: number, + outcome: + | { readonly ok: true; readonly values: TrellisHistoryValues; readonly at: number } + | { readonly ok: false; readonly message: string | null }, +): HistoryEdits { + const confirmed = outcome.ok ? { values: outcome.values, at: outcome.at } : edits.confirmed; + if (edits.pending[key]?.revision !== revision) return { ...edits, confirmed }; + const { [key]: _settled, ...pending } = edits.pending; + const settled = { ...edits, confirmed, pending }; + if (outcome.ok) return settled; + return outcome.message === null + ? { ...settled, resets: { ...edits.resets, [key]: (edits.resets[key] ?? 0) + 1 } } + : rejectHistoryEdit(settled, key, outcome.message); } /** The notice after a save that shortens retention; null when nothing would go. */ diff --git a/apps/web/src/components/settings/TrellisSettings.tsx b/apps/web/src/components/settings/TrellisSettings.tsx index 2b630605d22f..196ec16de5de 100644 --- a/apps/web/src/components/settings/TrellisSettings.tsx +++ b/apps/web/src/components/settings/TrellisSettings.tsx @@ -13,7 +13,7 @@ import { SproutIcon, TrashIcon, } from "lucide-react"; -import { type ReactNode, useState } from "react"; +import { type ReactNode, useRef, useState } from "react"; import { useTrellisStatusFor } from "../../hooks/useTrellis"; import { cn } from "../../lib/utils"; @@ -45,10 +45,17 @@ import { import { searchableSetting } from "./settingsSearch"; import { formatBytes, + finishHistoryWrite, HISTORY_SETTINGS, + type HistoryEdits, type HistoryKey, historyDefaultNote, historyEdit, + historyFieldValue, + historyValuesInForce, + NO_HISTORY_EDITS, + rejectHistoryEdit, + startHistoryWrite, lastThinningText, snapshotCountsText, staleDetailsNotice, @@ -666,8 +673,8 @@ function TrellisTrashSection(props: { /** * Trellis's snapshot timer, retention and expiry settings, one number per * row. A value is sent when its field commits (blur, Enter, the step - * buttons), only that key; Trellis's refusal shows under the row and the - * field goes back to the value in force. + * buttons), only that key, one write at a time; Trellis's refusal shows + * under the row and the field goes back to the value in force. */ function TrellisHistorySection(props: { readonly environmentId: EnvironmentId; @@ -681,37 +688,60 @@ function TrellisHistorySection(props: { reportFailure: false, }); const settings = historyQuery.data; - // The last edit's refusal, shown under its row until the next commit. - const [error, setError] = useState<{ readonly key: HistoryKey; readonly message: string } | null>( - null, - ); - // Bumped to reset a field to the value in force after a refused edit. - const [revisions, setRevisions] = useState>>({}); - - const resetField = (key: HistoryKey) => - setRevisions((current) => ({ ...current, [key]: (current[key] ?? 0) + 1 })); + // Kept in a ref as well, so commits made before a re-render see each other. + const editsRef = useRef(NO_HISTORY_EDITS); + const [edits, setEdits] = useState(NO_HISTORY_EDITS); + const nextRevision = useRef(0); + const apply = (change: (current: HistoryEdits) => HistoryEdits) => { + editsRef.current = change(editsRef.current); + setEdits(editsRef.current); + }; + const inForce = + settings === null + ? {} + : historyValuesInForce(settings.values, historyQuery.dataUpdatedAt, edits); + const staleNotice = staleDetailsNotice({ + hasData: settings !== null, + error: historyQuery.error, + updatedAt: historyQuery.dataUpdatedAt, + }); const commit = async (key: HistoryKey, input: number | null) => { - const edit = historyEdit(key, input, settings?.values[key]); - setError(null); + if (settings === null) return; + const current = historyFieldValue( + key, + historyValuesInForce(settings.values, historyQuery.dataUpdatedAt, editsRef.current), + editsRef.current, + ); + const edit = historyEdit(key, input, current); if (edit.kind === "unchanged") return; if (edit.kind === "invalid") { - setError({ key, message: edit.message }); - resetField(key); + apply((current) => rejectHistoryEdit(current, key, edit.message)); return; } + const revision = ++nextRevision.current; + apply((current) => startHistoryWrite(current, key, edit.value, revision)); + // Serial per environment, so writes reach Trellis in the order they were made. const result = await update({ environmentId, input: edit.patch }); if (result._tag === "Failure") { - if (!isAtomCommandInterrupted(result)) { - setError({ key, message: failureMessage(result, "Trellis did not respond.") }); - } - resetField(key); + const message = isAtomCommandInterrupted(result) + ? null + : failureMessage(result, "Trellis did not respond."); + apply((current) => finishHistoryWrite(current, key, revision, { ok: false, message })); return; } + apply((current) => + finishHistoryWrite(current, key, revision, { + ok: true, + values: result.value.values, + at: Date.now(), + }), + ); const notice = wouldRemoveText(result.value.wouldRemove); if (notice !== null) { toastManager.add({ type: "info", title: "History settings saved", description: notice }); } + // For the snapshot counts; the values are already the write's answer. historyQuery.refresh(); }; @@ -737,12 +767,18 @@ function TrellisHistorySection(props: { /> ) : ( <> + {staleNotice === null ? null : ( + History settings may be out of date} + description={staleNotice} + /> + )} {HISTORY_SETTINGS.map((setting) => { - const value = settings.values[setting.key]; + const value = historyFieldValue(setting.key, inForce, edits); if (value === undefined) return null; const defaultValue = settings.defaults[setting.key]; const defaultNote = historyDefaultNote(value, defaultValue, setting.unit); - const message = error?.key === setting.key ? error.message : undefined; + const message = edits.error?.key === setting.key ? edits.error.message : undefined; return ( environmentId }, }), graduate: createEnvironmentRpcCommand(connectionAtomRuntime, { label: "environment-data:trellis:graduate", From 0bca20a78befad4a7b2773f878dd28d6d3ce2fe3 Mon Sep 17 00:00:00 2001 From: Andrey170170 Date: Fri, 2 Oct 2026 19:30:36 -0600 Subject: [PATCH 3/6] feat(trellis): Run maintenance now from the History settings; keep the number fields mounted while stepping Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/server/src/auth/RpcAuthorization.ts | 1 + apps/server/src/trellis/Trellis.test.ts | 17 +++++++ apps/server/src/trellis/Trellis.ts | 10 ++++ apps/server/src/trellis/TrellisCatalog.ts | 3 ++ .../src/trellis/TrellisIdeaPromotion.test.ts | 1 + .../server/src/trellis/TrellisWorkers.test.ts | 1 + apps/server/src/ws.ts | 6 +++ .../components/settings/TrellisSettings.tsx | 47 +++++++++++++++++-- apps/web/src/state/trellis.ts | 5 ++ docs/user/trellis.md | 3 +- packages/contracts/src/rpc.ts | 9 ++++ 11 files changed, 98 insertions(+), 5 deletions(-) diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 7413a89f7e32..ecdef8603143 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -155,6 +155,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.trellisGetDetails]: AuthOrchestrationReadScope, [WS_METHODS.trellisGetHistorySettings]: AuthOrchestrationReadScope, [WS_METHODS.trellisUpdateHistorySettings]: AuthOrchestrationOperateScope, + [WS_METHODS.trellisRunMaintenance]: AuthOrchestrationOperateScope, [WS_METHODS.trellisFind]: AuthOrchestrationReadScope, [WS_METHODS.trellisRestoreConflicts]: AuthOrchestrationReadScope, // Publishes a workspace port, like preview.open. diff --git a/apps/server/src/trellis/Trellis.test.ts b/apps/server/src/trellis/Trellis.test.ts index 0153bc8a5b40..1976589fcd9e 100644 --- a/apps/server/src/trellis/Trellis.test.ts +++ b/apps/server/src/trellis/Trellis.test.ts @@ -525,6 +525,23 @@ describe("Trellis client", () => { ); }), ); + + it.effect("runs maintenance through the user socket", () => + Effect.gen(function* () { + const harness = setup(({ url }) => + url === "/v1/maintenance" ? { body: { ok: true } } : { body: { root: "/trellis" } }, + ); + const layer = yield* Effect.promise(() => harness.listen()); + yield* Effect.flatMap(Trellis.Trellis, (trellis) => trellis.runMaintenance).pipe( + Effect.provide(layer), + ); + expect( + harness.requests.some( + (request) => request.method === "POST" && request.url === "/v1/maintenance", + ), + ).toBe(true); + }), + ); }); describe("parseKnownRoots", () => { diff --git a/apps/server/src/trellis/Trellis.ts b/apps/server/src/trellis/Trellis.ts index 2584c0eee0db..b6e0145b5b8d 100644 --- a/apps/server/src/trellis/Trellis.ts +++ b/apps/server/src/trellis/Trellis.ts @@ -694,6 +694,11 @@ export class Trellis extends Context.Service< readonly updateHistorySettings: ( values: TrellisHistoryValues, ) => Effect.Effect; + /** + * Runs maintenance now (`POST /v1/maintenance`): expiries and thinning by + * the history settings, as the timer would; never purges projects. + */ + readonly runMaintenance: Effect.Effect; } >()("t3/trellis/Trellis") {} @@ -1322,6 +1327,10 @@ const make = Effect.gen(function* () { lastThinning: view.last_thinning ?? null, })), ), + runMaintenance: call(Schema.Unknown, "POST", "/v1/maintenance", { + // Thinning and reindexing a large root take a while. + timeoutMs: 10 * 60_000, + }).pipe(Effect.asVoid), updateHistorySettings: (values) => call(TrellisHistoryUpdateView, "PUT", "/v1/settings/history", { body: toHistoryPatch(values), @@ -1448,6 +1457,7 @@ export function makeTestTrellis( details: Effect.die(new Error("unused Trellis operation")), historySettings: Effect.die(new Error("unused Trellis operation")), updateHistorySettings: unused, + runMaintenance: Effect.die(new Error("unused Trellis operation")), ...rest, }); } diff --git a/apps/server/src/trellis/TrellisCatalog.ts b/apps/server/src/trellis/TrellisCatalog.ts index c3177bceff28..67026f9e0ce2 100644 --- a/apps/server/src/trellis/TrellisCatalog.ts +++ b/apps/server/src/trellis/TrellisCatalog.ts @@ -758,6 +758,8 @@ export class TrellisCatalog extends Context.Service< readonly updateHistorySettings: ( values: TrellisHistoryValues, ) => Effect.Effect; + /** Runs Trellis maintenance now; see `Trellis.runMaintenance`. */ + readonly runMaintenance: Effect.Effect; /** The T3 project for a Trellis item just created (a graduation's), made now rather than at the next poll. */ readonly projectFor: ( item: TrellisProjectView, @@ -1905,6 +1907,7 @@ const make = Effect.gen(function* () { historySettings: requireReady.pipe(Effect.andThen(trellis.historySettings)), updateHistorySettings: (values) => requireReady.pipe(Effect.andThen(trellis.updateHistorySettings(values))), + runMaintenance: requireReady.pipe(Effect.andThen(trellis.runMaintenance)), }); }); diff --git a/apps/server/src/trellis/TrellisIdeaPromotion.test.ts b/apps/server/src/trellis/TrellisIdeaPromotion.test.ts index fd1bfe4ec2ce..7ed021ab8e08 100644 --- a/apps/server/src/trellis/TrellisIdeaPromotion.test.ts +++ b/apps/server/src/trellis/TrellisIdeaPromotion.test.ts @@ -78,6 +78,7 @@ const FakeCatalog = Layer.effect( details: Effect.die("unused catalog operation"), historySettings: Effect.die("unused catalog operation"), updateHistorySettings: unused, + runMaintenance: Effect.die("unused"), }); }), ); diff --git a/apps/server/src/trellis/TrellisWorkers.test.ts b/apps/server/src/trellis/TrellisWorkers.test.ts index 499119e021c6..71d06a60a780 100644 --- a/apps/server/src/trellis/TrellisWorkers.test.ts +++ b/apps/server/src/trellis/TrellisWorkers.test.ts @@ -235,6 +235,7 @@ function fakeCatalog(fake: ReturnType) { details: Effect.die("unused"), historySettings: Effect.die("unused"), updateHistorySettings: unused, + runMaintenance: Effect.die("unused"), projectFor: unused, }); }), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 43b03f8b4580..f9a7a65897b5 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -3210,6 +3210,12 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.trellisGetHistorySettings, trellisCatalog.historySettings, { "rpc.aggregate": "trellis", }), + [WS_METHODS.trellisRunMaintenance]: () => + observeRpcEffect( + WS_METHODS.trellisRunMaintenance, + trellisCatalog.runMaintenance.pipe(Effect.as({})), + { "rpc.aggregate": "trellis" }, + ), [WS_METHODS.trellisUpdateHistorySettings]: (input) => observeRpcEffect( WS_METHODS.trellisUpdateHistorySettings, diff --git a/apps/web/src/components/settings/TrellisSettings.tsx b/apps/web/src/components/settings/TrellisSettings.tsx index 196ec16de5de..df48d5c689f5 100644 --- a/apps/web/src/components/settings/TrellisSettings.tsx +++ b/apps/web/src/components/settings/TrellisSettings.tsx @@ -687,6 +687,30 @@ function TrellisHistorySection(props: { const update = useAtomCommand(trellisEnvironment.updateHistorySettings, { reportFailure: false, }); + const runMaintenance = useAtomCommand(trellisEnvironment.runMaintenance, { + reportFailure: false, + }); + const [maintaining, setMaintaining] = useState(false); + const thinNow = async () => { + setMaintaining(true); + try { + const result = await runMaintenance({ environmentId, input: {} }); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + toastManager.add({ + type: "error", + title: "Could not run maintenance", + description: failureMessage(result, "Trellis did not respond."), + }); + } + return; + } + toastManager.add({ type: "success", title: "Maintenance done" }); + } finally { + setMaintaining(false); + historyQuery.refresh(); + } + }; const settings = historyQuery.data; // Kept in a ref as well, so commits made before a re-render see each other. const editsRef = useRef(NO_HISTORY_EDITS); @@ -807,8 +831,10 @@ function TrellisHistorySection(props: { control={
{lastThinningText(settings.lastThinning, formatTime)}} + description="Thinning runs with maintenance, about once an hour; Run now applies changed settings at once. It never purges projects." + control={ + + {lastThinningText(settings.lastThinning, formatTime)} + + + } /> )} diff --git a/apps/web/src/state/trellis.ts b/apps/web/src/state/trellis.ts index 15561bb06bad..28c591a2624c 100644 --- a/apps/web/src/state/trellis.ts +++ b/apps/web/src/state/trellis.ts @@ -117,6 +117,11 @@ export const trellisEnvironment = { tag: WS_METHODS.trellisUpdateHistorySettings, concurrency: { mode: "serial", key: ({ environmentId }) => environmentId }, }), + runMaintenance: createEnvironmentRpcCommand(connectionAtomRuntime, { + label: "environment-data:trellis:run-maintenance", + tag: WS_METHODS.trellisRunMaintenance, + concurrency: { mode: "serial", key: ({ environmentId }) => environmentId }, + }), graduate: createEnvironmentRpcCommand(connectionAtomRuntime, { label: "environment-data:trellis:graduate", tag: WS_METHODS.trellisGraduate, diff --git a/docs/user/trellis.md b/docs/user/trellis.md index 85289ed76d84..d97239d76483 100644 --- a/docs/user/trellis.md +++ b/docs/user/trellis.md @@ -103,7 +103,8 @@ and the trash shows the request with a **Purge** button. How long snapshots are kept, how often running workspaces are snapshotted, and when trashed ideas, discarded forks and incoming fork copies expire are set under **History** in **Settings** > **Trellis**; -each row says what 0 means for it. +each row says what 0 means for it. Changes apply at the next maintenance, about once an hour; +**Run now** applies them at once. ## Restoring files from a turn diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 038b0f013a0a..cc3e3213cd99 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -408,6 +408,7 @@ export const WS_METHODS = { trellisGetDetails: "trellis.getDetails", trellisGetHistorySettings: "trellis.getHistorySettings", trellisUpdateHistorySettings: "trellis.updateHistorySettings", + trellisRunMaintenance: "trellis.runMaintenance", assetsCreateUrl: "assets.createUrl", assetsPersistChatAttachments: "assets.persistChatAttachments", attachmentsCreateUploadUrl: "attachments.createUploadUrl", @@ -1328,6 +1329,13 @@ const WsTrellisUpdateHistorySettingsRpc = Rpc.make(WS_METHODS.trellisUpdateHisto error: TrellisRpcError, }); +/** Runs Trellis maintenance now: expiries and thinning by the history settings. */ +const WsTrellisRunMaintenanceRpc = Rpc.make(WS_METHODS.trellisRunMaintenance, { + payload: Schema.Struct({}), + success: Schema.Struct({}), + error: TrellisRpcError, +}); + const WsAgentSessionsScanRpc = Rpc.make(WS_METHODS.agentSessionsScan, { payload: AgentSessionScanInput, success: AgentSessionScanResult, @@ -1960,6 +1968,7 @@ export const WsRpcGroup = RpcGroup.make( WsTrellisGetDetailsRpc, WsTrellisGetHistorySettingsRpc, WsTrellisUpdateHistorySettingsRpc, + WsTrellisRunMaintenanceRpc, WsAgentSessionsScanRpc, WsAgentSessionsImportRpc, WsAssetsCreateUrlRpc, From 79e268211dd01563fb69b7da5643462f788feb90 Mon Sep 17 00:00:00 2001 From: Andrey170170 Date: Fri, 2 Oct 2026 19:52:15 -0600 Subject: [PATCH 4/6] fix(web): Run now waits for history saves on their way; history settings refresh every minute Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/components/settings/TrellisSettings.tsx | 14 ++++++++++++-- apps/web/src/state/trellis.ts | 2 ++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/settings/TrellisSettings.tsx b/apps/web/src/components/settings/TrellisSettings.tsx index df48d5c689f5..959afa433a92 100644 --- a/apps/web/src/components/settings/TrellisSettings.tsx +++ b/apps/web/src/components/settings/TrellisSettings.tsx @@ -691,9 +691,13 @@ function TrellisHistorySection(props: { reportFailure: false, }); const [maintaining, setMaintaining] = useState(false); + // Saves still on their way; maintenance runs only after them, so it applies + // the settings just entered (clicking Run now commits a focused field). + const writes = useRef>(Promise.resolve()); const thinNow = async () => { setMaintaining(true); try { + await writes.current.catch(() => undefined); const result = await runMaintenance({ environmentId, input: {} }); if (result._tag === "Failure") { if (!isAtomCommandInterrupted(result)) { @@ -745,8 +749,14 @@ function TrellisHistorySection(props: { } const revision = ++nextRevision.current; apply((current) => startHistoryWrite(current, key, edit.value, revision)); - // Serial per environment, so writes reach Trellis in the order they were made. - const result = await update({ environmentId, input: edit.patch }); + // Serial per environment, so writes reach Trellis in the order they were made; + // maintenance waits for them (see `thinNow`). + const write = update({ environmentId, input: edit.patch }); + writes.current = writes.current.then( + () => write, + () => write, + ); + const result = await write; if (result._tag === "Failure") { const message = isAtomCommandInterrupted(result) ? null diff --git a/apps/web/src/state/trellis.ts b/apps/web/src/state/trellis.ts index 28c591a2624c..0bf57a67e533 100644 --- a/apps/web/src/state/trellis.ts +++ b/apps/web/src/state/trellis.ts @@ -110,6 +110,8 @@ export const trellisEnvironment = { tag: WS_METHODS.trellisGetHistorySettings, staleTimeMs: 30_000, idleTtlMs: 60_000, + // Maintenance runs hourly and other clients may change the settings. + refreshIntervalMs: 60_000, }), /** One write at a time per environment, so they reach Trellis in order. */ updateHistorySettings: createEnvironmentRpcCommand(connectionAtomRuntime, { From 692431ffaa8311365754de6cf2d2ee9e1ea57d80 Mon Sep 17 00:00:00 2001 From: Andrey170170 Date: Fri, 2 Oct 2026 20:28:03 -0600 Subject: [PATCH 5/6] fix(web): Run now does not run maintenance when a history save it waited for failed Co-Authored-By: Claude Opus 5.5 (1M context) --- .../components/settings/TrellisSettings.tsx | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/settings/TrellisSettings.tsx b/apps/web/src/components/settings/TrellisSettings.tsx index 959afa433a92..44036b1b7baa 100644 --- a/apps/web/src/components/settings/TrellisSettings.tsx +++ b/apps/web/src/components/settings/TrellisSettings.tsx @@ -693,11 +693,23 @@ function TrellisHistorySection(props: { const [maintaining, setMaintaining] = useState(false); // Saves still on their way; maintenance runs only after them, so it applies // the settings just entered (clicking Run now commits a focused field). - const writes = useRef>(Promise.resolve()); + // Resolves to whether every save since the last Run now succeeded. + const writes = useRef>(Promise.resolve(true)); const thinNow = async () => { setMaintaining(true); try { - await writes.current.catch(() => undefined); + const saved = await writes.current; + writes.current = Promise.resolve(true); + // A setting the user just entered was refused or lost: running now + // would apply the previous one. + if (!saved) { + toastManager.add({ + type: "error", + title: "Maintenance did not run", + description: "A history setting was not saved. Fix it, then run maintenance again.", + }); + return; + } const result = await runMaintenance({ environmentId, input: {} }); if (result._tag === "Failure") { if (!isAtomCommandInterrupted(result)) { @@ -752,9 +764,11 @@ function TrellisHistorySection(props: { // Serial per environment, so writes reach Trellis in the order they were made; // maintenance waits for them (see `thinNow`). const write = update({ environmentId, input: edit.patch }); - writes.current = writes.current.then( - () => write, - () => write, + writes.current = writes.current.then((earlier) => + write.then( + (result) => earlier && result._tag !== "Failure", + () => false, + ), ); const result = await write; if (result._tag === "Failure") { From cd03fb485dafbf2cacc1a25917bf89fa507e9604 Mon Sep 17 00:00:00 2001 From: Andrey170170 Date: Fri, 2 Oct 2026 20:47:58 -0600 Subject: [PATCH 6/6] fix(web): no history edits while Run now waits and runs Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/web/src/components/settings/TrellisSettings.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/settings/TrellisSettings.tsx b/apps/web/src/components/settings/TrellisSettings.tsx index 44036b1b7baa..77e22009bae6 100644 --- a/apps/web/src/components/settings/TrellisSettings.tsx +++ b/apps/web/src/components/settings/TrellisSettings.tsx @@ -695,7 +695,9 @@ function TrellisHistorySection(props: { // the settings just entered (clicking Run now commits a focused field). // Resolves to whether every save since the last Run now succeeded. const writes = useRef>(Promise.resolve(true)); + const maintainingRef = useRef(false); const thinNow = async () => { + maintainingRef.current = true; setMaintaining(true); try { const saved = await writes.current; @@ -723,6 +725,7 @@ function TrellisHistorySection(props: { } toastManager.add({ type: "success", title: "Maintenance done" }); } finally { + maintainingRef.current = false; setMaintaining(false); historyQuery.refresh(); } @@ -747,7 +750,8 @@ function TrellisHistorySection(props: { }); const commit = async (key: HistoryKey, input: number | null) => { - if (settings === null) return; + // Fields and resets wait while Run now runs (see `thinNow`). + if (settings === null || maintainingRef.current) return; const current = historyFieldValue( key, historyValuesInForce(settings.values, historyQuery.dataUpdatedAt, editsRef.current), @@ -863,6 +867,8 @@ function TrellisHistorySection(props: { step={1} size="sm" className="w-32" + // No new saves while Run now waits for the earlier ones and runs. + disabled={maintaining} onValueCommitted={(next) => void commit(setting.key, next)} >