Merge remote-tracking branch 'origin/main' into ethie/pm-clean

# Conflicts:
#	hermes_cli/backup.py
#	tests/hermes_cli/test_gateway_restart_loop.py
#	website/docs/developer-guide/web-search-provider-plugin.md
#	website/docs/getting-started/installation.md
#	website/docs/getting-started/updating.md
#	website/docs/index.mdx
#	website/docs/reference/cli-commands.md
#	website/docs/user-guide/docker.md
#	website/docs/user-guide/windows-wsl-quickstart.md
#	website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/plugins/index.md
#	website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/developer-guide/web-search-provider-plugin.md
#	website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/index.mdx
#	website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/cli-commands.md
#	website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/reference/environment-variables.md
#	website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/docker.md
#	website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/features/plugins.md
#	website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/security.md
#	website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/windows-wsl-quickstart.md
This commit is contained in:
ethernet
2026-09-18 18:41:16 -04:00
590 changed files with 7937 additions and 2222 deletions

View File

@@ -8,6 +8,11 @@ import {
import { useLocation, useSearchParams } from "react-router";
import { api, setManagementProfile } from "@/lib/api";
import { ProfileContext } from "@/contexts/profile-context";
import {
dashboardInitialProfile,
initialProfileScope,
shouldAdoptActiveProfile,
} from "@/lib/profile-bootstrap";
/**
* Machine-level management-profile scope.
@@ -38,11 +43,13 @@ export function ProfileProvider({ children }: { children: ReactNode }) {
const { pathname } = useLocation();
const [profiles, setProfiles] = useState<string[]>([]);
const [currentProfile, setCurrentProfile] = useState("default");
const bootstrapProfile = dashboardInitialProfile();
// Initial value comes from the URL (deep link / refresh / unified-launch
// preselect); afterwards state leads and the URL follows.
// An explicit URL wins; profile-less deep links inherit the unified-launch
// preselection injected by the server. Afterwards state leads and the URL
// follows.
const [profile, setProfileState] = useState(
() => searchParams.get("profile") ?? "",
() => initialProfileScope(searchParams, bootstrapProfile),
);
// Mirror into the api module synchronously on every render where it
@@ -92,11 +99,17 @@ export function ProfileProvider({ children }: { children: ReactNode }) {
const active = info.active || "default";
setCurrentProfile(current);
// Deep links (?profile=) win. Otherwise align the switcher with the
// sticky active profile so Chat and management pages match what the
// Profiles page shows as "active" (machine dashboard runs as
// `current`, usually default).
if (urlProfile === null && active !== current) {
// Explicit URL and unified-launch bootstrap scopes win. Without
// either, align the switcher with the sticky active profile so Chat
// and management pages match what Profiles shows as "active".
if (
shouldAdoptActiveProfile(
urlProfile,
bootstrapProfile,
current,
active,
)
) {
setManagementProfile(active);
setProfileState(active);
}

View File

@@ -308,6 +308,10 @@ export const en: Translations = {
noJobs: "No cron jobs configured. Create one above.",
last: "Last",
next: "Next",
/** Replaces `next` when the stored next_run_at is already past the scheduler grace. */
overdueSince: "Overdue since",
/** Banner when the ticker heartbeat is stale; {when} is a relative time such as "7h ago". */
schedulerLastTicked: "Scheduler last ticked {when} — jobs that came due since then have not fired",
pause: "Pause",
resume: "Resume",
triggerNow: "Trigger now",

View File

@@ -322,6 +322,8 @@ export interface Translations {
noJobs: string;
last: string;
next: string;
overdueSince?: string;
schedulerLastTicked?: string;
pause: string;
resume: string;
triggerNow: string;

View File

@@ -2328,6 +2328,8 @@ export interface CronJob {
workdir?: string | null;
last_run_at?: string | null;
next_run_at?: string | null;
/** Seconds since the job's profile ticker last iterated; null when it cannot be dated. */
scheduler_heartbeat_age_s?: number | null;
last_status?: string | null;
last_error?: string | null;
last_delivery_error?: string | null;

View File

@@ -5,6 +5,9 @@ import {
cronJobHasExecutionContent,
cronJobFormFromJob,
cronLastResult,
cronAgoLabel,
cronNextRunOverdueMs,
cronSchedulerStaleAgeS,
splitCronList,
type CronJobFormState,
} from "./cron-job";
@@ -200,3 +203,39 @@ describe("cronLastResult", () => {
).toEqual({ status: "blocked_config", tone: "warning", detail: "missing API key" });
});
});
describe("cronNextRunOverdueMs", () => {
const now = Date.parse("2026-09-17T20:35:00+04:00");
it("flags an active job whose stored slot sits past the scheduler grace (#114309)", () => {
const job = { next_run_at: "2026-09-17T13:34:18+04:00", enabled: true, state: "scheduled" };
expect(cronNextRunOverdueMs(job, now)).toBe(now - Date.parse(job.next_run_at));
});
it("keeps upcoming, within-grace, paused and unparseable slots as plain next runs", () => {
expect(cronNextRunOverdueMs({ next_run_at: "2026-09-17T21:00:00+04:00", enabled: true }, now)).toBeNull();
expect(cronNextRunOverdueMs({ next_run_at: "2026-09-17T20:30:00+04:00", enabled: true }, now)).toBeNull();
expect(
cronNextRunOverdueMs({ next_run_at: "2026-09-17T13:34:18+04:00", enabled: true, state: "paused" }, now),
).toBeNull();
expect(cronNextRunOverdueMs({ next_run_at: "2026-09-17T13:34:18+04:00", enabled: false }, now)).toBeNull();
expect(cronNextRunOverdueMs({ next_run_at: "not-a-date", enabled: true }, now)).toBeNull();
});
});
describe("cronSchedulerStaleAgeS", () => {
it("dates the oldest stale ticker among jobs expected to fire, and stays quiet otherwise (#114309)", () => {
expect(
cronSchedulerStaleAgeS([
{ scheduler_heartbeat_age_s: 25 * 3600, enabled: true, state: "scheduled" },
{ scheduler_heartbeat_age_s: 7 * 3600, enabled: true },
{ scheduler_heartbeat_age_s: 90 * 3600, enabled: true, state: "paused" },
]),
).toBe(25 * 3600);
expect(cronSchedulerStaleAgeS([{ scheduler_heartbeat_age_s: 90, enabled: true }])).toBeNull();
expect(cronSchedulerStaleAgeS([{ scheduler_heartbeat_age_s: null, enabled: true }])).toBeNull();
expect(cronSchedulerStaleAgeS([{ scheduler_heartbeat_age_s: 25 * 3600, enabled: false }])).toBeNull();
expect(cronSchedulerStaleAgeS([])).toBeNull();
expect([7 * 3600 + 120, 90, 3 * 86400].map(cronAgoLabel)).toEqual(["7h ago", "1m ago", "3d ago"]);
});
});

