Collapsible run stage sidebar (#352)

## What

Makes the run-detail stage sidebar (shown on the Overview and Stages
tabs) collapsible with a slide animation.

- A toggle button slides the panel between full width (`w-56`) and an
icon-only rail (`w-12`), animating `width` over 300ms with the same
easing as the Ask Fabro panel.
- When collapsed, **stage status icons stay visible** — green check /
red X / spinning teal for running — so run progress is still scannable
at a glance. Workflow links (Graph Source, Run Logs, etc.) collapse to
icons too so they remain reachable.
- Labels and durations become `sr-only` with `title` tooltips for hover.
- The open/closed choice persists to `localStorage`
(`fabro:stage-sidebar-collapsed`), carrying across the Overview and
Stages tabs and reloads.

## Layout

- The collapse toggle is inline with the `STAGES` heading row (or
`WORKFLOW` when a run has no stages yet), so it doesn't push the stage
list down.
- The stage sidebar's top padding on the Stages tab was reduced (`pt-6`
→ `pt-3`) so the heading aligns with the adjacent content column and
sits closer to the tab nav.

## Notes

Self-contained in `StageSidebar` — `run-overview.tsx` and
`run-stages.tsx` render it inside flex layouts that already track its
width, so the slide works in both with no parent changes (aside from the
padding tweak).

Verified: `tsc` typecheck passes; `stage-sidebar` lib tests pass
(10/10).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: fabro-sh-0530[bot] <281434857+fabro-sh-0530[bot]@users.noreply.github.com>
Co-authored-by: Fabro <noreply@fabro.sh>
This commit is contained in:
Bryan Helmkamp 2026-05-22 12:20:29 -04:00 • committed by GitHub
parent 199cf0822e
commit 4190e13a20
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
49 changed files with 262 additions and 500 deletions

View file

