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
15 changes: 15 additions & 0 deletions .changeset/self-host-rebuild-on-publish.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@cloudhal/cli": patch
"@cloudhal/api": patch
"@cloudhal/core-ts": patch
"@cloudhal/mcp-server": patch
---

A self-hosted site rebuilds itself on publish (#150). With `CLOUDHAL_SITE_BUILD=on`, which is the Docker image's default, `cloudhal studio` runs the image's `site-build` in a child process after every publish: a publish in Studio or through MCP, an approval, a rollback, or a release that goes live (at its time or through "Publish now"). It also runs it once at start.

- **One build per burst.** A burst of publishes builds once (3 seconds after the last one, at most a minute after the first).
- **One build at a time.** Studio's builds, a cron `site-build` and a hand-started one share one lock.
- **Failures keep the last good site.** A failed build keeps the previous site and is tried again on the next publish.
- **Bounded.** A build has a deadline (`CLOUDHAL_SITE_BUILD_TIMEOUT`, 30 minutes by default). Stopping Studio during a build ends it within 5 seconds, with everything it started, and records it as stopped.

Every build is recorded in the site's Activity as "Build public site", with its duration, snapshot and output on failure. The site's overview shows a new "Public site build" card with the same details, and what visitors see now from `/data/site/current.json`, which `site-build` writes after each swap, so builds from cron or by hand show there too. The failure's output is shown only to roles with `audit:read`. `CLOUDHAL_SITE_BUILD=off` turns both off for sites built elsewhere, and a plain `cloudhal studio` keeps it off. For other integrations, `ScheduledTickOptions.onReleasePublished` (core-ts) reports releases that went live, `ReleaseServiceOptions.onReleasePublished` (and the API's `SchedulesDeps.onReleasePublished`) reports a release published now, `openLocalStore` takes `onPublished`, and `GET /api/sites/:id/site-build` reports the queue, the last build and what is served.
29 changes: 8 additions & 21 deletions apps/studio/src/components/Audit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,7 @@ import {
import { Robot } from "@phosphor-icons/react";
import { useCallback } from "react";
import { useT } from "../i18n/index.tsx";
import {
ASSET_EVENTS,
isAssetEvent,
isKeyEvent,
isKnownTool,
isMemberEvent,
KEY_EVENTS,
MEMBER_EVENTS,
requestedByAgent,
} from "../lib/activity.ts";
import { eventLabelKey, isKnownTool, requestedByAgent } from "../lib/activity.ts";
import { isAutoApproved } from "../lib/agents.ts";
import type {
AgentMode,
Expand All @@ -35,21 +26,17 @@ import { RoleBadge } from "./Roles.tsx";

/**
* Human-readable label for an MCP tool name ("update_page" → "Update page") or a person's action
* on an agent key ("agent-key.elevate" → "Elevate agent key"); unknown tools pass through.
* on an agent key ("agent-key.elevate" → "Elevate agent key"), or a system event ("site.build");
* unknown tools pass through.
*/
export function useToolLabel() {
const t = useT();
return useCallback(
(tool: string) =>
isKnownTool(tool)
? t(`tools.${tool}`)
: isKeyEvent(tool)
? t(`tools.${KEY_EVENTS[tool]}`)
: isMemberEvent(tool)
? t(`tools.${MEMBER_EVENTS[tool]}`)
: isAssetEvent(tool)
? t(`tools.${ASSET_EVENTS[tool]}`)
: tool,
(tool: string) => {
if (isKnownTool(tool)) return t(`tools.${tool}`);
const key = eventLabelKey(tool);
return key ? t(`tools.${key}`) : tool;
},
[t],
);
}
Expand Down
185 changes: 185 additions & 0 deletions apps/studio/src/components/site-overview/SiteBuildCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import { Badge, Banner, Code, InlineCopyText, SectionCard } from "@cloudhal/ui";
import { Globe, WarningCircle } from "@phosphor-icons/react";
import { useQuery } from "@tanstack/react-query";
import { useT } from "../../i18n/index.tsx";
import { ApiRequestError, api, type SiteBuildStatus } from "../../lib/api.ts";
import { RelativeTime } from "../RelativeTime.tsx";
import { StatRow } from "./SiteStatusCard.tsx";

/** While a build is queued or running the card follows it; otherwise it looks now and then. */
export const POLL_BUSY_MS = 3_000;
export const POLL_IDLE_MS = 30_000;

type Enabled = Extract<SiteBuildStatus, { enabled: true }>;

/** An answer that will not change by asking again: a 4xx (403, 404, …), not a timeout or 429. */
function isPermanent(error: unknown): boolean {
return (
error instanceof ApiRequestError &&
error.status >= 400 &&
error.status < 500 &&
error.status !== 408 &&
error.status !== 429
);
}

/**
* How often to look again: never once the server said the feature is off or refused for good (a
* 4xx); after a network error or a 5xx, at the last pace (idle when nothing came yet).
*/
export function siteBuildPoll(data: SiteBuildStatus | undefined, error: unknown): number | false {
if (isPermanent(error) || data?.enabled === false) return false;
return data?.enabled && data.state !== "idle" ? POLL_BUSY_MS : POLL_IDLE_MS;
}

/**
* Whether Studio's last build is what visitors see: by build id when both sides name one (a run
* that failed after its swap still went live), else by time (a build that went live within a
* minute of its end).
*/
function lastIsServed(s: Enabled): boolean | undefined {
const { last, served } = s;
if (!last || !served) return undefined;
if (last.build && served.build) return last.build === served.build;
if (!last.ok) return false;
const end = Date.parse(last.at) + (last.durationMs ?? 0);
return Date.parse(served.at) <= end + 60_000;
}

/** What the card shows: what is online, and whether Studio's last build failed after it. */
export function siteBuildView(s: Enabled) {
const { last, served } = s;
const live = lastIsServed(s);
const lastEnd = last ? Date.parse(last.at) + (last.durationMs ?? 0) : Number.NaN;
// Studio's failed build is current unless it went live after all (same build id), or another
// build went live after it (a cron or hand-started one: another id, or a later time)
const replaced =
!!served &&
!!last &&
(last.build && served.build ? served.build !== last.build : Date.parse(served.at) > lastEnd);
const failure = last && !last.ok && !live && !replaced ? last : undefined;
// the server says what is online (`served`); an older one only knows Studio's last build
const online =
served !== undefined ? served : last?.ok ? { at: last.at, ...pick(last) } : undefined;
// a failure: when it ran; otherwise when what is online went live
const when = failure ? failure.at : (served?.at ?? last?.at);
// the duration is Studio's build's, shown for the failure or while that build is online
const durationMs = failure || (last && (live ?? last.ok)) ? last?.durationMs : undefined;
return {
failure,
online,
when,
durationMs,
built: !failure && (!!last || !!served),
};
}

function pick(l: { snapshotId?: string; state?: "published" | "not-published" }) {
return {
...(l.snapshotId ? { snapshotId: l.snapshotId } : {}),
...(l.state ? { state: l.state } : {}),
};
}

/**
* The overview's "Public site build" card (#150): on a self-hosted server that rebuilds its static
* site after every publish, whether a build is running or queued, when the site was last built,
* what visitors see now (the published snapshot, or the coming soon page before the first
* publish) whoever built it, and why Studio's last build failed — saying whether a previous
* version stays online or nothing is online yet. Absent elsewhere.
*/
export function SiteBuildCard({ siteId }: { siteId: string }) {
const t = useT();
const status = useQuery({
queryKey: ["site-build", siteId],
queryFn: () => api.siteBuild.status(siteId),
retry: false,
refetchInterval: (q) => siteBuildPoll(q.state.data, q.state.error),
});
const s = status.data;
if (!s?.enabled) return null;
const v = siteBuildView(s);
return (
<SectionCard
title={t("overview.siteBuild.title")}
icon={<Globe size={16} />}
description={t("overview.siteBuild.description")}
actions={<StateBadge state={s.state} failed={!!v.failure} built={v.built} />}
>
<div className="grid gap-4">
{v.failure && (
<Banner
variant="error"
icon={<WarningCircle />}
title={t("overview.siteBuild.failedTitle")}
description={
v.online === null
? t("overview.siteBuild.failedNothingOnline")
: t("overview.siteBuild.failedDescription")
}
/>
)}
{v.when && (
<dl className="grid divide-y divide-kumo-hairline">
<StatRow
label={t("overview.siteBuild.lastBuild")}
value={<RelativeTime iso={v.when} size="base" />}
/>
{v.durationMs !== undefined && (
<StatRow
label={t("overview.siteBuild.duration")}
value={t("overview.siteBuild.seconds", {
seconds: Math.max(1, Math.round(v.durationMs / 1000)),
})}
/>
)}
{v.online?.state === "not-published" && (
<StatRow
label={t("overview.siteBuild.output")}
value={t("overview.siteBuild.statusPage")}
/>
)}
{v.online?.snapshotId && (
<StatRow
label={t("overview.siteBuild.snapshot")}
value={<InlineCopyText>{v.online.snapshotId}</InlineCopyText>}
/>
)}
</dl>
)}
{v.failure?.error && <Code lang="bash" code={v.failure.error} />}
</div>
</SectionCard>
);
}

function StateBadge(p: { state: Enabled["state"]; failed: boolean; built: boolean }) {
const t = useT();
if (p.state === "running")
return (
<Badge variant="warning" appearance="dot">
{t("overview.siteBuild.building")}
</Badge>
);
if (p.state === "queued")
return (
<Badge variant="neutral" appearance="dot">
{t("overview.siteBuild.queued")}
</Badge>
);
if (p.failed)
return (
<Badge variant="error" appearance="dot">
{t("overview.siteBuild.failed")}
</Badge>
);
return p.built ? (
<Badge variant="success" appearance="dot">
{t("overview.siteBuild.built")}
</Badge>
) : (
<Badge variant="neutral" appearance="dot">
{t("overview.siteBuild.never")}
</Badge>
);
}
3 changes: 2 additions & 1 deletion apps/studio/src/components/site-overview/SiteStatusCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,8 @@ export function SiteStatusCard({
);
}

function StatRow({ label, value }: { label: string; value: ReactNode }) {
/** One label and value of an overview card's list (`<dl>`). */
export function StatRow({ label, value }: { label: string; value: ReactNode }) {
return (
<div className="flex min-w-0 items-center justify-between gap-4 py-2 first:pt-0 last:pb-0">
<Text as="dt" variant="secondary">
Expand Down
21 changes: 20 additions & 1 deletion apps/studio/src/i18n/ar.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): ar core messages.
// Do not edit: change the translation file and import it; only English is edited here.
// hash: 44d1f7038905a304
// hash: 4bc35bf389a3fc55
import type { Messages } from "./en.ts";

export const messages: Messages = {
Expand Down Expand Up @@ -264,6 +264,24 @@ export const messages: Messages = {
recentEmpty: "لم يُكتب شيء بعد",
viewAll: "اعرض الكل",
cli: "من الطرفية",
siteBuild: {
title: "بناء الموقع العام",
description: "يُعاد بناؤه بعد كل نشر وموافقة وتراجع وإصدار مجدول.",
building: "قيد البناء",
queued: "في انتظار البناء",
built: "تم البناء",
failed: "فشل",
never: "لم يُبنَ بعد",
lastBuild: "آخر بناء",
duration: "المدة",
seconds: "{seconds, number} ث",
output: "يرى الزوار",
statusPage: "صفحة «قريبًا»",
snapshot: "اللقطة",
failedTitle: "فشل آخر بناء",
failedDescription: "تبقى النسخة السابقة متاحة على الإنترنت. سيحاول النشر التالي مرة أخرى.",
failedNothingOnline: "لا يوجد شيء متاح على الإنترنت بعد. سيحاول النشر التالي مرة أخرى.",
},
},
pageStatus: {
publishTitle: "هل تريد نشر «{title}»؟",
Expand Down Expand Up @@ -1692,6 +1710,7 @@ export const messages: Messages = {
assetDelete: "حذف ملف",
assetPurge: "إزالة ملف نهائيًا",
assetLiftPurge: "السماح بملف مجددًا",
siteBuild: "بناء الموقع العام",
list_sites: "اسرد المواقع",
get_site: "احصل على موقع",
list_pages: "اسرد الصفحات",
Expand Down
24 changes: 23 additions & 1 deletion apps/studio/src/i18n/de.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Generated by `pnpm i18n:import` (ADR-0034): de core messages.
// Do not edit: change the translation file and import it; only English is edited here.
// hash: 6137f81aef7c3570
// hash: fee1e4da7ca5d8d9
import type { Messages } from "./en.ts";

export const messages: Messages = {
Expand Down Expand Up @@ -269,6 +269,27 @@ export const messages: Messages = {
recentEmpty: "Noch nichts geschrieben",
viewAll: "Alle anzeigen",
cli: "Im Terminal",
siteBuild: {
title: "Build der öffentlichen Website",
description:
"Wird nach jeder Veröffentlichung, Freigabe, Rücknahme und geplanten Release neu gebaut.",
building: "Wird gebaut",
queued: "Wartet auf den Build",
built: "Gebaut",
failed: "Fehlgeschlagen",
never: "Noch nicht gebaut",
lastBuild: "Letzter Build",
duration: "Dauer",
seconds: "{seconds, number} s",
output: "Besucher sehen",
statusPage: "Die Demnächst-Seite",
snapshot: "Snapshot",
failedTitle: "Der letzte Build ist fehlgeschlagen",
failedDescription:
"Die vorherige Version bleibt online. Die nächste Veröffentlichung versucht es erneut.",
failedNothingOnline:
"Noch ist nichts online. Die nächste Veröffentlichung versucht es erneut.",
},
},
pageStatus: {
publishTitle: "„{title}“ veröffentlichen?",
Expand Down Expand Up @@ -1775,6 +1796,7 @@ export const messages: Messages = {
assetDelete: "Datei löschen",
assetPurge: "Datei entfernen",
assetLiftPurge: "Datei wieder zulassen",
siteBuild: "Öffentliche Website bauen",
list_sites: "Websites auflisten",
get_site: "Website abrufen",
list_pages: "Seiten auflisten",
Expand Down
20 changes: 20 additions & 0 deletions apps/studio/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,25 @@ export const en = {
recentEmpty: "Nothing written yet",
viewAll: "View all",
cli: "From the terminal",
/** The self-hosted server's static site, rebuilt after every publish (#150). */
siteBuild: {
title: "Public site build",
description: "Rebuilt after every publish, approval, rollback and scheduled release.",
building: "Building",
queued: "Waiting to build",
built: "Built",
failed: "Failed",
never: "Not built yet",
lastBuild: "Last build",
duration: "Duration",
seconds: "{seconds, number} s",
output: "Visitors see",
statusPage: "The coming soon page",
snapshot: "Snapshot",
failedTitle: "The last build failed",
failedDescription: "The previous version stays online. The next publish tries again.",
failedNothingOnline: "Nothing is online yet. The next publish tries again.",
},
},
pageStatus: {
publishTitle: "Publish “{title}”?",
Expand Down Expand Up @@ -1658,6 +1677,7 @@ export const en = {
assetDelete: "Delete file",
assetPurge: "Take down file",
assetLiftPurge: "Allow file again",
siteBuild: "Build public site",
list_sites: "List sites",
get_site: "Get site",
list_pages: "List pages",
Expand Down
Loading
Loading