Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,11 @@ containers. It serves a small local API over a Unix socket at `~/.hack/daemon/ha
- `hack ps --json`
- streaming consumers (TUI/MCP)

The event watcher ignores container actions such as health-check `exec_*` events that cannot change
the cached runtime view. Relevant event bursts are debounced and rate-bounded. Event refreshes reuse
inspect data for unchanged container IDs, while startup, watcher recovery, and the 30-second interval
perform full inspection so missed events and mutable network data remain eventually consistent.

If the daemon is not running (or version-mismatched), the CLI falls back to direct Docker calls.

Runtime health:
Expand Down
12 changes: 12 additions & 0 deletions docs/gateway-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -320,9 +320,21 @@ Response:
| `cache_age_ms` | number or null | Cache age in milliseconds |
| `last_refresh_at` | string or null | Last refresh attempt |
| `refresh_count` | number | Refresh count |
| `refresh_requests` | number | Scheduled refresh requests after event filtering |
| `refresh_requests_coalesced` | number | Refresh requests merged into pending work |
| `refresh_failures` | number | Refresh failures |
| `refresh_in_flight` | boolean | Whether the runtime cache is currently refreshing |
| `last_refresh_duration_ms` | number or null | Duration of the latest completed refresh |
| `max_refresh_duration_ms` | number or null | Longest refresh duration since daemon startup |
| `last_event_at` | string or null | Last docker event timestamp |
| `events_seen` | number | Docker events seen |
| `events_relevant` | number | Docker events classified as runtime-affecting |
| `events_ignored` | number | Known non-state-changing Docker events ignored |
| `inspect_calls` | number | Docker inspect invocations by the daemon cache |
| `inspect_ids` | number | Container IDs requested across inspect invocations |
| `inspect_cache_hits` | number | Container IDs served from the inspect cache |
| `inspect_cache_misses` | number | Container IDs absent from the inspect cache |
| `inspect_full_refreshes` | number | Forced full inspect reconciliations |
| `streams_active` | number | Active WS streams |
| `runtime_ok` | boolean | Docker runtime availability |
| `runtime_error` | string or null | Runtime error details (when unavailable) |
Expand Down
12 changes: 12 additions & 0 deletions src/control-plane/sdk/gateway-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,21 @@ export type GatewayMetrics = {
readonly cache_age_ms: number | null;
readonly last_refresh_at: string | null;
readonly refresh_count: number;
readonly refresh_requests: number;
readonly refresh_requests_coalesced: number;
readonly refresh_failures: number;
readonly refresh_in_flight: boolean;
readonly last_refresh_duration_ms: number | null;
readonly max_refresh_duration_ms: number | null;
readonly last_event_at: string | null;
readonly events_seen: number;
readonly events_relevant: number;
readonly events_ignored: number;
readonly inspect_calls: number;
readonly inspect_ids: number;
readonly inspect_cache_hits: number;
readonly inspect_cache_misses: number;
readonly inspect_full_refreshes: number;
readonly streams_active: number;
};

Expand Down
41 changes: 41 additions & 0 deletions src/daemon/docker-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,36 @@ export interface DockerEventWatcher {
stop(): void;
}

const ignoredContainerActions = new Set([
"attach",
"commit",
"copy",
"detach",
"exec_create",
"exec_detach",
"exec_die",
"exec_start",
"export",
"resize",
"top",
]);

/**
* Returns false only for known container actions that cannot change Hack's
* cached topology, state, ports, labels, mounts, or networks. Unknown actions
* fail open so the periodic reconciliation is not the only freshness path for
* new Docker event types.
*/
export function shouldRefreshForDockerEvent(opts: {
readonly event: DockerEvent;
}): boolean {
const action = readDockerEventAction({ event: opts.event });
if (!action) {
return true;
}
return !ignoredContainerActions.has(action);
}

export function startDockerEventWatcher(opts: {
readonly onEvent: (event: DockerEvent) => void;
readonly onError: (message: string) => void;
Expand Down Expand Up @@ -94,6 +124,17 @@ function parseDockerEvent(opts: { readonly line: string }): DockerEvent | null {
}
}

function readDockerEventAction(opts: {
readonly event: DockerEvent;
}): string | null {
const rawAction = opts.event.Action ?? opts.event.status;
if (typeof rawAction !== "string") {
return null;
}
const action = rawAction.split(":", 1)[0]?.trim().toLowerCase() ?? "";
return action.length > 0 ? action : null;
}

function sleep(opts: { readonly ms: number }): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, opts.ms));
}
137 changes: 137 additions & 0 deletions src/daemon/refresh-scheduler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
export type RefreshUrgency = "debounced" | "immediate";

export interface RefreshScheduler {
request(opts: {
readonly reason: string;
readonly urgency: RefreshUrgency;
}): void;
stop(): void;
}

type Timer = ReturnType<typeof setTimeout>;

type PendingRefresh = {
readonly reason: string;
readonly requestedAtMs: number;
readonly urgency: RefreshUrgency;
};

export function createRefreshScheduler(opts: {
readonly refresh: (opts: {
readonly reason: string;
readonly forceInspect: boolean;
}) => Promise<void>;
readonly debounceMs?: number;
readonly minIntervalMs?: number;
readonly maxWaitMs?: number;
readonly now?: () => number;
readonly onRequest?: (opts: { readonly coalesced: boolean }) => void;
readonly onRefreshStart?: () => void;
readonly onRefreshFinish?: (opts: {
readonly durationMs: number;
readonly error: unknown | null;
}) => void;
}): RefreshScheduler {
const debounceMs = opts.debounceMs ?? 250;
const minIntervalMs = opts.minIntervalMs ?? 1000;
const maxWaitMs = opts.maxWaitMs ?? 2000;
const now = opts.now ?? Date.now;

let active = false;
let lastCompletedAtMs: number | null = null;
let pending: PendingRefresh | null = null;
let stopped = false;
let timer: Timer | null = null;

function clearTimer(): void {
if (!timer) {
return;
}
clearTimeout(timer);
timer = null;
}

function schedulePending(): void {
if (stopped || active || !pending) {
return;
}

clearTimer();
const currentTimeMs = now();
const earliestByIntervalMs =
lastCompletedAtMs === null
? currentTimeMs
: lastCompletedAtMs + minIntervalMs;
const dueAtMs =
pending.urgency === "immediate"
? currentTimeMs
: Math.max(
earliestByIntervalMs,
Math.min(
pending.requestedAtMs + maxWaitMs,
currentTimeMs + debounceMs
)
);
const delayMs = Math.max(0, dueAtMs - currentTimeMs);
timer = setTimeout(() => {
timer = null;
void drain();
}, delayMs);
}

async function drain(): Promise<void> {
if (stopped || active || !pending) {
return;
}

const request = pending;
pending = null;
active = true;
const startedAtMs = now();
opts.onRefreshStart?.();
let error: unknown | null = null;
try {
await opts.refresh({
reason: request.reason,
forceInspect: request.urgency === "immediate",
});
} catch (caught: unknown) {
error = caught;
} finally {
const completedAtMs = now();
lastCompletedAtMs = completedAtMs;
active = false;
opts.onRefreshFinish?.({
durationMs: Math.max(0, completedAtMs - startedAtMs),
error,
});
schedulePending();
}
}

return {
request({ reason, urgency }) {
if (stopped) {
return;
}
const coalesced = active || pending !== null;
opts.onRequest?.({ coalesced });
const currentTimeMs = now();
if (!pending) {
pending = { reason, requestedAtMs: currentTimeMs, urgency };
} else if (urgency === "immediate") {
pending = {
reason,
requestedAtMs: pending.requestedAtMs,
urgency,
};
}
schedulePending();
},
stop() {
stopped = true;
pending = null;
clearTimer();
},
};
}
Loading
Loading