Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
139 changes: 139 additions & 0 deletions apps/server/src/trellis/Trellis.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number>;
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", () => {
Expand Down
103 changes: 102 additions & 1 deletion apps/server/src/trellis/Trellis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<keyof TrellisHistoryValues, string>;

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<Record<string, unknown>>): TrellisHistoryValues {
const values: { -readonly [K in keyof TrellisHistoryValues]: number } = {};
for (const key of Object.keys(HISTORY_KEYS) as Array<keyof typeof HISTORY_KEYS>) {
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<string, number> {
const patch: Record<string, number> = {};
for (const key of Object.keys(HISTORY_KEYS) as Array<keyof typeof HISTORY_KEYS>) {
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);

Expand Down Expand Up @@ -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<TrellisDetails, TrellisError>;
/** The history settings in force, their defaults, snapshot counts and the last thinning. */
readonly historySettings: Effect.Effect<TrellisHistorySettings, TrellisError>;
/**
* 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<TrellisHistorySettingsUpdateResult, TrellisError>;
/**
* Runs maintenance now (`POST /v1/maintenance`): expiries and thinning by
* the history settings, as the timer would; never purges projects.
*/
readonly runMaintenance: Effect.Effect<void, TrellisError>;
}
>()("t3/trellis/Trellis") {}

Expand Down Expand Up @@ -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}`. */
Expand Down Expand Up @@ -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,
})),
),
});
});

Expand Down Expand Up @@ -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,
});
}
15 changes: 15 additions & 0 deletions apps/server/src/trellis/TrellisCatalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ import {
TrellisError,
type TrellisBasesResult,
type TrellisDetails,
type TrellisHistorySettings,
type TrellisHistorySettingsUpdateResult,
type TrellisHistoryValues,
type TrellisCreateResult,
type TrellisFindHit,
type TrellisFindResult,
Expand Down Expand Up @@ -749,6 +752,14 @@ export class TrellisCatalog extends Context.Service<
readonly listBases: Effect.Effect<TrellisBasesResult, TrellisError>;
/** The Trellis service's status in full, workspaces named from the last sync. */
readonly details: Effect.Effect<TrellisDetails, TrellisError>;
/** Trellis's snapshot timer, retention and expiry settings, for Settings. */
readonly historySettings: Effect.Effect<TrellisHistorySettings, TrellisError>;
/** Changes the given history settings; Trellis validates them. */
readonly updateHistorySettings: (
values: TrellisHistoryValues,
) => Effect.Effect<TrellisHistorySettingsUpdateResult, TrellisError>;
/** Runs Trellis maintenance now; see `Trellis.runMaintenance`. */
readonly runMaintenance: Effect.Effect<void, TrellisError>;
/** The T3 project for a Trellis item just created (a graduation's), made now rather than at the next poll. */
readonly projectFor: (
item: TrellisProjectView,
Expand Down Expand Up @@ -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)),
});
});

Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/trellis/TrellisIdeaPromotion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
});
}),
);
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/trellis/TrellisWorkers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,9 @@ function fakeCatalog(fake: ReturnType<typeof makeForkTrellis>) {
purge: unused,
listBases: Effect.die("unused"),
details: Effect.die("unused"),
historySettings: Effect.die("unused"),
updateHistorySettings: unused,
runMaintenance: Effect.die("unused"),
projectFor: unused,
});
}),
Expand Down
16 changes: 16 additions & 0 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading