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
20 changes: 16 additions & 4 deletions .specs/gastown-usage-based-billing.md
Original file line number Diff line number Diff line change
Expand Up @@ -461,10 +461,14 @@ Metering and enforcement are separately controlled:
- **Announcement** — the default-off `GASTOWN_BILLING_ANNOUNCEMENT_ENABLED` web flag MAY show advance
notice; it MUST NOT alter backend billing behavior.

The status object distinguishes these: `enabled` means metering is active (drives the run estimate
and the automatic-start control), while `enforcing` gates cold-start/terminal blocking in the UI.
Enforcement and announcement flags are always enabled in local development while retaining
default-off production behavior; metering runs in development because the meter binding is present.
The status object distinguishes these: `enabled` means metering is active (usage is reported and
the run estimate is populated), while `enforcing` gates cold-start/terminal blocking in the UI.
User-facing billing UI (the estimate pill, cost strings, and automatic-start control) is gated
solely on the announcement flag (`GASTOWN_BILLING_ANNOUNCEMENT_ENABLED`). Neither `enabled` (shadow
metering, on in production before launch) nor `enforcing` (backend enforcement) may reveal billing
UI to users until billing is announced. Enforcement and announcement flags are always enabled in
local development while retaining default-off production behavior; metering runs in development
because the meter binding is present.

Dashboards MUST expose awake seconds, base Cloudflare cost, customer charge, gross multiplier,
unclosed intervals, SKU admission denials, RPC failures, graceful-stop duration, forced stops, and
Expand Down Expand Up @@ -503,6 +507,14 @@ counts of `warn` and `stop` verdicts.

## Changelog

### 2026-07-28 -- Gate user-facing billing UI on announcement, not metering

- User-facing billing UI (estimate pill, cost strings, automatic-start control) is now gated solely
on the announcement flag (`GASTOWN_BILLING_ANNOUNCEMENT_ENABLED`), not on `billing.enabled` or
`billing.enforcing`. Because metering (`enabled`) is on in production for shadow data, keying the
UI off `enabled` surfaced billing estimates to users before launch. The status field `enabled`
remains metering-only; the web `TerminalBar` receives the announcement flag from the town layouts.

### 2026-07-24 -- Separate reporting from enforcement

- Metering now always reports to the meter when the `CONTAINER_USAGE` binding is present;
Expand Down
10 changes: 9 additions & 1 deletion apps/web/src/app/(app)/gastown/[townId]/MayorTerminalBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,20 @@ import { GASTOWN_URL } from '@/lib/constants';
export function MayorTerminalBar({
params,
basePath,
billingAnnouncementEnabled,
}: {
params: Promise<{ townId: string }>;
basePath?: string;
billingAnnouncementEnabled?: boolean;
}) {
const { townId } = use(params);
// Track dashboard navigation context and sync to TownDO
useGastownUiContext(townId, GASTOWN_URL);
return <TerminalBar townId={townId} basePath={basePath} />;
return (
<TerminalBar
townId={townId}
basePath={basePath}
billingAnnouncementEnabled={billingAnnouncementEnabled}
/>
);
}
6 changes: 5 additions & 1 deletion apps/web/src/app/(app)/gastown/[townId]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { DrawerStackProvider } from '@/components/gastown/DrawerStack';
import { renderDrawerContent } from '@/components/gastown/DrawerStackContent';
import { TerminalBarPadding } from '@/components/gastown/TerminalBarPadding';
import { HideAppTopbar } from '@/components/gastown/HideAppTopbar';
import { GASTOWN_BILLING_ANNOUNCEMENT_ENABLED } from '@/lib/config.server';
import { MayorTerminalBar } from './MayorTerminalBar';
import { OnboardingTooltipsWrapper } from './OnboardingTooltipsWrapper';