View File

@@ -148,3 +148,51 @@ export function cronLastResult(
: asString(job.last_error).trim() || asString(job.last_delivery_error).trim();
return { status, tone, detail: detail || null };
}
/** Mirrors hermes_cli/cron.py `_OVERDUE_GRACE_SECONDS`: a busy tick can run a few minutes late. */
export const CRON_NEXT_RUN_OVERDUE_GRACE_MS = 15 * 60 * 1000;
/**
* Milliseconds a job's stored `next_run_at` has sat in the past beyond the doctor grace, or
* null when the job is upcoming, within grace, not expected to fire, or has no parseable stamp.
* A stamp parked in the past is the only user-visible trace of a dead scheduler (#114309), so
* the dashboard must never present it as an upcoming "Next".
*/
export function cronNextRunOverdueMs(
job: Pick<CronJob, "next_run_at" | "enabled" | "state">,
nowMs: number = Date.now(),
): number | null {
if (job.enabled === false || job.state === "paused" || job.state === "completed") return null;
const at = Date.parse(asString(job.next_run_at));
if (Number.isNaN(at)) return null;
const overdue = nowMs - at;
return overdue > CRON_NEXT_RUN_OVERDUE_GRACE_MS ? overdue : null;
}
/** Mirrors hermes_cli/cron.py `STALE_AFTER`: ~3 missed ticker iterations (60s) plus slack. */
export const CRON_SCHEDULER_STALE_S = 60 * 3 + 20;
/**
* Seconds since the scheduler last ticked when that is long enough ago to strand jobs, or null
* while it ticks on time, when no listed job is expected to fire, or when the server could not
* date the last tick. Jobs from several profiles report their own ticker; the oldest wins.
*/
export function cronSchedulerStaleAgeS(
jobs: Pick<CronJob, "scheduler_heartbeat_age_s" | "enabled" | "state">[],
): number | null {
let stale: number | null = null;
for (const job of jobs) {
const age = job.scheduler_heartbeat_age_s;
if (typeof age !== "number" || age <= CRON_SCHEDULER_STALE_S) continue;
if (job.enabled === false || job.state === "paused" || job.state === "completed") continue;
if (stale === null || age > stale) stale = age;
}
return stale;
}
/** "7h ago"-style label for an elapsed duration in seconds (no clock read, so it renders pure). */
export function cronAgoLabel(seconds: number): string {
if (seconds < 3600) return `${Math.max(1, Math.floor(seconds / 60))}m ago`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
return `${Math.floor(seconds / 86400)}d ago`;
}

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import {
initialProfileScope,
shouldAdoptActiveProfile,
} from "./profile-bootstrap";
describe("initialProfileScope", () => {
it("inherits the dashboard bootstrap profile when the URL omits profile", () => {
expect(initialProfileScope(new URLSearchParams("resume=session-1"), "worker_x"))
.toBe("worker_x");
});
it("keeps an explicit URL profile authoritative", () => {
expect(
initialProfileScope(
new URLSearchParams("resume=session-1&profile=review"),
"worker_x",
),
).toBe("review");
});
it("preserves an explicit empty profile", () => {
expect(
initialProfileScope(new URLSearchParams("profile="), "worker_x"),
).toBe("");
});
it("does not replace a launch profile with the sticky active profile", () => {
expect(
shouldAdoptActiveProfile(null, "worker_x", "default", "review"),
).toBe(false);
});
it("uses the sticky active profile without a URL or launch profile", () => {
expect(
shouldAdoptActiveProfile(null, "", "default", "review"),
).toBe(true);
});
});

