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
1 change: 1 addition & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ export const RPC_REQUIRED_SCOPES = {
[WS_METHODS.trellisNewProject]: AuthOrchestrationOperateScope,
[WS_METHODS.trellisGraduate]: AuthOrchestrationOperateScope,
[WS_METHODS.trellisListBases]: AuthOrchestrationReadScope,
[WS_METHODS.trellisGetDetails]: AuthOrchestrationReadScope,
[WS_METHODS.trellisFind]: AuthOrchestrationReadScope,
[WS_METHODS.trellisRestoreConflicts]: AuthOrchestrationReadScope,
// Publishes a workspace port, like preview.open.
Expand Down
77 changes: 77 additions & 0 deletions apps/server/src/trellis/Trellis.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,83 @@ describe("Trellis client", () => {
expect(result.failed.message).toBe("Trellis answered GET /v1/projects with HTTP 502.");
}),
);

it.effect("decodes the full status for display, reading absent fields as unknown", () =>
Effect.gen(function* () {
let body: unknown = {
root: "/trellis",
role: "user",
version: "0.1.0",
commit: "abc1234-dirty.5f2e",
pid: 42,
started_at: 1_700_000_000,
uptime_secs: 93_784,
bases: ["dev", "py"],
default_base: "dev",
node: "local",
missing_providers: ["codex"],
agent_homes: { claude: "/homes/claude", codex: null },
running_workspaces: ["ws-a", "ws-b"],
restart_needed: [{ workspace: "ws-b", reason: "started with another trellis binary" }],
pending_operations: [
{ id: 1, kind: "rollback", data: { ws: "ws-a" } },
{ id: 2, kind: "graduate", data: { project: "prj-1" } },
{ id: 3, kind: "purge", data: null },
],
disk: { free_bytes: 1_610_612_736, total_bytes: 1_099_511_627_776 },
};
const harness = setup(() => ({ body }));
const layer = yield* Effect.promise(() => harness.listen());
const result = yield* Effect.gen(function* () {
const trellis = yield* Trellis.Trellis;
const full = yield* trellis.details;
// An older Trellis reports little more than its root.
body = { root: "/trellis", role: "user" };
const old = yield* trellis.details;
// Podman failed: running workspaces and restarts are unknown, not none.
body = { root: "/trellis", running_workspaces: null, restart_needed: null, disk: null };
const podmanFailed = yield* trellis.details;
return { full, old, podmanFailed };
}).pipe(Effect.provide(layer));
expect(result.full).toEqual({
root: "/trellis",
version: "0.1.0",
commit: "abc1234-dirty.5f2e",
uptimeSecs: 93_784,
bases: ["dev", "py"],
defaultBase: "dev",
missingProviders: ["codex"],
agentHomes: { claude: "/homes/claude", codex: null },
runningWorkspaces: [
{ id: "ws-a", name: null },
{ id: "ws-b", name: null },
],
restartNeeded: [{ id: "ws-b", name: null, reason: "started with another trellis binary" }],
pendingOperations: [
{ kind: "rollback", target: "ws-a" },
{ kind: "graduate", target: "prj-1" },
{ kind: "purge", target: null },
],
disk: { freeBytes: 1_610_612_736, totalBytes: 1_099_511_627_776 },
});
const unknown = {
root: "/trellis",
version: null,
commit: null,
uptimeSecs: null,
bases: [],
defaultBase: null,
missingProviders: [],
agentHomes: null,
runningWorkspaces: null,
restartNeeded: null,
pendingOperations: [],
disk: null,
};
expect(result.old).toEqual(unknown);
expect(result.podmanFailed).toEqual(unknown);
}),
);
});

