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:
Anso
2026-06-11 14:36:20 -04:00
committed by GitHub
parent a2fe58f62f
commit 48cebf9501
21 changed files with 617 additions and 51 deletions
@@ -5,14 +5,36 @@
* treating the sentinel as a configured source.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
// Mutable controls so a deploy-mode test can set the active node and capture the
// runWithLog params, while the load tests keep the default (no active node).
const nodeCtl = vi.hoisted(() => ({ activeNode: null as { id: number; type?: string } | null }));
const dfCtl = vi.hoisted(() => ({ params: null as null | { stackName: string; action: string; nodeId: number | null } }));
vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() }));
vi.mock('@/context/DeployFeedbackContext', () => ({
useDeployFeedback: () => ({ runWithLog: vi.fn() }),
useDeployFeedback: () => ({
runWithLog: vi.fn(
async (
params: { stackName: string; action: string; nodeId: number | null },
run: (started: Promise<void>) => Promise<{ ok: boolean }>,
) => {
dfCtl.params = params;
return run(Promise.resolve());
},
),
}),
}));
vi.mock('@/context/NodeContext', () => ({
useNodes: () => ({ activeNode: null }),
useNodes: () => ({ activeNode: nodeCtl.activeNode }),
}));
// Drive applyPull(commitSha, deploy=true) directly without standing up the real
// diff UI; the panel passes applyPull as onApply.
vi.mock('./GitSourceDiffDialog', () => ({
GitSourceDiffDialog: ({ onApply }: { onApply: (sha: string, deploy: boolean) => void }) => (
<button data-testid="apply-deploy" onClick={() => onApply('sha-123', true)}>apply</button>
),
}));
vi.mock('@/components/ui/toast-store', () => ({
toast: {
@@ -64,6 +86,8 @@ function panel() {
beforeEach(() => {
vi.mocked(apiFetch).mockReset();
nodeCtl.activeNode = null;
dfCtl.params = null;
});
describe('GitSourcePanel load', () => {
@@ -95,3 +119,21 @@ describe('GitSourcePanel load', () => {
);
});
});
describe('GitSourcePanel deploy-mode apply node binding', () => {
beforeEach(() => {
nodeCtl.activeNode = { id: 4, type: 'local' };
vi.mocked(apiFetch).mockResolvedValue(jsonRes({ applied: true, deployed: true }));
});
it('binds both runWithLog and the apply POST to the captured node when deploying', async () => {
render(panel());
fireEvent.click(await screen.findByTestId('apply-deploy'));
await waitFor(() => {
const applyCall = vi.mocked(apiFetch).mock.calls.find(c => String(c[0]).includes('/git-source/apply'));
expect(applyCall?.[1]).toEqual(expect.objectContaining({ nodeId: 4 }));
});
expect(dfCtl.params).toEqual(expect.objectContaining({ action: 'deploy', nodeId: 4 }));
});
});
@@ -230,11 +230,15 @@ export function GitSourcePanel({
const applyPull = async (commitSha: string, deploy: boolean) => {
setApplying(true);
const loadingId = toast.loading(deploy ? 'Applying and deploying...' : 'Applying changes...');
// Snapshot the node once so the apply (and any deploy it triggers) stays
// bound to it even if the active node changes while the operation runs.
const opNodeId = activeNode?.id ?? null;
try {
const runApply = async (started: Promise<void>) => {
if (deploy) await started;
const res = await apiFetch(`/stacks/${encodeURIComponent(stackName)}/git-source/apply`, {
method: 'POST',
nodeId: opNodeId,
body: JSON.stringify({ commitSha, deploy }),
});
if (res.ok) {
@@ -260,7 +264,7 @@ export function GitSourcePanel({
};
if (deploy) {
await runWithLog({ stackName, action: 'deploy', nodeId: activeNode?.id ?? null }, runApply);
await runWithLog({ stackName, action: 'deploy', nodeId: opNodeId }, runApply);
} else {
await runApply(Promise.resolve());
}