diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 1fb0b55a2b56..ecdef8603143 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -153,6 +153,9 @@ 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.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 060c4e17fa4c..1976589fcd9e 100644 --- a/apps/server/src/trellis/Trellis.test.ts +++ b/apps/server/src/trellis/Trellis.test.ts @@ -403,6 +403,145 @@ 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 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, everyKey, 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 }, + { + 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, + }); + 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).", + ); + }), + ); + + 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 c47ef8fc0326..b6e0145b5b8d 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,20 @@ 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; + /** + * 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") {} @@ -708,6 +781,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 +1319,27 @@ 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, + })), + ), + 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), + }).pipe( + Effect.map((view) => ({ + values: toHistoryValues(view.values), + wouldRemove: view.would_remove ?? 0, + })), + ), }); }); @@ -1357,6 +1455,9 @@ 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, + 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 06f3ceb8ed9c..67026f9e0ce2 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,14 @@ 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; + /** 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, @@ -1893,6 +1904,10 @@ 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))), + 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 12f00151de06..7ed021ab8e08 100644 --- a/apps/server/src/trellis/TrellisIdeaPromotion.test.ts +++ b/apps/server/src/trellis/TrellisIdeaPromotion.test.ts @@ -76,6 +76,9 @@ 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, + runMaintenance: Effect.die("unused"), }); }), ); diff --git a/apps/server/src/trellis/TrellisWorkers.test.ts b/apps/server/src/trellis/TrellisWorkers.test.ts index 3c6044d83a3e..71d06a60a780 100644 --- a/apps/server/src/trellis/TrellisWorkers.test.ts +++ b/apps/server/src/trellis/TrellisWorkers.test.ts @@ -233,6 +233,9 @@ function fakeCatalog(fake: ReturnType) { purge: unused, listBases: Effect.die("unused"), 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 06c95f502555..f9a7a65897b5 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -3206,6 +3206,22 @@ 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.trellisRunMaintenance]: () => + observeRpcEffect( + WS_METHODS.trellisRunMaintenance, + trellisCatalog.runMaintenance.pipe(Effect.as({})), + { "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..75574e636520 100644 --- a/apps/web/src/components/settings/TrellisSettings.logic.test.ts +++ b/apps/web/src/components/settings/TrellisSettings.logic.test.ts @@ -1,6 +1,20 @@ import { describe, expect, it } from "vite-plus/test"; -import { formatBytes, staleDetailsNotice, trellisVersionText } from "./TrellisSettings.logic"; +import { + finishHistoryWrite, + formatBytes, + historyDefaultNote, + historyEdit, + historyFieldValue, + historyValuesInForce, + NO_HISTORY_EDITS, + startHistoryWrite, + lastThinningText, + snapshotCountsText, + staleDetailsNotice, + trellisVersionText, + wouldRemoveText, +} from "./TrellisSettings.logic"; describe("formatBytes", () => { it("uses the largest decimal unit, with one decimal below ten", () => { @@ -43,3 +57,117 @@ 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", + 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" }); + }); + + 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"); + }); +}); + +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 1ba5ada0d97a..0a866fc2c260 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,232 @@ 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 + * `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( + key: HistoryKey, + input: number | null, + current: number | undefined, +): + | { readonly kind: "unchanged" } + | { readonly kind: "invalid"; readonly message: string } + | { 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", 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. */ +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..77e22009bae6 100644 --- a/apps/web/src/components/settings/TrellisSettings.tsx +++ b/apps/web/src/components/settings/TrellisSettings.tsx @@ -8,11 +8,12 @@ import { ActivityIcon, AlertTriangleIcon, BoxesIcon, + HistoryIcon, LightbulbIcon, 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"; @@ -24,16 +25,42 @@ 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, + finishHistoryWrite, + HISTORY_SETTINGS, + type HistoryEdits, + type HistoryKey, + historyDefaultNote, + historyEdit, + historyFieldValue, + historyValuesInForce, + NO_HISTORY_EDITS, + rejectHistoryEdit, + startHistoryWrite, + lastThinningText, + snapshotCountsText, staleDetailsNotice, trellisVersionText, + wouldRemoveText, workspaceLabel, } from "./TrellisSettings.logic"; import { SettingsScopeNotice } from "./SettingsScopeNotice"; @@ -46,6 +73,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 +115,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 +233,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, 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; + readonly id: string | undefined; +}) { + const { environmentId } = props; + const historyQuery = useEnvironmentQuery( + trellisEnvironment.historySettings({ environmentId, input: {} }), + ); + const update = useAtomCommand(trellisEnvironment.updateHistorySettings, { + reportFailure: false, + }); + const runMaintenance = useAtomCommand(trellisEnvironment.runMaintenance, { + 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). + // 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; + 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)) { + 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 { + maintainingRef.current = false; + 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); + 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) => { + // 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), + editsRef.current, + ); + const edit = historyEdit(key, input, current); + if (edit.kind === "unchanged") return; + if (edit.kind === "invalid") { + 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; + // maintenance waits for them (see `thinNow`). + const write = update({ environmentId, input: edit.patch }); + writes.current = writes.current.then((earlier) => + write.then( + (result) => earlier && result._tag !== "Failure", + () => false, + ), + ); + const result = await write; + if (result._tag === "Failure") { + 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(); + }; + + 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} + /> + ) : ( + <> + {staleNotice === null ? null : ( + History settings may be out of date} + description={staleNotice} + /> + )} + {HISTORY_SETTINGS.map((setting) => { + 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 = edits.error?.key === setting.key ? edits.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..0bf57a67e533 100644 --- a/apps/web/src/state/trellis.ts +++ b/apps/web/src/state/trellis.ts @@ -104,6 +104,26 @@ 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, + // 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, { + label: "environment-data:trellis:update-history-settings", + 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 19aff804c5e9..d97239d76483 100644 --- a/docs/user/trellis.md +++ b/docs/user/trellis.md @@ -101,6 +101,11 @@ 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. Changes apply at the next maintenance, about once an hour; +**Run now** applies them at once. + ## 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..cc3e3213cd99 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,9 @@ export const WS_METHODS = { trellisGraduate: "trellis.graduate", trellisListBases: "trellis.listBases", trellisGetDetails: "trellis.getDetails", + trellisGetHistorySettings: "trellis.getHistorySettings", + trellisUpdateHistorySettings: "trellis.updateHistorySettings", + trellisRunMaintenance: "trellis.runMaintenance", assetsCreateUrl: "assets.createUrl", assetsPersistChatAttachments: "assets.persistChatAttachments", attachmentsCreateUploadUrl: "attachments.createUploadUrl", @@ -1310,6 +1316,26 @@ 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, +}); + +/** 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, @@ -1940,6 +1966,9 @@ export const WsRpcGroup = RpcGroup.make( WsTrellisGraduateRpc, WsTrellisListBasesRpc, WsTrellisGetDetailsRpc, + WsTrellisGetHistorySettingsRpc, + WsTrellisUpdateHistorySettingsRpc, + WsTrellisRunMaintenanceRpc, 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;