describe("parseKnownRoots", () => {
Expand Down
67 changes: 66 additions & 1 deletion apps/server/src/trellis/Trellis.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
import * as NodeHttp from "node:http";
import * as NodePath from "node:path";

import { TrellisError, type TrellisStatus } from "@t3tools/contracts";
import { type TrellisDetails, TrellisError, type TrellisStatus } from "@t3tools/contracts";
import {
isTrellisManagedPath as isSharedTrellisManagedPath,
trellisWorkspaceIdOf,
Expand Down Expand Up @@ -286,6 +286,67 @@ const TrellisBasesView = Schema.Struct({
default_base: Schema.optional(Schema.String),
});

/**
* `GET /v1/status` in full, for the settings page. Every field but `root` is
* absent from some Trellis version; `restart_needed` from all before Ops 2.
*/
const TrellisDetailsView = Schema.Struct({
root: Schema.String,
version: Schema.optional(Schema.String),
commit: Schema.optional(Schema.String),
uptime_secs: Schema.optional(Schema.Finite),
bases: Schema.optional(Schema.Array(Schema.String)),
default_base: Schema.optional(Schema.NullOr(Schema.String)),
missing_providers: Schema.optional(Schema.Array(Schema.String)),
agent_homes: Schema.optional(
Schema.Struct({ claude: Schema.NullOr(Schema.String), codex: Schema.NullOr(Schema.String) }),
),
running_workspaces: Schema.optional(Schema.NullOr(Schema.Array(Schema.String))),
restart_needed: Schema.optional(
Schema.NullOr(Schema.Array(Schema.Struct({ workspace: Schema.String, reason: Schema.String }))),
),
pending_operations: Schema.optional(
Schema.Array(Schema.Struct({ kind: Schema.String, data: Schema.optional(Schema.Unknown) })),
),
disk: Schema.optional(
Schema.NullOr(Schema.Struct({ free_bytes: Schema.Finite, total_bytes: Schema.Finite })),
),
});

/** The workspace (`ws`) or project a pending operation's journal data names. */
function pendingTarget(data: unknown): string | null {
for (const key of ["ws", "project"]) {
if (Predicate.hasProperty(data, key) && typeof data[key] === "string") return data[key];
}
return null;
}

const toDetails = (view: typeof TrellisDetailsView.Type): TrellisDetails => ({
root: view.root,
version: view.version ?? null,
commit: view.commit ?? null,
uptimeSecs: view.uptime_secs ?? null,
bases: view.bases ?? [],
defaultBase: view.default_base ?? null,
missingProviders: view.missing_providers ?? [],
agentHomes: view.agent_homes ?? null,
runningWorkspaces: view.running_workspaces?.map((id) => ({ id, name: null })) ?? null,
restartNeeded:
view.restart_needed?.map((entry) => ({
id: entry.workspace,
name: null,
reason: entry.reason,
})) ?? null,
pendingOperations: (view.pending_operations ?? []).map((operation) => ({
kind: operation.kind,
target: pendingTarget(operation.data),
})),
disk:
view.disk == null
? null
: { freeBytes: view.disk.free_bytes, totalBytes: view.disk.total_bytes },
});

const sameAgentHomes = (a: TrellisAgentHomes | undefined, b: TrellisAgentHomes | undefined) =>
a?.claude === b?.claude && a?.codex === b?.codex && (a === undefined) === (b === undefined);

Expand Down Expand Up @@ -563,6 +624,8 @@ export class Trellis extends Context.Service<
{ readonly bases: ReadonlyArray<string>; readonly defaultBase: string | null },
TrellisError
>;
/** Everything `/v1/status` reports, for display; workspace names are left null. */
readonly details: Effect.Effect<TrellisDetails, TrellisError>;
}
>()("t3/trellis/Trellis") {}

Expand Down Expand Up @@ -1178,6 +1241,7 @@ const make = Effect.gen(function* () {
bases: call(TrellisBasesView, "GET", "/v1/status").pipe(
Effect.map((view) => ({ bases: view.bases ?? [], defaultBase: view.default_base ?? null })),
),
details: call(TrellisDetailsView, "GET", "/v1/status").pipe(Effect.map(toDetails)),
});
});

Expand Down Expand Up @@ -1292,6 +1356,7 @@ export function makeTestTrellis(
graduate: unused,
getProject: unused,
bases: Effect.die(new Error("unused Trellis operation")),
details: Effect.die(new Error("unused Trellis operation")),
...rest,
});
}
37 changes: 37 additions & 0 deletions apps/server/src/trellis/TrellisCatalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,43 @@ describe("splitFindHits", () => {
});
});

describe("nameDetailsWorkspaces", () => {
it("names primary workspaces, forks and the scratch workspace; leaves unknown ones unnamed", () => {
const details = TrellisCatalog.nameDetailsWorkspaces(
{
root: ROOT,
version: null,
commit: null,
uptimeSecs: null,
bases: [],
defaultBase: null,
missingProviders: [],
agentHomes: null,
runningWorkspaces: [
{ id: "ws-scratch", name: null },
{ id: "ws-app", name: null },
{ id: "ws-gone", name: null },
],
restartNeeded: [{ id: "ws-fork", name: null, reason: "older binary" }],
pendingOperations: [],
disk: null,
},
[
idea("idea-1", "Sketch"),
dedicated("prj-app", "App", [workspace("ws-app"), workspace("ws-fork", "try-sqlite")]),
],
);
expect(details.runningWorkspaces).toEqual([
{ id: "ws-scratch", name: "Ideas" },
{ id: "ws-app", name: "App" },
{ id: "ws-gone", name: null },
]);
expect(details.restartNeeded).toEqual([
{ id: "ws-fork", name: "App · try-sqlite", reason: "older binary" },
]);
});
});