Expand All @@ -18,7 +19,10 @@ export default function TownLayout({
<DrawerStackProvider renderContent={renderDrawerContent}>
<HideAppTopbar />
<TerminalBarPadding>{children}</TerminalBarPadding>
<MayorTerminalBar params={params} />
<MayorTerminalBar
params={params}
billingAnnouncementEnabled={GASTOWN_BILLING_ANNOUNCEMENT_ENABLED}
/>
<OnboardingTooltipsWrapper params={params} />
</DrawerStackProvider>
</TerminalBarProvider>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { DrawerStackProvider } from '@/components/gastown/DrawerStack';
import { renderDrawerContent } from '@/components/gastown/DrawerStackContent';
import { TerminalBarPadding } from '@/components/gastown/TerminalBarPadding';
import { HideAppTopbar } from '@/components/gastown/HideAppTopbar';
import { GASTOWN_BILLING_ANNOUNCEMENT_ENABLED } from '@/lib/config.server';
import { MayorTerminalBar } from '@/app/(app)/gastown/[townId]/MayorTerminalBar';
import { OnboardingTooltips } from '@/components/gastown/OnboardingTooltips';

Expand All @@ -21,7 +22,11 @@ export default async function OrgTownLayout({
<DrawerStackProvider renderContent={renderDrawerContent}>
<HideAppTopbar />
<TerminalBarPadding>{children}</TerminalBarPadding>
<MayorTerminalBar params={params} basePath={basePath} />
<MayorTerminalBar
params={params}
basePath={basePath}
billingAnnouncementEnabled={GASTOWN_BILLING_ANNOUNCEMENT_ENABLED}
/>
<OnboardingTooltips townId={townId} />
</DrawerStackProvider>
</TerminalBarProvider>
Expand Down
29 changes: 26 additions & 3 deletions apps/web/src/components/gastown/TerminalBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ type TerminalBarProps = {
townId: string;
/** Override base path for org-scoped routes (e.g. /organizations/[id]/gastown/[townId]) */
basePath?: string;
/**
* Whether user-facing billing UI (estimate pill, cost strings, automatic-start
* control) may be shown. Driven by GASTOWN_BILLING_ANNOUNCEMENT_ENABLED. This is
* intentionally separate from `billing.enabled`, which only reflects shadow
* metering and is on in production before billing is announced to users.
*/
billingAnnouncementEnabled?: boolean;
};

type BillingStatus = GastownOutputs['gastown']['getBillingStatus'];
Expand All @@ -61,7 +68,11 @@ type BillingStatus = GastownOutputs['gastown']['getBillingStatus'];
* Agent terminal tabs are opened/closed via TerminalBarContext.
* Can be positioned at bottom/top/right/left with drag-to-resize.
*/
export function TerminalBar({ townId, basePath: basePathOverride }: TerminalBarProps) {
export function TerminalBar({
townId,
basePath: basePathOverride,
billingAnnouncementEnabled = false,
}: TerminalBarProps) {
const trpc = useGastownTRPC();
const townBasePath = basePathOverride ?? `/gastown/${townId}`;
const { state: sidebarState, isMobile } = useSidebar();
Expand Down Expand Up @@ -378,6 +389,7 @@ export function TerminalBar({ townId, basePath: basePathOverride }: TerminalBarP
setPosition={setPosition}
closeTab={closeTab}
billing={billing}
billingAnnouncementEnabled={billingAnnouncementEnabled}
townId={townId}
/>
<TerminalContent
Expand Down Expand Up @@ -416,6 +428,7 @@ export function TerminalBar({ townId, basePath: basePathOverride }: TerminalBarP
setPosition={setPosition}
closeTab={closeTab}
billing={billing}
billingAnnouncementEnabled={billingAnnouncementEnabled}
townId={townId}
/>
{!collapsed && (
Expand Down Expand Up @@ -452,6 +465,7 @@ export function TerminalBar({ townId, basePath: basePathOverride }: TerminalBarP
setPosition={setPosition}
closeTab={closeTab}
billing={billing}
billingAnnouncementEnabled={billingAnnouncementEnabled}
townId={townId}
/>
<TerminalContent
Expand Down Expand Up @@ -480,6 +494,7 @@ export function TerminalBar({ townId, basePath: basePathOverride }: TerminalBarP
setPosition={setPosition}
closeTab={closeTab}
billing={billing}
billingAnnouncementEnabled={billingAnnouncementEnabled}
townId={townId}
/>
<TerminalContent
Expand Down Expand Up @@ -529,6 +544,7 @@ function TabBar({
setPosition,
closeTab,
billing,
billingAnnouncementEnabled,
townId,
}: {
allTabs: TabDef[];
Expand All @@ -542,6 +558,7 @@ function TabBar({
setPosition: (position: TerminalPosition) => void;
closeTab: (tabId: string) => void;
billing?: BillingStatus;
billingAnnouncementEnabled: boolean;
townId: string;
}) {
const borderClass = horizontal ? 'border-b border-white/[0.06]' : 'border-r border-white/[0.06]';
Expand Down Expand Up @@ -605,6 +622,12 @@ function TabBar({
const showRunCostTooltip =
billing?.estimatedRunCharge !== undefined &&
(billing.state === 'running' || billing.state === 'warning' || billing.state === 'stopping');
// Whether to surface user-facing billing UI (estimate pill, cost strings,
// automatic-start control). Gated solely on the announcement flag. `billing.enabled`
// reflects shadow metering (on in production before launch) and `billing.enforcing`
// reflects backend enforcement; neither should reveal billing UI to users until
// billing is announced.
const showBilling = billing !== undefined && billingAnnouncementEnabled;
Comment thread
jrf0110 marked this conversation as resolved.

return (
<div
Expand Down Expand Up @@ -692,7 +715,7 @@ function TabBar({
{billingAnnouncement}
</span>

{horizontal && billing && (billing.enabled || billing.runPolicy === 'paused_by_user') && (
{horizontal && billing && showBilling && (
<div
className={`mr-2 flex shrink-0 items-center gap-1.5 rounded-full border px-2 py-0.5 text-[10px] tabular-nums ${
billing.state === 'warning'
Expand Down Expand Up @@ -747,7 +770,7 @@ function TabBar({
</div>
)}

{!horizontal && billing && (billing.enabled || billing.runPolicy === 'paused_by_user') && (
{!horizontal && billing && showBilling && (
<div className="flex justify-center py-2" title="Allow automatic container starts">
<Switch
checked={billing.runPolicy === 'automatic'}
Expand Down
Loading