mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-10 18:56:53 +00:00
fix: bind deploy progress, request, and health gate to the captured node (#1357)
* fix: bind deploy progress, request, and health gate to the captured node A deploy/update/install/git-apply re-read the active node from localStorage independently at three points: the progress WebSocket at mount, the POST at call time, and the health-gate poll. If the active node changed between the click and any of those, the operation, its live output, and its health verdict could target different nodes, and the socket and POST splitting across nodes broke output streaming. Capture the operation's node once when it starts and thread it through every leg. A new nodeId option on apiFetch overrides the active-node read, the progress terminal takes a nodeId prop for its socket URL, the health gate polls on the captured node, and a failed gate records its recovery entry only on the node it ran on. The surface, the request, and the gate now always agree. * fix: scope failed-gate recovery to the file list's node and harden node targeting Addresses review findings on the captured-node binding: - Track the node the stack file list was fetched for (filesNodeId) and record a failed gate's recovery entry only when it matches the gate's node. This closes a race where switching back to the gate's node could match a same-named stack from the previous node's still-loaded list before the new list lands, keying the record to the wrong file and blocking the correct one. refreshStacks now carries a sequence token so an out-of-order resolution cannot leave files and filesNodeId inconsistent. - Make an explicit apiFetch nodeId authoritative over a caller-supplied x-node-id header. - Add the missing stack-logs nodeId cases (null, and active-node fallback) to the terminal tests.
This commit is contained in:
@@ -63,6 +63,10 @@ interface RunResult {
|
||||
/** Post-update health gate state for the current deploy session. */
|
||||
export interface HealthGateUiState {
|
||||
stackName: string;
|
||||
/** Node the gate runs on (null = local), captured from the operation. Polled
|
||||
* on this node and matched against the active node before its failure routes
|
||||
* into recovery, so a same-named stack on another node never inherits it. */
|
||||
nodeId: number | null;
|
||||
gateId: string;
|
||||
trigger: 'update' | 'deploy';
|
||||
status: 'observing' | 'passed' | 'failed' | 'unknown';
|
||||
@@ -257,9 +261,9 @@ export function DeployFeedbackProvider({ children }: { children: React.ReactNode
|
||||
// instead of this session showing a newer run's result. Mirrors the backend
|
||||
// gate's own degradation: repeated failures (or an absurdly long poll)
|
||||
// resolve to an honest client-side unknown rather than observing forever.
|
||||
const startGatePolling = useCallback((stackName: string, gateId: string, trigger: 'update' | 'deploy', mySession: number) => {
|
||||
const startGatePolling = useCallback((stackName: string, nodeId: number | null, gateId: string, trigger: 'update' | 'deploy', mySession: number) => {
|
||||
stopGatePolling();
|
||||
setHealthGate({ stackName, gateId, trigger, status: 'observing', reason: null, windowSeconds: null, startedAt: null });
|
||||
setHealthGate({ stackName, nodeId, gateId, trigger, status: 'observing', reason: null, windowSeconds: null, startedAt: null });
|
||||
let strikes = 0;
|
||||
// Single-flight: skip a tick while one request is outstanding so two
|
||||
// overlapping responses cannot land out of order.
|
||||
@@ -286,7 +290,7 @@ export function DeployFeedbackProvider({ children }: { children: React.ReactNode
|
||||
}
|
||||
inFlight = true;
|
||||
try {
|
||||
const res = await apiFetch(`/stacks/${stackName}/health-gate?gateId=${encodeURIComponent(gateId)}`);
|
||||
const res = await apiFetch(`/stacks/${stackName}/health-gate?gateId=${encodeURIComponent(gateId)}`, { nodeId });
|
||||
const report = res.ok
|
||||
? await res.json() as {
|
||||
id: string | null;
|
||||
@@ -306,7 +310,7 @@ export function DeployFeedbackProvider({ children }: { children: React.ReactNode
|
||||
return;
|
||||
}
|
||||
strikes = 0;
|
||||
setHealthGate({ stackName, gateId, trigger, status: report.status, reason: report.reason, windowSeconds: report.windowSeconds, startedAt: report.startedAt });
|
||||
setHealthGate({ stackName, nodeId, gateId, trigger, status: report.status, reason: report.reason, windowSeconds: report.windowSeconds, startedAt: report.startedAt });
|
||||
if (report.status !== 'observing') {
|
||||
settled = true;
|
||||
stopGatePolling();
|
||||
@@ -414,7 +418,7 @@ export function DeployFeedbackProvider({ children }: { children: React.ReactNode
|
||||
errorMessage: result.ok ? undefined : result.errorMessage,
|
||||
}));
|
||||
if (result.ok && result.healthGateId && (params.action === 'update' || params.action === 'deploy')) {
|
||||
startGatePolling(params.stackName, result.healthGateId, params.action, mySession);
|
||||
startGatePolling(params.stackName, params.nodeId, result.healthGateId, params.action, mySession);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,14 @@ import { DeployFeedbackProvider, useDeployFeedback } from '../DeployFeedbackCont
|
||||
import { DEPLOY_FEEDBACK_KEY } from '@/hooks/use-deploy-feedback-enabled';
|
||||
import { DEPLOY_FEEDBACK_STYLE_KEY } from '@/hooks/use-deploy-feedback-style';
|
||||
|
||||
// Only the health-gate poll touches the network; mock apiFetch so a poll can be
|
||||
// asserted to target the captured node. Other exports stay real.
|
||||
vi.mock('@/lib/api', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/api')>();
|
||||
return { ...actual, apiFetch: vi.fn() };
|
||||
});
|
||||
import { apiFetch } from '@/lib/api';
|
||||
|
||||
function wrapper({ children }: { children: ReactNode }) {
|
||||
return <DeployFeedbackProvider>{children}</DeployFeedbackProvider>;
|
||||
}
|
||||
@@ -12,6 +20,7 @@ function wrapper({ children }: { children: ReactNode }) {
|
||||
describe('DeployFeedbackContext', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.setItem(DEPLOY_FEEDBACK_KEY, 'true');
|
||||
vi.mocked(apiFetch).mockReset();
|
||||
});
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
@@ -168,6 +177,38 @@ describe('DeployFeedbackContext', () => {
|
||||
await act(async () => { result.current.onTerminalReady(); await outer; });
|
||||
});
|
||||
|
||||
it('polls the health gate on the captured node and stamps it on the gate state', async () => {
|
||||
vi.mocked(apiFetch).mockResolvedValue(new Response(
|
||||
JSON.stringify({ id: 'g1', status: 'observing', reason: null, windowSeconds: 90, startedAt: Date.now() }),
|
||||
{ status: 200 },
|
||||
));
|
||||
const { result } = renderHook(() => useDeployFeedback(), { wrapper });
|
||||
|
||||
let outer: Promise<unknown> | undefined;
|
||||
await act(async () => {
|
||||
outer = result.current.runWithLog({ stackName: 'web', action: 'update', nodeId: 7 }, async (started) => {
|
||||
await started;
|
||||
return { ok: true, healthGateId: 'g1' };
|
||||
});
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
result.current.onTerminalReady();
|
||||
await outer;
|
||||
// Let the immediate gate tick's apiFetch resolve and its setHealthGate land.
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const gateCall = vi.mocked(apiFetch).mock.calls.find((c) => String(c[0]).includes('/health-gate'));
|
||||
expect(gateCall).toBeDefined();
|
||||
expect(gateCall?.[1]).toEqual(expect.objectContaining({ nodeId: 7 }));
|
||||
expect(result.current.healthGate?.nodeId).toBe(7);
|
||||
|
||||
act(() => { result.current.onPanelClose(); });
|
||||
});
|
||||
|
||||
it('minimized is false before any session', () => {
|
||||
const { result } = renderHook(() => useDeployFeedback(), { wrapper });
|
||||
expect(result.current.minimized).toBe(false);
|
||||
|
||||
Reference in New Issue
Block a user