mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 10:12:44 +00:00
fix(heartbeats): refresh mounted schedule state
Poll the shared schedules query while mounted and use each successful refresh timestamp when deriving heartbeat activity. This keeps externally created and expired heartbeats in sync without introducing a heartbeat-local timer.\n\nExtend the real-daemon browser flow to create heartbeats after the composer mounts and verify expiry removes the track and workspace activity.
This commit is contained in:
@@ -17,7 +17,7 @@ import {
|
||||
test("an agent heartbeat stays visible and keeps its workspace running", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
test.setTimeout(150_000);
|
||||
const scenario = await seedAgentWithHeartbeat();
|
||||
|
||||
try {
|
||||
@@ -25,27 +25,44 @@ test("an agent heartbeat stays visible and keeps its workspace running", async (
|
||||
workspaceId: scenario.workspace.workspaceId,
|
||||
agentId: scenario.parent.id,
|
||||
});
|
||||
await expectWorkspaceDone(page);
|
||||
const heartbeat = await scenario.createHeartbeat();
|
||||
await expectWorkspaceRunning(page);
|
||||
await openHeartbeatAndSubagentTracks(page);
|
||||
await expectHeartbeatVisible(page, scenario.heartbeat);
|
||||
await expectHeartbeatVisible(page, heartbeat);
|
||||
await captureHeartbeatEvidence(page, testInfo, "heartbeat-stacked-tracks");
|
||||
|
||||
await openHeartbeat(page, scenario.heartbeat);
|
||||
await expectHeartbeatDetails(page, scenario.heartbeat);
|
||||
await openHeartbeat(page, heartbeat);
|
||||
await expectHeartbeatDetails(page, heartbeat);
|
||||
await captureHeartbeatEvidence(page, testInfo, "heartbeat-schedule-details");
|
||||
|
||||
await removeSeededSubagent(scenario);
|
||||
await returnToAgentHeartbeat(page);
|
||||
await expectWorkspaceRunning(page);
|
||||
await page.getByRole("button", { name: "1 heartbeat" }).click();
|
||||
await deleteHeartbeat(page, scenario.heartbeat);
|
||||
await expect.poll(scenario.readHeartbeatExists, { timeout: 30_000 }).toBe(false);
|
||||
await deleteHeartbeat(page, heartbeat);
|
||||
await expect
|
||||
.poll(() => scenario.readHeartbeatExists(heartbeat.id), { timeout: 30_000 })
|
||||
.toBe(false);
|
||||
await expect(scenario.readAgentStatuses()).resolves.toMatchObject({
|
||||
[scenario.parent.id]: "idle",
|
||||
});
|
||||
await expect.poll(scenario.readWorkspaceStatus, { timeout: 30_000 }).toBe("done");
|
||||
await expectWorkspaceDone(page);
|
||||
await captureHeartbeatEvidence(page, testInfo, "heartbeat-removed-workspace-done");
|
||||
|
||||
const expiringHeartbeat = await scenario.createHeartbeat({
|
||||
name: "Temporary watch",
|
||||
expiresAt: new Date(Date.now() + 20_000).toISOString(),
|
||||
});
|
||||
await expectWorkspaceRunning(page);
|
||||
await page.getByRole("button", { name: "1 heartbeat" }).click();
|
||||
await expectHeartbeatVisible(page, expiringHeartbeat);
|
||||
await expect(page.getByRole("button", { name: "1 heartbeat" })).toHaveCount(0, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await expect.poll(scenario.readWorkspaceStatus, { timeout: 30_000 }).toBe("done");
|
||||
await expectWorkspaceDone(page);
|
||||
} finally {
|
||||
await scenario.cleanup().catch(() => undefined);
|
||||
}
|
||||
|
||||
@@ -10,18 +10,25 @@ interface HeartbeatScheduleClient {
|
||||
cadence: { type: "every"; everyMs: number };
|
||||
target: { type: "agent"; agentId: string };
|
||||
runOnCreate: false;
|
||||
expiresAt?: string;
|
||||
}): Promise<{ schedule: ScheduleSummary | null; error: string | null }>;
|
||||
scheduleDelete(input: { id: string }): Promise<{ error: string | null }>;
|
||||
scheduleList(): Promise<{ schedules: ScheduleSummary[]; error: string | null }>;
|
||||
}
|
||||
|
||||
export interface SeededHeartbeat {
|
||||
id: string;
|
||||
name: string;
|
||||
cadence: string;
|
||||
}
|
||||
|
||||
export interface AgentHeartbeatScenario {
|
||||
workspace: SeededWorkspace;
|
||||
parent: { id: string; title: string };
|
||||
child: { id: string; title: string };
|
||||
heartbeat: { id: string; name: string; cadence: string };
|
||||
createHeartbeat(input?: { name?: string; expiresAt?: string }): Promise<SeededHeartbeat>;
|
||||
readWorkspaceStatus(): Promise<string | null>;
|
||||
readHeartbeatExists(): Promise<boolean>;
|
||||
readHeartbeatExists(id: string): Promise<boolean>;
|
||||
readAgentStatuses(): Promise<Record<string, string>>;
|
||||
cleanup(): Promise<void>;
|
||||
}
|
||||
@@ -47,22 +54,27 @@ export async function seedAgentWithHeartbeat(): Promise<AgentHeartbeatScenario>
|
||||
(snapshot) => snapshot.status === "idle",
|
||||
30_000,
|
||||
);
|
||||
const created = await scheduleClient.scheduleCreate({
|
||||
prompt: "Check the build and report any failures.",
|
||||
name: "Watch the build",
|
||||
cadence: { type: "every", everyMs: 15 * 60_000 },
|
||||
target: { type: "agent", agentId: agents.parent.id },
|
||||
runOnCreate: false,
|
||||
});
|
||||
if (!created.schedule) {
|
||||
throw new Error(created.error ?? "Failed to create heartbeat");
|
||||
}
|
||||
const heartbeatId = created.schedule.id;
|
||||
const heartbeatIds = new Set<string>();
|
||||
return {
|
||||
workspace,
|
||||
parent: agents.parent,
|
||||
child: agents.child,
|
||||
heartbeat: { id: heartbeatId, name: "Watch the build", cadence: "Every 15 minutes" },
|
||||
createHeartbeat: async (input = {}) => {
|
||||
const name = input.name ?? "Watch the build";
|
||||
const created = await scheduleClient.scheduleCreate({
|
||||
prompt: "Check the build and report any failures.",
|
||||
name,
|
||||
cadence: { type: "every", everyMs: 15 * 60_000 },
|
||||
target: { type: "agent", agentId: agents.parent.id },
|
||||
runOnCreate: false,
|
||||
...(input.expiresAt ? { expiresAt: input.expiresAt } : {}),
|
||||
});
|
||||
if (!created.schedule) {
|
||||
throw new Error(created.error ?? "Failed to create heartbeat");
|
||||
}
|
||||
heartbeatIds.add(created.schedule.id);
|
||||
return { id: created.schedule.id, name, cadence: "Every 15 minutes" };
|
||||
},
|
||||
readWorkspaceStatus: async () => {
|
||||
const result = await workspace.client.fetchWorkspaces();
|
||||
return (
|
||||
@@ -73,16 +85,20 @@ export async function seedAgentWithHeartbeat(): Promise<AgentHeartbeatScenario>
|
||||
)?.status ?? null
|
||||
);
|
||||
},
|
||||
readHeartbeatExists: async () => {
|
||||
readHeartbeatExists: async (id) => {
|
||||
const result = await scheduleClient.scheduleList();
|
||||
return result.schedules.some((schedule) => schedule.id === heartbeatId);
|
||||
return result.schedules.some((schedule) => schedule.id === id);
|
||||
},
|
||||
readAgentStatuses: async () => {
|
||||
const result = await workspace.client.fetchAgents({ scope: "active" });
|
||||
return Object.fromEntries(result.entries.map(({ agent }) => [agent.id, agent.status]));
|
||||
},
|
||||
cleanup: async () => {
|
||||
await scheduleClient.scheduleDelete({ id: heartbeatId }).catch(() => undefined);
|
||||
await Promise.all(
|
||||
Array.from(heartbeatIds, (id) =>
|
||||
scheduleClient.scheduleDelete({ id }).catch(() => undefined),
|
||||
),
|
||||
);
|
||||
await workspace.cleanup();
|
||||
},
|
||||
};
|
||||
@@ -115,23 +131,20 @@ export async function openHeartbeatAndSubagentTracks(page: Page): Promise<void>
|
||||
|
||||
export async function expectHeartbeatVisible(
|
||||
page: Page,
|
||||
heartbeat: AgentHeartbeatScenario["heartbeat"],
|
||||
heartbeat: SeededHeartbeat,
|
||||
): Promise<void> {
|
||||
await expect(
|
||||
page.getByRole("link", { name: `${heartbeat.name}, ${heartbeat.cadence}` }),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
|
||||
export async function openHeartbeat(
|
||||
page: Page,
|
||||
heartbeat: AgentHeartbeatScenario["heartbeat"],
|
||||
): Promise<void> {
|
||||
export async function openHeartbeat(page: Page, heartbeat: SeededHeartbeat): Promise<void> {
|
||||
await page.getByRole("link", { name: `${heartbeat.name}, ${heartbeat.cadence}` }).click();
|
||||
}
|
||||
|
||||
export async function expectHeartbeatDetails(
|
||||
page: Page,
|
||||
heartbeat: AgentHeartbeatScenario["heartbeat"],
|
||||
heartbeat: SeededHeartbeat,
|
||||
): Promise<void> {
|
||||
const sheet = page.getByTestId("schedule-form-sheet");
|
||||
await expect(sheet).toBeVisible({ timeout: 30_000 });
|
||||
@@ -151,10 +164,7 @@ export async function removeSeededSubagent(scenario: AgentHeartbeatScenario): Pr
|
||||
await scenario.workspace.client.archiveAgent(scenario.child.id);
|
||||
}
|
||||
|
||||
export async function deleteHeartbeat(
|
||||
page: Page,
|
||||
heartbeat: AgentHeartbeatScenario["heartbeat"],
|
||||
): Promise<void> {
|
||||
export async function deleteHeartbeat(page: Page, heartbeat: SeededHeartbeat): Promise<void> {
|
||||
const row = page.getByRole("link", { name: `${heartbeat.name}, ${heartbeat.cadence}` });
|
||||
page.once("dialog", (dialog) => void dialog.accept());
|
||||
await page.getByRole("button", { name: `Delete heartbeat ${heartbeat.name}` }).click();
|
||||
|
||||
@@ -15,11 +15,16 @@ export function useAgentHeartbeats(target: {
|
||||
agentId: string;
|
||||
}): AgentHeartbeatRow[] {
|
||||
const { serverId, agentId } = target;
|
||||
const { loadState } = useSchedules();
|
||||
const { loadState, dataUpdatedAt } = useSchedules();
|
||||
const schedules = loadState.status === "loaded" ? loadState.data : EMPTY_SCHEDULES;
|
||||
|
||||
return useMemo(
|
||||
() => selectAgentHeartbeats({ schedules, target: { serverId, agentId }, now: Date.now() }),
|
||||
[schedules, serverId, agentId],
|
||||
() =>
|
||||
selectAgentHeartbeats({
|
||||
schedules,
|
||||
target: { serverId, agentId },
|
||||
now: dataUpdatedAt || Date.now(),
|
||||
}),
|
||||
[schedules, serverId, agentId, dataUpdatedAt],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,8 +24,11 @@ export function schedulesQueryKey(serverIds: readonly string[]) {
|
||||
return [...schedulesQueryBaseKey, [...serverIds].sort().join("|")] as const;
|
||||
}
|
||||
|
||||
const SCHEDULE_REFRESH_INTERVAL_MS = 5_000;
|
||||
|
||||
export interface UseSchedulesResult {
|
||||
loadState: AggregateLoadState<AggregatedSchedule>;
|
||||
dataUpdatedAt: number;
|
||||
hostErrors: ScheduleHostError[];
|
||||
isError: boolean;
|
||||
error: Error | null;
|
||||
@@ -51,6 +54,7 @@ export function useSchedules(): UseSchedulesResult {
|
||||
queryKey: [...schedulesQueryKey(serverIds), connectionStatusKey],
|
||||
queryFn: () => fetchAggregatedSchedules({ hosts: hostInputs, runtime }),
|
||||
dataShape: "list",
|
||||
refetchInterval: SCHEDULE_REFRESH_INTERVAL_MS,
|
||||
staleTimeMs: 5_000,
|
||||
});
|
||||
|
||||
@@ -65,6 +69,7 @@ export function useSchedules(): UseSchedulesResult {
|
||||
|
||||
return {
|
||||
loadState,
|
||||
dataUpdatedAt: query.dataUpdatedAt,
|
||||
hostErrors: query.data?.status === "loaded" ? query.data.hostErrors : [],
|
||||
isError: query.isError,
|
||||
error: query.error,
|
||||
|
||||
Reference in New Issue
Block a user