feat: detect stalled stack updates and add in-app recovery actions (#1347)

* feat: detect stalled stack updates and add in-app recovery actions

Add a backend idle-output backstop that stops a deploy/update compose step
that has gone silent (SENCHO_COMPOSE_STALL_TIMEOUT_MS, default 10m), so a
hung image pull surfaces a fast failure instead of spinning indefinitely.

Surface failed, timed-out, and stalled operations with recovery actions on
the stack page: a desktop chip plus popover menu and an inline mobile card
offering retry, restart, roll back (when a backup exists), refresh state,
and copy diagnostics, all gated by deploy permission. The streaming
deploy/update progress modal is now on by default and warns when output
goes quiet. Container state is refreshed after a failed or stalled
operation, and the UI never sits in an indefinite spinner.

* fix: harden rollback against policy-blocked file mutation and refine recovery

Address review findings on the stalled-update recovery work:

- The rollback route restored backup files before running the policy gate, so
  a policy-blocked rollback could leave the on-disk config rolled back while the
  deployed containers were unchanged. Snapshot the current files first and
  revert them when the gate blocks; if that revert itself fails, escalate it on
  the persistent alert feed since the 409 is already sent.
- Refresh container state after a successful manual rollback (rollback
  redeploys), without mis-recording a refetch failure as a rollback failure.
- Suppress the stalled-output warning once live progress is unavailable.

* test: mock snapshotStackFiles in the atomic-deploy rollback route tests

The rollback route now snapshots stack files before restoring a backup, so its
FileSystemService mock needs snapshotStackFiles. Without it the mocked call
threw and the route returned 500, failing the success-path rollback assertions.
This commit is contained in:
Anso
2026-06-10 10:12:24 -04:00
committed by GitHub
parent a3033a848e
commit d369b03a38
31 changed files with 1580 additions and 76 deletions
+13 -1
View File
@@ -53,6 +53,14 @@ interface DeployFeedbackContextValue {
) => Promise<RunResult>;
panelState: DeployPanelState;
logRows: ParsedLogRow[];
/**
* Epoch ms of the most recent activity for the current session: stamped when
* the deploy starts, again when the stream connects, and on every output
* chunk. The modal compares it against now to warn that an in-flight
* operation has gone quiet (a possible stall), covering the no-first-line
* case because it is seeded at start rather than at first output.
*/
lastOutputAt: number;
onTerminalReady: () => void;
onTerminalError: () => void;
onMessage: (text: string) => void;
@@ -92,6 +100,7 @@ const DeployFeedbackContext = createContext<DeployFeedbackContextValue | undefin
export function DeployFeedbackProvider({ children }: { children: React.ReactNode }): React.ReactElement {
const [panelState, setPanelState] = useState<DeployPanelState>(DEFAULT_PANEL_STATE);
const [logRows, setLogRows] = useState<ParsedLogRow[]>([]);
const [lastOutputAt, setLastOutputAt] = useState<number>(0);
// Idempotent resolver for the current session's deployStarted gate. Set at the
// start of each runWithLog call; called by onTerminalReady (stream connected),
@@ -116,6 +125,7 @@ export function DeployFeedbackProvider({ children }: { children: React.ReactNode
const onTerminalReady = useCallback(() => {
streamReadyRef.current = true;
setLastOutputAt(Date.now());
setPanelState((prev) => (prev.status === 'preparing' ? { ...prev, status: 'streaming' } : prev));
// Give the connectTerminal handshake a beat to register on the backend
// before the deploy POST fires, so the first lines are not missed.
@@ -134,6 +144,7 @@ export function DeployFeedbackProvider({ children }: { children: React.ReactNode
const onMessage = useCallback((text: string) => {
const newRows = parseLogChunk(text, idCounterRef.current);
idCounterRef.current += newRows.length;
setLastOutputAt(Date.now());
setLogRows((prev) => {
const combined = prev.length > 0 && prev[0].id === TRUNCATION_ROW_ID
? [...prev.slice(1), ...newRows]
@@ -182,6 +193,7 @@ export function DeployFeedbackProvider({ children }: { children: React.ReactNode
// idCounterRef is intentionally not reset; keys must remain globally unique across sessions.
setLogRows([]);
setLastOutputAt(Date.now());
setPanelState({
isOpen: true,
@@ -236,7 +248,7 @@ export function DeployFeedbackProvider({ children }: { children: React.ReactNode
return (
<DeployFeedbackContext.Provider
value={{ runWithLog, panelState, logRows, onTerminalReady, onTerminalError, onMessage, onPanelClose }}
value={{ runWithLog, panelState, logRows, lastOutputAt, onTerminalReady, onTerminalError, onMessage, onPanelClose }}
>
{children}
</DeployFeedbackContext.Provider>