@ -1,4 +1,4 @@
import { type ComponentType } from "react";
import { type ComponentType, type ReactNode, useCallback, useState } from "react";
import { Link } from "react-router";
import type { StageHandler, StageState } from "@qltysh/fabro-api-client";
import {
@ -12,6 +12,8 @@ import {
import {
Bars3BottomLeftIcon,
BoltIcon,
ChevronDoubleLeftIcon,
ChevronDoubleRightIcon,
DocumentTextIcon,
MapIcon,
PaperClipIcon,
@ -42,6 +44,90 @@ export const statusConfig: Record<StageState, { icon: ComponentType<{ className?
cancelled: { icon: NoSymbolIcon, color: "text-fg-muted" },
};
const COLLAPSED_STORAGE_KEY = "fabro:stage-sidebar-collapsed";
/** Read the persisted collapsed preference; defaults to expanded. */
function loadStoredCollapsed(): boolean {
if (typeof window === "undefined") return false;
try {
return window.localStorage.getItem(COLLAPSED_STORAGE_KEY) === "1";
} catch {
// localStorage not available (e.g. sandboxed iframe)
return false;
}
}
function persistCollapsed(collapsed: boolean) {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(COLLAPSED_STORAGE_KEY, collapsed ? "1" : "0");
} catch {
// non-fatal
}
}
interface SidebarRowProps {
to: string;
icon: ComponentType<{ className?: string }>;
iconClass: string;
label: string;
/** Right-aligned text (e.g. stage duration); hidden when collapsed. */
trailing?: string;
active: boolean;
collapsed: boolean;
/** Spin the icon to signal an in-flight stage. */
spin?: boolean;
}
/** A single sidebar link. The icon stays visible when collapsed; the label
* becomes screen-reader-only and a `title` tooltip stands in for sighted users. */
function SidebarRow({ to, icon: Icon, iconClass, label, trailing, active, collapsed, spin }: SidebarRowProps) {
return (
<li>
<Link
to={to}
title={collapsed ? label : undefined}
className={`flex items-center rounded-md py-1.5 text-sm transition-colors ${
collapsed ? "mx-1 justify-center" : "gap-2 px-2"
} ${active ? "bg-overlay text-fg" : "text-fg-3 hover:bg-overlay hover:text-fg"}`}
>
<Icon className={`size-4 shrink-0 ${iconClass} ${spin ? "animate-spin" : ""}`} />
<span className={collapsed ? "sr-only" : "flex-1 truncate"}>{label}</span>
{trailing != null && !collapsed && (
<span className="shrink-0 font-mono text-xs tabular-nums text-fg-muted">{trailing}</span>
)}
</Link>
</li>
);
}
/** Section heading. When a `toggle` is given it sits inline with the heading
* (or stands alone, centered, when collapsed) rather than on its own row. */
function SectionHeading({
title,
collapsed,
toggle,
}: {
title: string;
collapsed: boolean;
toggle?: ReactNode;
}) {
if (collapsed) {
return (
<>
<h3 className="sr-only">{title}</h3>
{toggle && <div className="flex h-7 items-center justify-center">{toggle}</div>}
</>
);
}
return (
<div className="flex h-7 items-center justify-between">
<h3 className="px-2 text-xs font-medium uppercase tracking-wider text-fg-muted">{title}</h3>
{toggle}
</div>
);
}
interface StageSidebarProps {
stages: Stage[];
runId: string;
@ -49,7 +135,30 @@ interface StageSidebarProps {
activeLink?: "settings" | "source" | "logs" | "artifacts" | "events";
}
const WORKFLOW_LINKS: ReadonlyArray<{
key: NonNullable<StageSidebarProps["activeLink"]>;
path: string;
icon: ComponentType<{ className?: string }>;
label: string;
}> = [
{ key: "source", path: "source", icon: MapIcon, label: "Graph Source" },
{ key: "logs", path: "logs", icon: Bars3BottomLeftIcon, label: "Run Logs" },
{ key: "events", path: "events", icon: BoltIcon, label: "Run Events" },
{ key: "artifacts", path: "artifacts", icon: PaperClipIcon, label: "Artifacts" },
{ key: "settings", path: "settings", icon: DocumentTextIcon, label: "Run Settings" },
];
export function StageSidebar({ stages, runId, selectedStageId, activeLink }: StageSidebarProps) {
// Persisted so the choice carries across the Overview and Stages tabs.
const [collapsed, setCollapsed] = useState(loadStoredCollapsed);
const toggleCollapsed = useCallback(() => {
setCollapsed((prev) => {
const next = !prev;
persistCollapsed(next);
return next;
});
}, []);
// Tick every second while any stage is running so the elapsed clock keeps up.
const hasActive = stages.some((s) => ACTIVE_STAGE_STATES.has(s.status));
const now = useTickingNow(hasActive);
@ -62,108 +171,75 @@ export function StageSidebar({ stages, runId, selectedStageId, activeLink }: Sta
return stage.duration;
}
const linkBase = "flex items-center gap-2 rounded-md px-2 py-1.5 text-sm transition-colors";
const toggleButton = (
<button
type="button"
onClick={toggleCollapsed}
aria-expanded={!collapsed}
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
title={collapsed ? "Expand sidebar" : "Collapse sidebar"}
className="-mr-1 inline-flex size-7 shrink-0 items-center justify-center rounded-md text-fg-3 transition-colors hover:bg-overlay hover:text-fg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500"
>
{collapsed ? (
<ChevronDoubleRightIcon className="size-4" />
) : (
<ChevronDoubleLeftIcon className="size-4" />
)}
</button>
);
return (
<nav className="w-56 shrink-0 space-y-6">
{stages.length > 0 && (
<div>
<h3 className="px-2 text-xs font-medium uppercase tracking-wider text-fg-muted">Stages</h3>
<ul className="mt-2 space-y-0.5">
{stages.map((stage) => {
const config = statusConfig[stage.status];
const Icon = config.icon;
const isSelected = selectedStageId === stage.id;
return (
<li key={stage.id}>
<Link
<nav
className={`${collapsed ? "w-12" : "w-56"} shrink-0 transition-[width] duration-300 ease-[cubic-bezier(0.16,1,0.3,1)]`}
>
<div className="space-y-6">
{stages.length > 0 && (
<div>
<SectionHeading title="Stages" collapsed={collapsed} toggle={toggleButton} />
<ul className="mt-2 space-y-0.5 overflow-hidden">
{stages.map((stage) => {
const config = statusConfig[stage.status];
return (
<SidebarRow
key={stage.id}
to={`/runs/${runId}/stages/${stage.id}`}
className={`${linkBase} ${
isSelected
? "bg-overlay text-fg"
: "text-fg-3 hover:bg-overlay hover:text-fg"
}`}
>
<Icon className={`size-4 shrink-0 ${config.color} ${ACTIVE_STAGE_STATES.has(stage.status) ? "animate-spin" : ""}`} />
<span className="flex-1 truncate">{formatStageLabel(stage)}</span>
<span className="font-mono text-xs tabular-nums text-fg-muted">{stageDuration(stage)}</span>
</Link>
</li>
);
})}
icon={config.icon}
iconClass={config.color}
label={formatStageLabel(stage)}
trailing={stageDuration(stage)}
active={selectedStageId === stage.id}
collapsed={collapsed}
spin={ACTIVE_STAGE_STATES.has(stage.status)}
/>
);
})}
</ul>
</div>
)}
<div>
<SectionHeading
title="Workflow"
collapsed={collapsed}
toggle={stages.length === 0 ? toggleButton : undefined}
/>
{collapsed && stages.length > 0 && (
<div aria-hidden="true" className="mx-3 border-t border-line" />
)}
<ul className="mt-2 space-y-0.5 overflow-hidden">
{WORKFLOW_LINKS.map((link) => (
<SidebarRow
key={link.key}
to={`/runs/${runId}/${link.path}`}
icon={link.icon}
iconClass="text-fg-muted"
label={link.label}
active={activeLink === link.key}
collapsed={collapsed}
/>
))}
</ul>
</div>
)}
<div>
<h3 className="px-2 text-xs font-medium uppercase tracking-wider text-fg-muted">Workflow</h3>
<ul className="mt-2 space-y-0.5">
<li>
<Link
to={`/runs/${runId}/source`}
className={`${linkBase} ${
activeLink === "source"
? "bg-overlay text-fg"
: "text-fg-3 hover:bg-overlay hover:text-fg"
}`}
>
<MapIcon className="size-4 shrink-0 text-fg-muted" />
Graph Source
</Link>
</li>
<li>
<Link
to={`/runs/${runId}/logs`}
className={`${linkBase} ${
activeLink === "logs"
? "bg-overlay text-fg"
: "text-fg-3 hover:bg-overlay hover:text-fg"
}`}
>
<Bars3BottomLeftIcon className="size-4 shrink-0 text-fg-muted" />
Run Logs
</Link>
</li>
<li>
<Link
to={`/runs/${runId}/events`}
className={`${linkBase} ${
activeLink === "events"
? "bg-overlay text-fg"
: "text-fg-3 hover:bg-overlay hover:text-fg"
}`}
>
<BoltIcon className="size-4 shrink-0 text-fg-muted" />
Run Events
</Link>
</li>
<li>
<Link
to={`/runs/${runId}/artifacts`}
className={`${linkBase} ${
activeLink === "artifacts"
? "bg-overlay text-fg"
: "text-fg-3 hover:bg-overlay hover:text-fg"
}`}
>
<PaperClipIcon className="size-4 shrink-0 text-fg-muted" />
Artifacts
</Link>
</li>
<li>
<Link
to={`/runs/${runId}/settings`}
className={`${linkBase} ${
activeLink === "settings"
? "bg-overlay text-fg"
: "text-fg-3 hover:bg-overlay hover:text-fg"
}`}
>
<DocumentTextIcon className="size-4 shrink-0 text-fg-muted" />
Run Settings
</Link>
</li>
</ul>
</div>
</nav>
);

View file

@ -382,7 +382,7 @@ describe("RunDetail full-height child routes", () => {
const outletWrappers = renderer.root.findAll(
(node) =>
node.type === "div" &&
hasClasses(node.props.className, ["mt-6", "min-h-0", "flex-1"]),
hasClasses(node.props.className, ["pt-3", "min-h-0", "flex-1"]),
);
expect(outletWrappers).toHaveLength(1);
});
@ -541,7 +541,7 @@ describe("RunDetail full-height child routes", () => {
(node) =>
node.type === "div" &&
hasClasses(node.props.className, [
"mt-6",
"pt-3",
"pb-[var(--fabro-interview-dock-clearance)]",
]),
);

View file

@ -700,8 +700,8 @@ export default function RunDetail({ params }: { params: { id: string } }) {
<div
className={
fullHeight
? "mt-6 flex min-h-0 flex-1 flex-col"
: "mt-6 pb-[var(--fabro-interview-dock-clearance)]"
? "pt-3 flex min-h-0 flex-1 flex-col"
: "pt-3 pb-[var(--fabro-interview-dock-clearance)]"
}
>
<Outlet />
@ -744,10 +744,9 @@ const ASK_FABRO_UNAVAILABLE_TOOLTIPS: Record<
AskFabroUnavailableReasonEnum,
string
> = {
[AskFabroUnavailableReasonEnum.FEATURE_DISABLED]: "Ask Fabro is disabled",
[AskFabroUnavailableReasonEnum.NO_SANDBOX]: "Run sandbox isn't ready",
[AskFabroUnavailableReasonEnum.SANDBOX_NOT_READY]:"Run sandbox isn't ready",
[AskFabroUnavailableReasonEnum.LLM_UNCONFIGURED]: "No LLM configured",
[AskFabroUnavailableReasonEnum.NO_SANDBOX]: "Run sandbox isn't ready",
[AskFabroUnavailableReasonEnum.SANDBOX_NOT_READY]: "Run sandbox isn't ready",
[AskFabroUnavailableReasonEnum.LLM_UNCONFIGURED]: "No LLM configured",
};
function AskFabroTriggerButton({

View file

@ -28,8 +28,8 @@ export default function RunEvents() {
);
return (
<div className="-mr-4 -mt-6 flex min-h-0 flex-1 sm:-mr-6 lg:-mr-8">
<div className="shrink-0 pb-6 pr-3 pt-6">
<div className="-mr-4 -mt-3 flex min-h-0 flex-1 sm:-mr-6 lg:-mr-8">
<div className="shrink-0 pb-6 pr-3 pt-3">
<StageSidebar stages={stages} runId={id!} activeLink="events" />
</div>
@ -110,7 +110,7 @@ function EventsView({
if (error) {
return (
<div className="min-w-0 flex-1 pt-6">
<div className="min-w-0 flex-1 pt-3">
<ErrorState
title="Couldn't load events"
description={errorMessage(error)}
@ -121,7 +121,7 @@ function EventsView({
}
if (events === undefined) {
return (
<div className="min-w-0 flex-1 pt-6">
<div className="min-w-0 flex-1 pt-3">
<LoadingState label="Loading events…" />
</div>
);

View file

@ -287,14 +287,13 @@ export default function RunSandbox({ params }: { params: { id: string } }) {
};
// The outer flex spans from the tab bar's bottom border down to the
// steer bar — `-mt-6` cancels the outlet wrapper's top gap, and we
// intentionally omit `pb-[clearance]` here so the column divider can
// run the full height. Each column adds its own `pt-6` and dock
// clearance to its content instead.
// steer bar — `-mt-3` cancels the outlet wrapper's top padding so the
// column divider runs the full height, and we omit `pb-[clearance]`
// here. Each column adds its own `pt-3` and dock clearance instead.
return (
<div className="-mt-6 flex min-h-0 flex-1">
<div className="-mt-3 flex min-h-0 flex-1">
<aside
className={`w-80 shrink-0 min-h-0 overflow-y-auto pt-6 pr-6 ${TERMINAL_DOCK_CLEARANCE_CLASS}`}
className={`w-80 shrink-0 min-h-0 overflow-y-auto pt-3 pr-6 ${TERMINAL_DOCK_CLEARANCE_CLASS}`}
>
{sandboxQuery.error ? (
<ErrorState
@ -311,7 +310,7 @@ export default function RunSandbox({ params }: { params: { id: string } }) {
</aside>
<div className="flex min-w-0 min-h-0 flex-1 flex-col border-l border-line">
<div
className={`flex min-h-0 flex-1 flex-col pt-6 pl-6 ${TERMINAL_DOCK_CLEARANCE_CLASS}`}
className={`flex min-h-0 flex-1 flex-col pt-3 pl-6 ${TERMINAL_DOCK_CLEARANCE_CLASS}`}
>
{(() => {
const modeToggle = (

View file

@ -1480,8 +1480,8 @@ export default function RunStages() {
}
return (
<div className="-mr-4 -mt-6 flex min-h-0 flex-1 sm:-mr-6 lg:-mr-8">
<div className="shrink-0 pb-6 pr-3 pt-6">
<div className="-mr-4 -mt-3 flex min-h-0 flex-1 sm:-mr-6 lg:-mr-8">
<div className="shrink-0 pb-6 pr-3 pt-3">
<StageSidebar stages={stages} runId={id} selectedStageId={selectedStage.id} />
</div>

View file

@ -97,10 +97,9 @@ function sampleResources(overrides: ResourceOverrides = {}): SystemResourcesResp
function sampleServerSettings(maxConcurrentRuns = 8): ServerSettings {
return {
server: {
server: {
scheduler: { max_concurrent_runs: maxConcurrentRuns },
},
features: { session_sandboxes: false },
} as unknown as ServerSettings;
}

View file

@ -16,7 +16,6 @@ import {
MagnifyingGlassIcon,
XMarkIcon,
} from "@heroicons/react/24/outline";
import { useSystemInfo } from "../lib/queries";
export const handle = { hideHeader: true, wide: true };
@ -45,10 +44,6 @@ function BranchIcon({ className }: { className?: string }) {
}
export default function Start() {
const systemInfo = useSystemInfo();
const features = systemInfo.data?.features ?? {
session_sandboxes: false,
};
const [prompt, setPrompt] = useState("");
const [project, setProject] = useState(projects[0]);
const [branch, setBranch] = useState(branches[0]);
@ -105,22 +100,20 @@ export default function Start() {
/>
<div className="absolute bottom-3 inset-x-3 flex items-center justify-between">
{features.session_sandboxes && (
<div className="flex items-center gap-1.5">
<Picker
value={project}
onChange={setProject}
options={projects}
icon={<FolderIcon className="size-3.5 text-fg-muted" />}
/>
<Picker
value={branch}
onChange={setBranch}
options={branches}
icon={<BranchIcon className="size-3.5 text-fg-muted" />}
/>
</div>
)}
<div className="flex items-center gap-1.5">
<Picker
value={project}
onChange={setProject}
options={projects}
icon={<FolderIcon className="size-3.5 text-fg-muted" />}
/>
<Picker
value={branch}
onChange={setBranch}
options={branches}
icon={<BranchIcon className="size-3.5 text-fg-muted" />}
/>
</div>
<div className="ml-auto flex items-center gap-3">
<span className="text-xs text-fg-muted select-none">

View file

@ -17,7 +17,7 @@ Fabro only reads `settings.toml`. Older `server.toml`, `user.toml`, and `cli.tom
| Scope | Examples |
|---|---|
| Server-owned (runtime-only from local `settings.toml`) | `[server.listen]`, `[server.api]`, `[server.web]`, `[server.auth]`, `[server.storage]`, `[server.artifacts]`, `[server.slatedb]`, `[server.scheduler]`, `[server.logging]`, `[server.integrations]`, `[features]` |
| Server-owned (runtime-only from local `settings.toml`) | `[server.listen]`, `[server.api]`, `[server.web]`, `[server.auth]`, `[server.storage]`, `[server.artifacts]`, `[server.slatedb]`, `[server.scheduler]`, `[server.logging]`, `[server.integrations]` |
| Shared run defaults (layered through `.fabro/project.toml`/`workflow.toml`) | `[run.model]`, `[run.prepare]`, `[run.sandbox]`, `[run.checkpoint]`, `[run.inputs]`, `[run.pull_request]`, `[run.git]`, `[run.hooks]`, `[run.agent]` |
The CLI-only `[cli.*]` sections (including `[cli.target]`) belong in the client machine's `settings.toml`. They tell CLI commands how to reach a server. The server process does not read `[cli.*]` for its own binding or routing.
@ -106,9 +106,6 @@ default_branch = "main"
[run.git.author]
name = "fabro-bot"
email = "fabro-bot@company.com"
[features]
session_sandboxes = true
```
### CLI overrides
@ -228,7 +225,7 @@ The `[run.*]` sections in `settings.toml` act as defaults for every run.
On a same-machine setup, `settings.toml` is the shared machine-default layer under `workflow.toml` and `.fabro/project.toml`.
On a remote setup, the client bundles workflow, project, and user config into the run manifest. The server then layers those bundled client configs over its own local defaults for run-shaped fields. Server-owned values like `[server.storage]`, `[server.api]`, `[server.web]`, `[features]`, and `[server.scheduler]` always come from the server machine's own `settings.toml` or `fabro server start` flags.
On a remote setup, the client bundles workflow, project, and user config into the run manifest. The server then layers those bundled client configs over its own local defaults for run-shaped fields. Server-owned values like `[server.storage]`, `[server.api]`, `[server.web]`, and `[server.scheduler]` always come from the server machine's own `settings.toml` or `fabro server start` flags.
Merge rules follow the normative matrix: TOML `[run.inputs]` tables replace wholesale, CLI `-I` / `--input` values replayed from run manifests merge per key at highest precedence, `[run.sandbox.env]` and `[run.sandbox.daytona.labels]` merge by key, `[run.prepare.steps]` replaces whole-list, and `[[run.hooks]]` merge by optional `id`. Most other fields use "higher-precedence wins" field-wise merging.
@ -299,16 +296,6 @@ Configure checkpoint behavior for all runs.
`exclude_globs` replaces across layers — the highest-precedence layer wins wholesale. See [Run Configuration — Checkpoint](/execution/run-configuration#runcheckpoint) for per-run configuration.
### `[features]` section
Toggle experimental or opt-in features. All features default to `false`.
| Key | Description |
|---|---|
| `session_sandboxes` | Enable session sandboxes in the web UI |
The same `[features]` section can be set in `.fabro/project.toml` (project-level) to enable features per-project.
## Secrets and environment variables
Fabro splits secrets into two scopes:

View file

@ -8156,7 +8156,6 @@ components:
unavailable_reason:
type: ["string", "null"]
enum:
- feature_disabled
- no_sandbox
- sandbox_not_ready
- llm_unconfigured
@ -10026,12 +10025,10 @@ components:
ServerSettings:
description: Current in-memory server settings view.
type: object
required: [server, features]
required: [server]
properties:
server:
$ref: "#/components/schemas/ServerNamespace"
features:
$ref: "#/components/schemas/FeaturesNamespace"
ServerNamespace:
type: object
@ -10071,13 +10068,6 @@ components:
integrations:
$ref: "#/components/schemas/ServerIntegrationsSettings"
FeaturesNamespace:
type: object
required: [session_sandboxes]
properties:
session_sandboxes:
type: boolean
ServerListenSettings:
oneOf:
- $ref: "#/components/schemas/ServerListenTcpSettings"
@ -10989,16 +10979,6 @@ components:
sandbox_provider:
type: string
description: Effective sandbox provider for launched runs.
features:
$ref: "#/components/schemas/SystemFeatures"
SystemFeatures:
description: Server-level capability flags.
type: object
properties:
session_sandboxes:
type: boolean
description: Whether session sandboxes are enabled.
SystemRunCounts:
description: Counts of known runs in the active server process.

View file

@ -31,14 +31,6 @@ fabro ps # active runs
fabro ps -a # all runs including completed
```
<Warning>
**`[feature_flags]` renamed to `[features]`.** If your `fabro.toml` uses `[feature_flags]`, rename the section to `[features]`.
</Warning>
<Warning>
**Retros are now experimental and disabled by default.** If you use retros, enable them explicitly with `retros = true` under `[features]` in your `fabro.toml`.
</Warning>
## More
<Accordion title="CLI">

View file

@ -551,7 +551,7 @@ When running locally, the machine defaults at `~/.fabro/settings.toml` can set r
Fabro validates the run config when it loads:
- **`_version` check** — Only `_version = 1` (or missing, which defaults to `1`) is accepted. The legacy top-level `version` key is rejected with a rename hint.
- **Unknown keys** — Any top-level key not in `[project]`, `[workflow]`, `[run]`, `[cli]`, `[server]`, `[features]`, or `_version` is rejected with a targeted rename hint pointing at the v2 replacement path.
- **Unknown keys** — Any top-level key not in `[project]`, `[workflow]`, `[run]`, `[cli]`, `[server]`, or `_version` is rejected with a targeted rename hint pointing at the v2 replacement path.
- **Variable check** — Undefined workflow or prompt template variables produce diagnostics. `fabro validate` reports them as warnings; run-style commands treat them as errors before creating or starting a run.
Use `fabro preflight` to validate a run config without executing it:

View file

@ -29,7 +29,7 @@ Files that omit `_version` are treated as version `1`. The legacy top-level `ver
## Who reads what
`settings.toml` uses the same schema as `.fabro/project.toml` and `workflow.toml`, but each process only reads the fields it understands. The top-level schema is strictly namespaced — the only allowed domains are `[project]`, `[workflow]`, `[run]`, `[llm]`, `[cli]`, `[server]`, and `[features]`.
`settings.toml` uses the same schema as `.fabro/project.toml` and `workflow.toml`, but each process only reads the fields it understands. The top-level schema is strictly namespaced — the only allowed domains are `[project]`, `[workflow]`, `[run]`, `[llm]`, `[cli]`, and `[server]`.
| Scope | Examples |
|---|---|
@ -44,7 +44,7 @@ See [Server Configuration](/administration/server-configuration) for the server-
## Precedence
Shared layered domains (`[project]`, `[workflow]`, `[run]`, `[features]`) use this override order:
Shared layered domains (`[project]`, `[workflow]`, `[run]`) use this override order:
1. **CLI flags** — always win
2. **Environment overrides** — Fabro-defined override channels

View file

@ -209,11 +209,6 @@ fn main() {
"fabro_types::settings::ServerNamespace",
&[],
),
(
"FeaturesNamespace",
"fabro_types::settings::FeaturesNamespace",
&[],
),
(
"ServerListenSettings",
"fabro_types::settings::server::ServerListenSettings",

View file

@ -18,6 +18,7 @@ pub mod types {
Model, ModelCosts, ModelFeatures, ModelLimits, ModelRef as BillingModelRef, ModelTestMode,
Provider, ReasoningEffortFeature, Speed as BillingSpeed,
};
pub use fabro_types::settings::ServerNamespace;
pub use fabro_types::settings::server::{
GithubIntegrationSettings, GithubIntegrationStrategy, IntegrationWebhooksSettings,
IpAllowEntry, LogDestination, ObjectStoreSettings, ServerApiSettings,
@ -27,7 +28,6 @@ pub mod types {
ServerSlateDbSettings, ServerStorageSettings, ServerWebSettings, SlackIntegrationSettings,
WebhookStrategy,
};
pub use fabro_types::settings::{FeaturesNamespace, ServerNamespace};
pub use fabro_types::status::{
BlockedReason, FailureReason, RunControlAction, RunStatus, SuccessReason,
};

View file

@ -1,20 +1,18 @@
use std::any::{TypeId, type_name};
use fabro_api::types::{
FeaturesNamespace as ApiFeaturesNamespace, LogDestination as ApiLogDestination,
ObjectStoreSettings as ApiObjectStoreSettings, ServerNamespace as ApiServerNamespace,
ServerSettings as ApiServerSettings,
LogDestination as ApiLogDestination, ObjectStoreSettings as ApiObjectStoreSettings,
ServerNamespace as ApiServerNamespace, ServerSettings as ApiServerSettings,
};
use fabro_config::ServerSettingsBuilder;
use fabro_types::ServerSettings;
use fabro_types::settings::ServerNamespace;
use fabro_types::settings::server::{LogDestination, ObjectStoreSettings};
use fabro_types::settings::{FeaturesNamespace, ServerNamespace};
#[test]
fn server_settings_family_reuses_domain_types() {
assert_same_type::<ApiServerSettings, ServerSettings>();
assert_same_type::<ApiServerNamespace, ServerNamespace>();
assert_same_type::<ApiFeaturesNamespace, FeaturesNamespace>();
assert_same_type::<ApiObjectStoreSettings, ObjectStoreSettings>();
assert_same_type::<ApiLogDestination, LogDestination>();
}
@ -54,9 +52,6 @@ strategy = "app"
app_id = "12345"
client_id = "Iv1.abcdef"
slug = "fabro-dev"
[features]
session_sandboxes = true
"#,
)
.expect("settings should resolve");
@ -66,7 +61,7 @@ session_sandboxes = true
assert_eq!(json["server"]["listen"]["address"], "127.0.0.1:32276");
assert_eq!(json["server"]["storage"]["root"], "/srv/fabro");
assert_eq!(json["server"]["logging"]["destination"], "stdout");
assert_eq!(json["features"]["session_sandboxes"], true);
assert!(json.get("features").is_none());
let round_trip: ApiServerSettings =
serde_json::from_value(json).expect("server settings should deserialize");

View file

@ -468,10 +468,7 @@ shared = "cli"
cfg["user"]["cli"]["output"]["verbosity"].as_str(),
Some("verbose")
);
assert_eq!(
cfg["user"]["features"]["session_sandboxes"].as_bool(),
Some(false)
);
assert!(cfg["user"].get("features").is_none());
assert_eq!(
cfg["server"]["server"]["auth"]["methods"][0].as_str(),
Some("dev-token")

View file

@ -10,8 +10,7 @@ use fabro_util::error::SharedError;
use crate::defaults::DEFAULTS_LAYER;
use crate::load::load_settings_path;
use crate::resolve::{
ResolveError, resolve_cli, resolve_features, resolve_project, resolve_run, resolve_server,
resolve_workflow,
ResolveError, resolve_cli, resolve_project, resolve_run, resolve_server, resolve_workflow,
};
use crate::user::load_settings_config;
use crate::{
@ -98,9 +97,8 @@ impl ServerSettingsBuilder {
let layer = layer.clone().combine(DEFAULTS_LAYER.clone());
let mut errors = Vec::new();
let server = resolve_server(&layer.server.clone().unwrap_or_default(), &mut errors);
let features = resolve_features(&layer.features.clone().unwrap_or_default(), &mut errors);
finish_result(
ServerSettings { server, features },
ServerSettings { server },
"failed to resolve server settings",
errors,
)
@ -148,9 +146,8 @@ impl UserSettingsBuilder {
let layer = layer.clone().combine(DEFAULTS_LAYER.clone());
let mut errors = Vec::new();
let cli = resolve_cli(&layer.cli.clone().unwrap_or_default(), &mut errors);
let features = resolve_features(&layer.features.clone().unwrap_or_default(), &mut errors);
finish_result(
UserSettings { cli, features },
UserSettings { cli },
"failed to resolve user settings",
errors,
)
@ -552,7 +549,6 @@ impl WorkflowSettingsBuilder {
layer = layer.combine(DEFAULTS_LAYER.clone());
layer.server = None;
layer.cli = None;
layer.features = None;
layer
}

View file

@ -60,6 +60,3 @@ provider = "local"
prefix = ""
flush_interval = "1ms"
disk_cache = false
[features]
session_sandboxes = false

View file

@ -13,7 +13,6 @@ use fabro_types::settings::{Duration, InterpString, Size};
use super::LogFilter;
use super::cli::{CliAuthLayer, CliLoggingLayer, CliTargetLayer};
use super::features::FeaturesLayer;
use super::llm::{CostRates, CredentialRef, HeaderValueRef, ReasoningEffortFeature};
use super::run::{
DaytonaSnapshotLayer, DaytonaVolumeLayer, HookAgentMarker, HookEntry, HookTlsMode,
@ -148,7 +147,6 @@ impl_combine_self!(
CliAuthLayer,
CliLoggingLayer,
CliTargetLayer,
FeaturesLayer,
DaytonaNetworkLayer,
DaytonaSnapshotLayer,
InterviewProviderLayer,

View file

@ -1,14 +0,0 @@
//! Sparse `[features]` settings layer definitions.
use serde::{Deserialize, Serialize};
/// A sparse `[features]` layer as it appears in a single settings file.
///
/// Every field is an `Option<bool>` so layers can independently set or
/// override a flag without forcing a default that hides an unset value.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FeaturesLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_sandboxes: Option<bool>,
}

View file

@ -1,6 +1,5 @@
mod cli;
mod combine;
mod features;
mod llm;
mod log_filter;
mod maps;
@ -16,7 +15,6 @@ pub use cli::{
CliOutputLayer, CliTargetLayer, CliUpdatesLayer,
};
pub(crate) use combine::Combine;
pub use features::FeaturesLayer;
pub use llm::{
CostRates, CredentialRef, CredentialRefParseError, HeaderValueRef, LlmLayer, ModelControls,
ModelCostTable, ModelFeatures as LlmModelFeatures, ModelLimits as LlmModelLimits,

View file

@ -10,7 +10,6 @@ use std::str::FromStr;
use serde::{Deserialize, Serialize};
use super::cli::CliLayer;
use super::features::FeaturesLayer;
use super::llm::LlmLayer;
use super::project::ProjectLayer;
use super::run::RunLayer;
@ -34,8 +33,6 @@ pub(crate) struct SettingsLayer {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub server: Option<ServerLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub features: Option<FeaturesLayer>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub llm: Option<LlmLayer>,
}
@ -56,15 +53,6 @@ impl From<CliLayer> for SettingsLayer {
}
}
impl From<FeaturesLayer> for SettingsLayer {
fn from(features: FeaturesLayer) -> Self {
Self {
features: Some(features),
..Self::default()
}
}
}
impl From<LlmLayer> for SettingsLayer {
fn from(llm: LlmLayer) -> Self {
Self {

View file

@ -42,10 +42,10 @@ pub use layers::{
CliAuthLayer, CliExecAgentLayer, CliExecLayer, CliExecModelLayer, CliLayer, CliLoggingLayer,
CliOutputLayer, CliTargetLayer, CliUpdatesLayer, CostRates, CredentialRef,
CredentialRefParseError, DaytonaDockerfileLayer, DaytonaSandboxLayer, DaytonaSnapshotLayer,
DaytonaVolumeLayer, DockerSandboxLayer, FeaturesLayer, GitAuthorLayer, GithubIntegrationLayer,
HeaderValueRef, HookAgentMarker, HookEntry, HookTlsMode, IntegrationWebhooksLayer,
InterviewProviderLayer, InterviewsLayer, LlmLayer, LlmModelFeatures, LlmModelLimits, LogFilter,
McpEntryLayer, MergeMap, ModelControls, ModelCostTable, ModelRefOrSplice, ModelSettings,
DaytonaVolumeLayer, DockerSandboxLayer, GitAuthorLayer, GithubIntegrationLayer, HeaderValueRef,
HookAgentMarker, HookEntry, HookTlsMode, IntegrationWebhooksLayer, InterviewProviderLayer,
InterviewsLayer, LlmLayer, LlmModelFeatures, LlmModelLimits, LogFilter, McpEntryLayer,
MergeMap, ModelControls, ModelCostTable, ModelRefOrSplice, ModelSettings,
NotificationProviderLayer, NotificationRouteLayer, ObjectStoreLocalLayer, ObjectStoreS3Layer,
PrepareStep, ProjectLayer, ProviderSettings, ReasoningEffortFeature, ReplaceMap, RunAgentLayer,
RunArtifactsLayer, RunCheckpointLayer, RunCloneLayer, RunExecutionLayer, RunGitLayer,
@ -61,8 +61,7 @@ pub(crate) use layers::{Combine, SettingsLayer};
pub use logging::{resolve_log_destination, resolve_log_destination_with_env};
pub use parse::ParseError;
pub use resolve::{
ResolveError, resolve_cli, resolve_features, resolve_project, resolve_run, resolve_server,
resolve_workflow,
ResolveError, resolve_cli, resolve_project, resolve_run, resolve_server, resolve_workflow,
};
use serde::de::DeserializeOwned;
pub use storage::{RunScratch, RuntimeDirectory, Storage};

View file

@ -5,7 +5,7 @@ use crate::SettingsLayer;
const CURRENT_VERSION: u32 = 1;
const ALLOWED_TOP_LEVEL_KEYS: &[&str] = &[
"_version", "project", "workflow", "run", "cli", "server", "features", "llm",
"_version", "project", "workflow", "run", "cli", "server", "llm",
];
/// Legacy `[llm]` keys that pre-date the settings-driven catalog plan and
@ -38,7 +38,7 @@ impl fmt::Display for ParseError {
} else {
write!(
f,
"unknown top-level settings key `{key}`: expected one of `_version`, `project`, `workflow`, `run`, `cli`, `server`, `features`, `llm`"
"unknown top-level settings key `{key}`: expected one of `_version`, `project`, `workflow`, `run`, `cli`, `server`, `llm`"
)
}
}
@ -190,6 +190,18 @@ mod tests {
assert!(matches!(err, ParseError::UnknownTopLevelKey { .. }));
}
#[test]
fn rejects_legacy_features_section() {
let namespace = "features";
let err = format!("[{namespace}]\nremoved_flag = true\n")
.parse::<SettingsLayer>()
.unwrap_err();
match err {
ParseError::UnknownTopLevelKey { key, .. } => assert_eq!(key, "features"),
other => panic!("expected UnknownTopLevelKey, got: {other:?}"),
}
}
#[test]
fn higher_version_rejected_with_upgrade_hint() {
let err = "_version = 99".parse::<SettingsLayer>().unwrap_err();

View file

@ -1,15 +0,0 @@
use fabro_types::settings::FeaturesNamespace;
use super::ResolveError;
use crate::FeaturesLayer;
pub fn resolve_features(
layer: &FeaturesLayer,
_errors: &mut Vec<ResolveError>,
) -> FeaturesNamespace {
FeaturesNamespace {
session_sandboxes: layer
.session_sandboxes
.expect("defaults.toml should provide features.session_sandboxes"),
}
}

View file

@ -1,6 +1,5 @@
mod cli;
mod error;
mod features;
mod project;
mod run;
mod server;
@ -9,7 +8,6 @@ mod workflow;
pub use cli::resolve_cli;
pub use error::ResolveError;
use fabro_types::settings::InterpString;
pub use features::resolve_features;
pub use project::resolve_project;
pub use run::resolve_run;
pub use server::resolve_server;

View file

@ -2,7 +2,6 @@ mod combine;
mod defaults;
mod log_filter;
mod resolve_cli;
mod resolve_features;
mod resolve_project;
mod resolve_root;
mod resolve_run;

View file

@ -35,9 +35,6 @@ _version = 1
[cli.target]
type = "http"
url = "https://config.example.com"
[features]
session_sandboxes = true
"#,
)
.expect("user settings should resolve");
@ -48,7 +45,6 @@ session_sandboxes = true
url: InterpString::parse("https://config.example.com"),
})
);
assert!(user_settings.features.session_sandboxes);
}
#[test]
@ -61,9 +57,6 @@ _version = 1
[cli.output]
verbosity = "verbose"
[features]
session_sandboxes = true
"#,
)
.unwrap();
@ -72,7 +65,6 @@ session_sandboxes = true
let user_settings = fabro_config::UserSettingsBuilder::load_default()
.expect("user settings should resolve");
assert_eq!(user_settings.cli.output.verbosity, OutputVerbosity::Verbose);
assert!(user_settings.features.session_sandboxes);
});
}
@ -85,7 +77,6 @@ fn user_settings_resolve_returns_defaults_when_default_settings_file_is_missing(
.expect("user settings should resolve");
assert_eq!(user_settings.cli.output.format, OutputFormat::Text);
assert_eq!(user_settings.cli.output.verbosity, OutputVerbosity::Normal);
assert!(!user_settings.features.session_sandboxes);
});
}

View file

@ -1,28 +0,0 @@
use crate::{SettingsLayer, UserSettingsBuilder};
#[test]
fn resolves_features_defaults_from_empty_settings() {
let settings = SettingsLayer::default();
let features = UserSettingsBuilder::from_layer(&settings)
.expect("empty settings should resolve")
.features;
assert!(!features.session_sandboxes);
}
#[test]
fn resolves_session_sandboxes_flag() {
let features = UserSettingsBuilder::from_toml(
r"
_version = 1
[features]
session_sandboxes = true
",
)
.expect("features should resolve")
.features;
assert!(features.session_sandboxes);
}

View file

@ -198,20 +198,13 @@ methods = ["dev-token"]
[server.storage]
root = "/srv/fabro"
[features]
session_sandboxes = true
"#,
);
let context = fabro_config::ServerSettingsBuilder::from_layer(&settings)
.expect("settings should resolve");
let user_settings = fabro_config::UserSettingsBuilder::from_layer(&settings)
.expect("user settings should resolve");
assert_eq!(context.server.storage.root.as_source(), "/srv/fabro");
assert!(context.features.session_sandboxes);
assert_eq!(context.features, user_settings.features);
}
#[test]
@ -227,9 +220,6 @@ methods = ["dev-token"]
[server.storage]
root = "/srv/from-home"
[features]
session_sandboxes = true
"#,
)
.unwrap();
@ -238,7 +228,6 @@ session_sandboxes = true
let settings =
fabro_config::ServerSettingsBuilder::load_default().expect("settings should resolve");
assert_eq!(settings.server.storage.root.as_source(), "/srv/from-home");
assert!(settings.features.session_sandboxes);
});
}

View file

@ -875,8 +875,7 @@ pub(crate) async fn get_system_info(
"storage_dir": "/demo/fabro/storage",
"uptime_secs": 42,
"runs": { "total": 3, "active": 1 },
"sandbox_provider": "local",
"features": { "session_sandboxes": false }
"sandbox_provider": "local"
})),
)
.into_response()
@ -2123,9 +2122,6 @@ strategy = "app"
app_id = "12345"
client_id = "Iv1.abc123"
slug = "fabro-dev"
[features]
session_sandboxes = false
"#,
)
.expect("demo settings fixture should resolve"),

View file

@ -37,10 +37,9 @@ pub use fabro_api::types::{
RunStage, SandboxDetails, SandboxFileEntry, SandboxFileListResponse, SandboxService,
SandboxServiceListResponse, SshAccessRequest, SshAccessResponse, StageHandler, StageState,
StartRunRequest, SubmitAnswerRequest, SystemCpuResourceScope, SystemCpuResources,
SystemDiskResourceScope, SystemDiskResources, SystemFeatures, SystemInfoResponse,
SystemMemoryResourceScope, SystemMemoryResources, SystemRepairRunIssue,
SystemRepairRunsResponse, SystemResourcesResponse, SystemRunCounts, TimelineEntryResponse,
VncPreviewResponse, WriteBlobResponse,
SystemDiskResourceScope, SystemDiskResources, SystemInfoResponse, SystemMemoryResourceScope,
SystemMemoryResources, SystemRepairRunIssue, SystemRepairRunsResponse, SystemResourcesResponse,
SystemRunCounts, TimelineEntryResponse, VncPreviewResponse, WriteBlobResponse,
};
use fabro_auth::{CredentialSource, VaultCredentialSource, auth_issue_message};
#[cfg(test)]
@ -641,8 +640,7 @@ pub struct AppState {
type PullRequestCreateLocks = Arc<Mutex<HashMap<RunId, Arc<AsyncMutex<()>>>>>;
struct AskFabroReadiness {
feature_enabled: bool,
default_model: Option<String>,
default_model: Option<String>,
}
impl AskFabroReadiness {
@ -652,9 +650,7 @@ impl AskFabroReadiness {
}
fn ask_fabro_for(&self, run: &fabro_types::Run) -> AskFabro {
let unavailable_reason = if !self.feature_enabled {
Some(AskFabroUnavailableReason::FeatureDisabled)
} else if run.sandbox.is_none() {
let unavailable_reason = if run.sandbox.is_none() {
Some(AskFabroUnavailableReason::NoSandbox)
} else if run
.sandbox
@ -871,14 +867,6 @@ impl AppState {
}
async fn ask_fabro_readiness(&self) -> AskFabroReadiness {
let feature_enabled = self.server_settings().features.session_sandboxes;
if !feature_enabled {
return AskFabroReadiness {
feature_enabled,
default_model: None,
};
}
let provider_ids = self.ready_llm_provider_ids().await;
let default_model = if provider_ids.is_empty() {
None
@ -890,10 +878,7 @@ impl AppState {
.clone(),
)
};
AskFabroReadiness {
feature_enabled,
default_model,
}
AskFabroReadiness { default_model }
}
pub(crate) fn vault_or_env(&self, name: &str) -> Option<String> {
@ -1503,16 +1488,6 @@ async fn github_webhook(
StatusCode::OK
}
fn system_features(
server_settings: &ServerSettings,
_manifest_run_settings: &std::result::Result<RunNamespace, SharedError>,
) -> SystemFeatures {
let session_sandboxes = server_settings.features.session_sandboxes;
SystemFeatures {
session_sandboxes: Some(session_sandboxes),
}
}
struct PrunePlan {
run_ids: Vec<RunId>,
rows: Vec<PruneRunEntry>,

View file

@ -6,8 +6,8 @@ use super::super::{
PruneRunsRequest, PruneRunsResponse, Query, RequiredUser, Response, Router, RunStatus, State,
StatusCode, SystemInfoResponse, SystemRepairRunIssue, SystemRepairRunsResponse,
SystemRunCounts, build_disk_usage_response, build_prune_plan, delete_run_internal, diagnostics,
get, post, resolve_interp_string, resource_sampler, spawn_blocking, system_features,
system_sandbox_provider, to_i64,
get, post, resolve_interp_string, resource_sampler, spawn_blocking, system_sandbox_provider,
to_i64,
};
pub(super) fn routes() -> Router<Arc<AppState>> {
@ -76,10 +76,6 @@ async fn get_system_info(_auth: RequiredUser, State(state): State<Arc<AppState>>
active: Some(to_i64(active_runs)),
}),
sandbox_provider: Some(system_sandbox_provider(&manifest_run_settings)),
features: Some(system_features(
server_settings.as_ref(),
&manifest_run_settings,
)),
};
(StatusCode::OK, Json(response)).into_response()
}

View file

@ -870,49 +870,6 @@ provider = "invalid-provider"
);
}
#[test]
fn system_features_use_dense_server_and_manifest_defaults() {
let source = r#"
_version = 1
[server.auth]
methods = ["dev-token"]
[features]
session_sandboxes = true
"#;
let server_settings = server_settings_from_toml(source);
let manifest_run_settings = resolve_manifest_run_settings(
&run_manifest::manifest_run_defaults(Some(&manifest_run_defaults_from_toml(source))),
);
let features = system_features(&server_settings, &manifest_run_settings);
assert_eq!(features.session_sandboxes, Some(true));
}
#[test]
fn system_features_ignore_manifest_run_settings_resolution() {
let source = r#"
_version = 1
[server.auth]
methods = ["dev-token"]
[features]
session_sandboxes = true
[run.sandbox]
provider = "invalid-provider"
"#;
let server_settings = server_settings_from_toml(source);
let manifest_run_settings = resolve_manifest_run_settings(
&run_manifest::manifest_run_defaults(Some(&manifest_run_defaults_from_toml(source))),
);
let features = system_features(&server_settings, &manifest_run_settings);
assert_eq!(features.session_sandboxes, Some(true));
}
#[test]
fn system_sandbox_provider_uses_manifest_defaults() {
let source = r#"

View file

@ -57,9 +57,6 @@ async fn run_responses_include_ask_fabro_affordance() {
let settings = settings_from_toml(
r"
_version = 1
[features]
session_sandboxes = true
",
);
let state = fabro_server::test_support::TestAppStateBuilder::new()
@ -149,9 +146,6 @@ goal = "Ship it"
[cli.output]
verbosity = "verbose"
[features]
session_sandboxes = true
"#
}]);

View file

@ -50,9 +50,7 @@ client_id = "Iv1.abcdef"
let top_level = body
.as_object()
.expect("server settings response should be an object");
assert_eq!(top_level.len(), 2);
assert!(top_level.contains_key("server"));
assert!(top_level.contains_key("features"));
assert_eq!(body["server"]["listen"]["type"], "tcp");
assert_eq!(body["server"]["listen"]["address"], "127.0.0.1:32276");
@ -68,7 +66,7 @@ client_id = "Iv1.abcdef"
body["server"]["integrations"]["github"]["client_id"],
"Iv1.abcdef"
);
assert_eq!(body["features"]["session_sandboxes"], false);
assert!(body.get("features").is_none());
assert!(body.get("cli").is_none());
assert!(body.get("run").is_none());
}

View file

@ -138,9 +138,9 @@ async fn get_system_info_returns_runtime_fields() {
assert_eq!(body["runs"]["total"], 0);
assert_eq!(body["runs"]["active"], 0);
assert!(body["uptime_secs"].as_i64().is_some());
assert_eq!(
body["features"],
serde_json::json!({ "session_sandboxes": false })
assert!(
body.get("features").is_none(),
"system info should not include a features field"
);
}

View file

@ -4,14 +4,13 @@ use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::settings::{
CliNamespace, FeaturesNamespace, InterpString, ObjectStoreSettings, ProjectNamespace,
RunNamespace, ServerNamespace, WorkflowNamespace,
CliNamespace, InterpString, ObjectStoreSettings, ProjectNamespace, RunNamespace,
ServerNamespace, WorkflowNamespace,
};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ServerSettings {
pub server: ServerNamespace,
pub features: FeaturesNamespace,
pub server: ServerNamespace,
}
impl ServerSettings {
@ -43,8 +42,7 @@ fn override_local_object_store_root(
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct UserSettings {
pub cli: CliNamespace,
pub features: FeaturesNamespace,
pub cli: CliNamespace,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]

View file

@ -33,7 +33,6 @@ pub struct AskFabro {
#[serde(rename_all = "snake_case")]
#[strum(serialize_all = "snake_case")]
pub enum AskFabroUnavailableReason {
FeatureDisabled,
NoSandbox,
SandboxNotReady,
LlmUnconfigured,

View file

@ -1,12 +0,0 @@
//! Features domain.
//!
//! `[features]` is a reserved cross-cutting namespace for Fabro capability
//! flags only. It has a high admission bar and must not become a junk drawer.
use serde::{Deserialize, Serialize};
/// A structurally resolved `[features]` view for consumers.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct FeaturesNamespace {
pub session_sandboxes: bool,
}

View file

@ -1,9 +1,9 @@
//! Namespaced settings schema.
//!
//! Top-level schema is strictly namespaced with `_version`, `[project]`,
//! `[workflow]`, `[run]`, `[cli]`, `[server]`, and `[features]`. Value-language
//! helpers live alongside the tree: durations, byte sizes, model references,
//! and env interpolation.
//! `[workflow]`, `[run]`, `[cli]`, and `[server]`. Value-language helpers live
//! alongside the tree: durations, byte sizes, model references, and env
//! interpolation.
//!
//! Stage 6.5b promoted these modules up out of the transitional
//! `settings/v2/` subdirectory, so the `::v2::` path prefix no longer
@ -11,7 +11,6 @@
pub mod cli;
pub mod duration;
pub mod features;
pub mod interp;
pub mod model_ref;
pub mod project;
@ -26,7 +25,6 @@ pub use cli::{
CliLoggingSettings, CliNamespace, CliOutputSettings, CliTargetSettings, CliUpdatesSettings,
};
pub use duration::{Duration, ParseDurationError};
pub use features::FeaturesNamespace;
pub use interp::{InterpString, Provenance, ResolveEnvError, Resolved};
pub use model_ref::{
AmbiguousModelRef, ModelRef, ModelRegistry, ParseModelRefError, ResolvedModelRef,

View file

@ -105,7 +105,6 @@ models/execute-query-response.ts
models/failure-category.ts
models/failure-detail.ts
models/failure-reason.ts
models/features-namespace.ts
models/file-checkpoint.ts
models/file-diff.ts
models/fork-request.ts
@ -402,7 +401,6 @@ models/system-cpu-resource-scope.ts
models/system-cpu-resources.ts
models/system-disk-resource-scope.ts
models/system-disk-resources.ts
models/system-features.ts
models/system-info-response.ts
models/system-memory-resource-scope.ts
models/system-memory-resources.ts

View file

@ -24,7 +24,6 @@ export interface AskFabro {
}
export const AskFabroUnavailableReasonEnum = {
FEATURE_DISABLED: 'feature_disabled',
NO_SANDBOX: 'no_sandbox',
SANDBOX_NOT_READY: 'sandbox_not_ready',
LLM_UNCONFIGURED: 'llm_unconfigured'

View file

@ -1,19 +0,0 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
export interface FeaturesNamespace {
'session_sandboxes': boolean;
}

View file

@ -82,7 +82,6 @@ export * from './execute-query-response-rows-inner-inner';
export * from './failure-category';
export * from './failure-detail';
export * from './failure-reason';
export * from './features-namespace';
export * from './file-checkpoint';
export * from './file-diff';
export * from './fork-request';
@ -378,7 +377,6 @@ export * from './system-cpu-resource-scope';
export * from './system-cpu-resources';
export * from './system-disk-resource-scope';
export * from './system-disk-resources';
export * from './system-features';
export * from './system-info-response';
export * from './system-memory-resource-scope';
export * from './system-memory-resources';

View file

@ -13,9 +13,6 @@
*/
// May contain unused imports in some cases
// @ts-ignore
import type { FeaturesNamespace } from './features-namespace';
// May contain unused imports in some cases
// @ts-ignore
import type { ServerNamespace } from './server-namespace';
@ -25,5 +22,4 @@ import type { ServerNamespace } from './server-namespace';
*/
export interface ServerSettings {
'server': ServerNamespace;
'features': FeaturesNamespace;
}

View file

@ -1,25 +0,0 @@
/* tslint:disable */
/* eslint-disable */
/**
* Fabro Run API
* HTTP API for managing Fabro workflow run executions.
*
* The version of the OpenAPI document: 0.1.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
* Server-level capability flags.
*/
export interface SystemFeatures {
/**
* Whether session sandboxes are enabled.
*/
'session_sandboxes'?: boolean;
}

View file

@ -13,9 +13,6 @@
*/
// May contain unused imports in some cases
// @ts-ignore
import type { SystemFeatures } from './system-features';
// May contain unused imports in some cases
// @ts-ignore
import type { SystemRunCounts } from './system-run-counts';
@ -69,5 +66,4 @@ export interface SystemInfoResponse {
* Effective sandbox provider for launched runs.
*/
'sandbox_provider'?: string;
'features'?: SystemFeatures;
}