View File

@@ -0,0 +1,31 @@
declare global {
interface Window {
__HERMES_INITIAL_PROFILE__?: string;
}
}
export function dashboardInitialProfile(): string {
if (typeof window === "undefined") return "";
return window.__HERMES_INITIAL_PROFILE__ ?? "";
}
export function initialProfileScope(
searchParams: URLSearchParams,
bootstrapProfile = dashboardInitialProfile(),
): string {
const urlProfile = searchParams.get("profile");
return urlProfile === null ? bootstrapProfile : urlProfile;
}
export function shouldAdoptActiveProfile(
urlProfile: string | null,
bootstrapProfile: string,
currentProfile: string,
activeProfile: string,
): boolean {
return (
urlProfile === null &&
!bootstrapProfile &&
activeProfile !== currentProfile
);
}

View File

@@ -21,6 +21,9 @@ import type {
import {
buildCronJobPayload,
cronJobHasExecutionContent,
cronAgoLabel,
cronNextRunOverdueMs,
cronSchedulerStaleAgeS,
cronJobFormFromJob,
cronLastResult,
focusCronField,
@@ -530,6 +533,7 @@ const STATUS_TONE: Record<string, "success" | "warning" | "destructive"> = {
export default function CronPage() {
const [jobs, setJobs] = useState<CronJob[]>([]);
const schedulerStaleAgeS = cronSchedulerStaleAgeS(jobs);
const [triggeringJobKeys, setTriggeringJobKeys] = useState<ReadonlySet<string>>(
() => new Set(),
);
@@ -890,6 +894,15 @@ export default function CronPage() {
/>
)}
{schedulerStaleAgeS !== null && (
<p className="text-sm text-warning font-medium" data-testid="cron-scheduler-stale">
{(t.cron.schedulerLastTicked ?? en.cron.schedulerLastTicked!).replace(
"{when}",
cronAgoLabel(schedulerStaleAgeS),
)}
</p>
)}
<Segmented
value={view}
onChange={(v) => setView(v as "jobs" | "blueprints")}
@@ -1181,9 +1194,18 @@ export default function CronPage() {
<span>
{t.cron.last}: {formatTime(job.last_run_at)}
</span>
<span>
{t.cron.next}: {formatTime(job.next_run_at)}
</span>
{cronNextRunOverdueMs(job) === null ? (
<span>
{t.cron.next}: {formatTime(job.next_run_at)}
</span>
) : (
<span
className="text-warning font-medium"
data-testid="cron-next-run-overdue"
>
{t.cron.overdueSince ?? en.cron.overdueSince!}: {formatTime(job.next_run_at)}
</span>
)}
</div>
{job.last_delivery_error && (
<p className="text-xs text-destructive mt-1">

View File

@@ -26,13 +26,15 @@ const BACKEND = process.env.HERMES_DASHBOARD_URL ?? "http://127.0.0.1:9119";
* token, every protected `/api/*` call 401s.
*
* This plugin fetches the running dashboard's `index.html` on each dev page
* load, scrapes the `window.__HERMES_SESSION_TOKEN__` assignment, and
* re-injects it into the dev HTML. No-op in production builds.
* load and forwards its runtime bootstrap values into the dev HTML. No-op in
* production builds.
*/
function hermesDevToken(): Plugin {
const TOKEN_RE = /window\.__HERMES_SESSION_TOKEN__\s*=\s*"([^"]+)"/;
const EMBEDDED_RE =
/window\.__HERMES_DASHBOARD_EMBEDDED_CHAT__\s*=\s*(true|false)/;
const INITIAL_PROFILE_RE =
/window\.__HERMES_INITIAL_PROFILE__\s*=\s*("(?:\\.|[^"\\])*")/;
return {
name: "hermes:dev-session-token",
@@ -51,13 +53,16 @@ function hermesDevToken(): Plugin {
}
const embeddedMatch = html.match(EMBEDDED_RE);
const embeddedJs = embeddedMatch ? embeddedMatch[1] : "true";
const initialProfileMatch = html.match(INITIAL_PROFILE_RE);
const initialProfileJs = initialProfileMatch?.[1] ?? '""';
return [
{
tag: "script",
injectTo: "head",
children:
`window.__HERMES_SESSION_TOKEN__="${match[1]}";` +
`window.__HERMES_DASHBOARD_EMBEDDED_CHAT__=${embeddedJs};`,
`window.__HERMES_DASHBOARD_EMBEDDED_CHAT__=${embeddedJs};` +
`window.__HERMES_INITIAL_PROFILE__=${initialProfileJs};`,
},
];
} catch (err) {