describe("trashItems", () => {
it("folds forks trashed with their project and lists earlier-trashed forks", () => {
const items = TrellisCatalog.trashItems({
Expand Down
40 changes: 40 additions & 0 deletions apps/server/src/trellis/TrellisCatalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
TRELLIS_LANDING_PAD_PROJECT_ID,
TrellisError,
type TrellisBasesResult,
type TrellisDetails,
type TrellisCreateResult,
type TrellisFindHit,
type TrellisFindResult,
Expand Down Expand Up @@ -178,6 +179,39 @@ export function workerRoots(items: ReadonlyArray<TrellisProjectView>): ReadonlyA
.map((entry) => entry.workspaceRoot);
}

/**
* Names the workspaces `details` lists from a catalog listing: the project's
* name for its primary workspace, `project · fork` for a fork, `Ideas` for
* the scratch workspace. Workspaces the listing lacks stay unnamed.
*/
export function nameDetailsWorkspaces(
details: TrellisDetails,
items: ReadonlyArray<TrellisProjectView>,
): TrellisDetails {
const names = new Map<string, string>();
for (const item of items) {
for (const workspace of item.workspaces) {
names.set(
workspace.id,
workspace.kind === "scratch"
? "Ideas"
: workspace.id === item.workspace_id
? item.name
: `${item.name} · ${workspace.name}`,
);
}
}
const named = <T extends { readonly id: string }>(entry: T) => ({
...entry,
name: names.get(entry.id) ?? null,
});
return {
...details,
runningWorkspaces: details.runningWorkspaces?.map(named) ?? null,
restartNeeded: details.restartNeeded?.map(named) ?? null,
};
}

/** `<ws>` when `root` is exactly `<trellis root>/workspaces/<ws>/project`. */
function workspaceIdOfRoot(trellisRoot: string, root: string): string | null {
const relative = NodePath.posix
Expand Down Expand Up @@ -659,6 +693,8 @@ export class TrellisCatalog extends Context.Service<
readonly discardFork: (workspaceId: string) => Effect.Effect<boolean, TrellisError>;
/** The bases a new project (or a graduating idea) can start from. */
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>;
/** 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 @@ -1770,6 +1806,10 @@ const make = Effect.gen(function* () {
restoreConflicts(input).pipe(asTrellisError("Could not check the restore")),
projectFor,
listBases: requireReady.pipe(Effect.andThen(trellis.bases)),
details: requireReady.pipe(
Effect.andThen(Effect.all([trellis.details, Ref.get(lastApplied)])),
Effect.map(([details, applied]) => nameDetailsWorkspaces(details, applied?.items ?? [])),
),
});
});

Expand Down
1 change: 1 addition & 0 deletions apps/server/src/trellis/TrellisIdeaPromotion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ const FakeCatalog = Layer.effect(
discardFork: unused,
projectFor: unused,
listBases: Effect.die("unused catalog operation"),
details: Effect.die("unused catalog operation"),
});
}),
);
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/trellis/TrellisWorkers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ function fakeCatalog(fake: ReturnType<typeof makeForkTrellis>) {
forkWorkspace: unused,
purge: unused,
listBases: Effect.die("unused"),
details: Effect.die("unused"),
projectFor: unused,
});
}),
Expand Down
4 changes: 4 additions & 0 deletions apps/server/src/ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3202,6 +3202,10 @@ const makeWsRpcLayer = (
observeRpcEffect(WS_METHODS.trellisListBases, trellisCatalog.listBases, {
"rpc.aggregate": "trellis",
}),
[WS_METHODS.trellisGetDetails]: () =>
observeRpcEffect(WS_METHODS.trellisGetDetails, trellisCatalog.details, {
"rpc.aggregate": "trellis",
}),
[WS_METHODS.filesystemBrowse]: (input) =>
observeRpcEffect(
WS_METHODS.filesystemBrowse,
Expand Down
45 changes: 45 additions & 0 deletions apps/web/src/components/settings/TrellisSettings.logic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vite-plus/test";

import { formatBytes, staleDetailsNotice, trellisVersionText } from "./TrellisSettings.logic";

describe("formatBytes", () => {
it("uses the largest decimal unit, with one decimal below ten", () => {
expect(formatBytes(0)).toBe("0 B");
expect(formatBytes(999)).toBe("999 B");
expect(formatBytes(1_500_000_000)).toBe("1.5 GB");
expect(formatBytes(120_000_000_000)).toBe("120 GB");
expect(formatBytes(1_000_000_000_000)).toBe("1 TB");
});
});

describe("staleDetailsNotice", () => {
const formatTime = (epochMs: number) => `t=${epochMs}`;

it("flags shown details whose refresh failed, with the reason and their time", () => {
expect(
staleDetailsNotice({
hasData: true,
error: "Trellis is unavailable",
updatedAt: 5,
formatTime,
}),
).toBe("Could not refresh: Trellis is unavailable. Showing details from t=5.");
});

it("says nothing for current details or when there are none to mark", () => {
expect(staleDetailsNotice({ hasData: true, error: null, updatedAt: 5, formatTime })).toBeNull();
expect(
staleDetailsNotice({ hasData: false, error: "down", updatedAt: 0, formatTime }),
).toBeNull();
});
});

describe("trellisVersionText", () => {
it("shows the commit beside the version and tolerates either being unreported", () => {
expect(trellisVersionText({ version: "0.1.0", commit: "abc1234-dirty" })).toBe(
"0.1.0 (abc1234-dirty)",
);
expect(trellisVersionText({ version: "0.1.0", commit: null })).toBe("0.1.0");
expect(trellisVersionText({ version: null, commit: null })).toBeNull();
});
});
Loading
Loading