feat: auto-heal policies for unhealthy containers (#671)

* feat(db): add auto_heal_policies and auto_heal_history schema and CRUD

Adds two new SQLite tables (auto_heal_policies, auto_heal_history) to
DatabaseService.initSchema() and exposes CRUD methods: getAutoHealPolicies,
getAutoHealPolicy, addAutoHealPolicy, updateAutoHealPolicy,
deleteAutoHealPolicy, recordAutoHealHistory, getAutoHealHistory,
incrementConsecutiveFailures, resetConsecutiveFailures, setPolicyEnabled.
Also adds AutoHealPolicy and AutoHealHistoryEntry TypeScript interfaces.

* feat(events): track health-status duration and expose state accessors

- Add healthStatus and unhealthySince fields to InternalContainerState
- onHealthStatus now records unhealthySince timestamp on first transition
  to unhealthy, and clears it when the container recovers or restarts
- onStart resets both fields so a restarted container begins from 'starting'
- Add listContainerStates() and getContainerState() public accessors for
  use by the upcoming AutoHealService evaluator

* fix(auto-heal): key allowlist in updateAutoHealPolicy, cascade delete, extract ContainerHealthSnapshot

* feat: add AutoHealService evaluator singleton

Polls every 30 s, matches containers to enabled policies via Compose
labels, and restarts containers that have been unhealthy beyond the
configured threshold. Enforces cooldown, per-hour rate cap, and
recent-user-action suppression; auto-disables policies after repeated
consecutive failures. Also adds DockerEventManager.getService() accessor
required by the evaluator.

* fix(auto-heal): prune stale restartTimestamps, guard undefined policy id

- Prune restartTimestamps entries for containers no longer running after
  each container list fetch, preventing unbounded map growth from dead
  container IDs.
- Guard against policies with undefined id at the start of the per-policy
  loop; warn and skip rather than proceed with a non-null assertion.
- Extract handleAutoDisable private helper to bring executeHeal under 30
  lines and isolate the auto-disable side-effect sequence.
- Move ContainerInfo type to module scope.

* feat: add auto-heal API routes and wire AutoHealService lifecycle

Registers five REST endpoints under /api/auto-heal/policies (list, create,
patch, delete, history) with requirePaid + requireAdmin guards and Zod
validation. Wires AutoHealService.start()/stop() into the server startup
and graceful-shutdown blocks alongside MonitorService.

* test: add AutoHealService and DatabaseService auto-heal unit tests

- 15 unit tests for AutoHealService.shouldHeal covering all decision branches
  (healthy state, duration threshold, user-action suppression, cooldown,
  rate limiting, and correct skipReason values)
- 13 integration tests for DatabaseService auto-heal CRUD: policy round-trip,
  stack-name filter, partial update, cascade delete, history ordering/limit,
  consecutive failure counters, and setPolicyEnabled toggle

* fix: log AutoHealService shutdown errors consistently

* fix(api): requireAdmin-first guard order and try/catch on auto-heal routes

* feat(ui): add StackAutoHealSheet component

* feat(ui): add Auto-Heal context menu item to EditorLayout

* fix(ui): StackAutoHealSheet label, token, a11y, and useEffect fixes

- Rename 'All services in stack' to 'All services' in combobox options and placeholder
- Replace text-green-600 with text-success design token in actionColorClass
- Add htmlFor/id pairs to all four numeric form inputs for accessibility
- Inline fetch logic into useEffect, removing stale closure risk and eslint-disable comment
- Remove now-unused fetchPolicies and fetchServices standalone functions
- Update 'Auto-disable after' label to 'Auto-disable after (failures)' for clarity
- Add toast.error in policy fetch failure path; services fetch silently skips as before

* docs: add auto-heal-policies feature documentation

* test(e2e): add auto-heal policies CRUD spec

* fix(docs): correct auto-heal-policies nav position in docs.json
This commit is contained in:
Anso
2026-04-17 22:45:06 -04:00
committed by GitHub
parent 1efa91a990
commit 5bb4b01953
12 changed files with 1712 additions and 8 deletions
@@ -0,0 +1,168 @@
/**
* Unit tests for AutoHealService.shouldHeal decision logic.
*
* shouldHeal is private; accessed via type cast (service as any) to avoid
* exposing it in production API surface. All tests are pure (no I/O, no
* timers) - they exercise the decision function directly.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { INTENTIONAL_KILL_WINDOW_MS } from '../services/ContainerLifecycleClassifier';
import { AutoHealService } from '../services/AutoHealService';
describe('AutoHealService.shouldHeal', () => {
let service: any;
beforeEach(() => {
// Reset singleton so each test starts with a clean restartTimestamps map
(AutoHealService as any).instance = undefined;
service = AutoHealService.getInstance();
});
const basePolicy = {
id: 1,
stack_name: 'mystack',
service_name: null,
unhealthy_duration_mins: 5,
cooldown_mins: 10,
max_restarts_per_hour: 3,
auto_disable_after_failures: 5,
enabled: 1,
consecutive_failures: 0,
last_fired_at: 0,
created_at: Date.now(),
updated_at: Date.now(),
};
const baseState = {
id: 'container123',
name: 'mystack-web-1',
stackName: 'mystack',
healthStatus: 'unhealthy' as const,
unhealthySince: Date.now() - 6 * 60_000, // 6 minutes ago (past 5 min threshold)
lastKillAt: undefined,
};
it('returns heal:true when all conditions are met', () => {
const result = service.shouldHeal(baseState, basePolicy, 'container123', Date.now());
expect(result.heal).toBe(true);
});
it('returns heal:false when healthStatus is not unhealthy', () => {
const result = service.shouldHeal(
{ ...baseState, healthStatus: 'healthy' },
basePolicy,
'container123',
Date.now(),
);
expect(result.heal).toBe(false);
});
it('returns heal:false when healthStatus is undefined', () => {
const result = service.shouldHeal(
{ ...baseState, healthStatus: undefined },
basePolicy,
'container123',
Date.now(),
);
expect(result.heal).toBe(false);
});
it('returns heal:false when state is undefined', () => {
const result = service.shouldHeal(undefined, basePolicy, 'container123', Date.now());
expect(result.heal).toBe(false);
});
it('returns heal:false when unhealthySince is undefined', () => {
const result = service.shouldHeal(
{ ...baseState, unhealthySince: undefined },
basePolicy,
'container123',
Date.now(),
);
expect(result.heal).toBe(false);
});
it('returns heal:false when duration threshold is not yet met', () => {
// Only 2 minutes, threshold is 5
const state = { ...baseState, unhealthySince: Date.now() - 2 * 60_000 };
const result = service.shouldHeal(state, basePolicy, 'container123', Date.now());
expect(result.heal).toBe(false);
expect(result.skipReason).toBe('duration_not_met');
});
it('returns skipped_user_action when lastKillAt is within the window', () => {
// 30s ago, well within the 60s INTENTIONAL_KILL_WINDOW_MS
const state = { ...baseState, lastKillAt: Date.now() - 30_000 };
const result = service.shouldHeal(state, basePolicy, 'container123', Date.now());
expect(result.heal).toBe(false);
expect(result.skipReason).toBe('skipped_user_action');
});
it('does not suppress when lastKillAt is outside the intentional kill window', () => {
const state = {
...baseState,
lastKillAt: Date.now() - (INTENTIONAL_KILL_WINDOW_MS + 5_000),
};
const result = service.shouldHeal(state, basePolicy, 'container123', Date.now());
expect(result.heal).toBe(true);
});
it('returns skipped_cooldown when last_fired_at is within cooldown period', () => {
// Fired 5 min ago, cooldown is 10 min
const policy = { ...basePolicy, last_fired_at: Date.now() - 5 * 60_000, cooldown_mins: 10 };
const result = service.shouldHeal(baseState, policy, 'container123', Date.now());
expect(result.heal).toBe(false);
expect(result.skipReason).toBe('skipped_cooldown');
});
it('does not apply cooldown when last_fired_at is 0', () => {
const policy = { ...basePolicy, last_fired_at: 0 };
const result = service.shouldHeal(baseState, policy, 'container123', Date.now());
expect(result.heal).toBe(true);
});
it('does not apply cooldown when last_fired_at exceeds the cooldown window', () => {
// Fired 15 min ago, cooldown is 10 min
const policy = { ...basePolicy, last_fired_at: Date.now() - 15 * 60_000, cooldown_mins: 10 };
const result = service.shouldHeal(baseState, policy, 'container123', Date.now());
expect(result.heal).toBe(true);
});
it('returns skipped_rate_limit when hourly restart count is at the configured max', () => {
const now = Date.now();
// Pre-populate with 3 entries within the last hour (policy max is 3)
const map = (service as any).restartTimestamps as Map<string, number[]>;
map.set('container123', [now - 10_000, now - 20_000, now - 30_000]);
const result = service.shouldHeal(baseState, basePolicy, 'container123', now);
expect(result.heal).toBe(false);
expect(result.skipReason).toBe('skipped_rate_limit');
});
it('does not rate-limit when all timestamps are older than one hour', () => {
const now = Date.now();
const map = (service as any).restartTimestamps as Map<string, number[]>;
// All entries are >1 hour old, so they fall outside the rate-limit window
map.set('container123', [now - 70 * 60_000, now - 80 * 60_000, now - 90 * 60_000]);
const result = service.shouldHeal(baseState, basePolicy, 'container123', now);
expect(result.heal).toBe(true);
});
it('counts only recent timestamps toward the rate limit', () => {
const now = Date.now();
const map = (service as any).restartTimestamps as Map<string, number[]>;
// 2 old (outside window) + 1 recent = 1 active restart; max is 3, so still allowed
map.set('container123', [now - 70 * 60_000, now - 80 * 60_000, now - 5_000]);
const result = service.shouldHeal(baseState, basePolicy, 'container123', now);
expect(result.heal).toBe(true);
});
it('returns not_unhealthy as skipReason when container is healthy', () => {
const result = service.shouldHeal(
{ ...baseState, healthStatus: 'healthy' },
basePolicy,
'container123',
Date.now(),
);
expect(result.skipReason).toBe('not_unhealthy');
});
});