mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-01 22:52:21 +00:00
48cebf9501
* 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.
136 lines
5.1 KiB
TypeScript
136 lines
5.1 KiB
TypeScript
const API_BASE = '/api';
|
|
|
|
export interface ApiFetchOptions extends RequestInit {
|
|
/** When true, omits the x-node-id header so the request always targets
|
|
* the local node regardless of which node is currently active in the UI. */
|
|
localOnly?: boolean;
|
|
/** Explicit node target, overriding the active-node read. A number targets
|
|
* that node; null omits x-node-id so the request hits the local node. Leave
|
|
* undefined for the default (read the active node from localStorage). Lets an
|
|
* operation stay bound to the node captured when it started, even if the
|
|
* active node changes mid-flight. Takes precedence over localOnly when both
|
|
* are set. */
|
|
nodeId?: number | null;
|
|
}
|
|
|
|
/** Header carrying a deploy's progress-stream correlation id. Mirrors the
|
|
* `sessionId` the frontend sends in the `connectTerminal` WebSocket message so
|
|
* the backend streams compose output to the matching socket.
|
|
* Must stay in sync with `DEPLOY_SESSION_HEADER` in `backend/src/websocket/generic.ts`. */
|
|
export const DEPLOY_SESSION_HEADER = 'x-deploy-session-id';
|
|
|
|
/** Tag a deploy/update/down POST with its progress-stream session id, preserving
|
|
* any caller-supplied options and headers. */
|
|
export function withDeploySession(
|
|
deploySessionId: string,
|
|
options: ApiFetchOptions = {},
|
|
): ApiFetchOptions {
|
|
return {
|
|
...options,
|
|
headers: {
|
|
...(options.headers as Record<string, string> | undefined),
|
|
[DEPLOY_SESSION_HEADER]: deploySessionId,
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function apiFetch(
|
|
endpoint: string,
|
|
options: ApiFetchOptions = {}
|
|
): Promise<Response> {
|
|
const { localOnly, nodeId: nodeIdOverride, ...fetchOptions } = options;
|
|
const url = `${API_BASE}${endpoint}`;
|
|
// An explicit nodeId (including null) wins over the active-node read so a
|
|
// captured operation node stays authoritative; null means target the local
|
|
// node, not the stored active node.
|
|
let activeNodeId: string | null;
|
|
if (nodeIdOverride !== undefined) {
|
|
activeNodeId = nodeIdOverride === null ? null : String(nodeIdOverride);
|
|
} else if (localOnly) {
|
|
activeNodeId = null;
|
|
} else {
|
|
activeNodeId = localStorage.getItem('sencho-active-node');
|
|
}
|
|
|
|
const headers: Record<string, string> = {
|
|
'Content-Type': 'application/json',
|
|
...(activeNodeId ? { 'x-node-id': activeNodeId } : {}),
|
|
...(fetchOptions.headers as Record<string, string> | undefined),
|
|
};
|
|
// An explicit nodeId is authoritative: a caller-supplied x-node-id header must
|
|
// not silently override the captured operation node (it would for the default
|
|
// active-node read, which is the pre-existing behavior left unchanged).
|
|
if (nodeIdOverride !== undefined) {
|
|
if (activeNodeId) headers['x-node-id'] = activeNodeId;
|
|
else delete headers['x-node-id'];
|
|
}
|
|
const defaultOptions: RequestInit = {
|
|
credentials: 'include',
|
|
headers,
|
|
};
|
|
|
|
// Drop headers from fetchOptions before the outer spread so the merged
|
|
// defaultOptions.headers (with Content-Type and x-node-id) survives. Without
|
|
// this, any caller that passes a `headers` field clobbers the defaults.
|
|
const { headers: _callerHeaders, ...fetchOptionsWithoutHeaders } = fetchOptions;
|
|
void _callerHeaders;
|
|
const response = await fetch(url, { ...defaultOptions, ...fetchOptionsWithoutHeaders });
|
|
|
|
if (response.status === 401) {
|
|
// Only fire the global logout event for local auth failures.
|
|
// When the response carries x-sencho-proxy, the 401 came from a remote
|
|
// Sencho node (expired/invalid api_token) - not from the user's own session.
|
|
// Logging out in that case creates an unrecoverable loop.
|
|
if (!response.headers.get('x-sencho-proxy')) {
|
|
window.dispatchEvent(new Event('sencho-unauthorized'));
|
|
}
|
|
throw new Error('Unauthorized');
|
|
}
|
|
|
|
// Intercept 404 Node Not Found responses and force context refresh
|
|
if (response.status === 404) {
|
|
try {
|
|
const clone = response.clone();
|
|
const errData = await clone.json();
|
|
if (errData.error && errData.error.includes('not found') && errData.error.includes('Node')) {
|
|
window.dispatchEvent(new Event('node-not-found'));
|
|
}
|
|
} catch {
|
|
// Ignore JSON parse errors, caller handles standard 404s
|
|
}
|
|
}
|
|
|
|
return response;
|
|
}
|
|
|
|
/** Fetch against a specific node by ID without touching the localStorage active-node key.
|
|
* Used by the notification panel to target individual remote nodes explicitly. */
|
|
export async function fetchForNode(
|
|
endpoint: string,
|
|
nodeId: number,
|
|
options: RequestInit = {}
|
|
): Promise<Response> {
|
|
const { headers: extraHeaders, ...rest } = options;
|
|
const response = await fetch(`${API_BASE}${endpoint}`, {
|
|
credentials: 'include',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'x-node-id': String(nodeId),
|
|
...(extraHeaders as Record<string, string> | undefined),
|
|
},
|
|
...rest,
|
|
});
|
|
|
|
if (response.status === 401) {
|
|
// Same logic as apiFetch: only log out for local auth failures.
|
|
if (!response.headers.get('x-sencho-proxy')) {
|
|
window.dispatchEvent(new Event('sencho-unauthorized'));
|
|
}
|
|
throw new Error('Unauthorized');
|
|
}
|
|
|
|
return response;
|
|
}
|
|
|
|
export { API_BASE };
|