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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -322,6 +322,8 @@ export interface Translations {
|
||||
noJobs: string;
|
||||
last: string;
|
||||
next: string;
|
||||
overdueSince?: string;
|
||||
schedulerLastTicked?: string;
|
||||
pause: string;
|
||||
resume: string;
|
||||
triggerNow: string;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
40
web/src/lib/profile-bootstrap.test.ts
Normal file
40
web/src/lib/profile-bootstrap.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
31
web/src/lib/profile-bootstrap.ts
Normal file
31
web/src/lib/profile-bootstrap.ts
Normal 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
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user