Skip to content
Open
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
7 changes: 7 additions & 0 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ paths = ['''^packages/runtime-core/tests/stream_relay_contract\.rs$''']
regexTarget = "line"
regexes = ['''^\s*let token = "0123456789abcdef0123456789abcdef";\s*$''']

[[rules.allowlists]]
description = "Projects long-poll test uses a fixed synthetic hexadecimal state token"
condition = "AND"
paths = ['''^tests/projects-wait\.test\.ts$''']
regexTarget = "line"
regexes = ['''^\s*const TOKEN = "0123456789abcdef0123456789abcdef";\s*$''']

[[rules.allowlists]]
description = "MCP inspect-tool tests use a fixed synthetic canary to prove values are never echoed"
condition = "AND"
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -384,7 +384,7 @@ Each is small enough for one PR.
10. **R7b — exists.** `hack doctor --fix --only <id> --yes --json` executes one allowlisted safe fix and rechecks it. Initially only `hack gitignore` is safe; other IDs and mixed modes refuse before writes. Results use `{ok,data:{schema:1,checks,summary,counts,fix}}`; `fix` records `id`, `changed`, `confirmed`.
11. **R7c** Machine-only doctor: `hack doctor --machine --json` (no project), so the app does not need to de-duplicate.
12. **R8** Daemon unit staleness: `service.unit_stale` and reason in `hack daemon status --json`, and `version` there.
13. **R9** (optional) Push updates: `GET /v1/projects?wait=<generated_at>` long-poll.
13. **R9 — exists** Push updates: `GET /v1/projects?wait=<state_token>&wait_timeout_ms=<ms>` long-poll ([waiting for changes](../../docs/reference/project-state.md#waiting-for-changes)). `generated_at` changes on every response, so the marker is the response's `state_token`, a digest that ignores per-response and refresh-timestamp fields. Answers on change, or at the timeout (default 25 s, max 55 s).
14. **R10 — exists** Env key state: `key_state: ok | missing | unreadable | not_checked` in `hack env list --json` (no values). `not_checked` is the legacy `hack.env.json` contract. The payload keeps its published bare-object shape; no envelope was added.
15. **R11** Admin argv plan: `hack global install|trust --plan --json` listing the root-only argv lines.
16. **R12a** Always-on persistence and `hack autostart add|remove|list|pause|resume` ([section 6](#6-always-on-hack-2213-proposed-contract)).
Expand Down
6 changes: 6 additions & 0 deletions docs/guides/v5-release-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ no longer removes legacy inputs in the same step.

The full upgrade steps are in [Upgrade to v5](upgrade-to-v5.md#preview-resume-and-undo-env-migration).

### Waiting for project changes from hackd

| Change | What to do |
| --- | --- |
| **hackd's `GET /v1/projects` carries `state_token` and accepts `?wait=<state_token>`.** The request answers when the projects content changes, or at `wait_timeout_ms` (default 25 s, 1–55 s). hackd's socket now keeps idle connections for up to 70 s. `hack projects --json` output is unchanged. | Long-poll instead of polling quickly: send the last `state_token` back and repeat. See [waiting for changes](../reference/project-state.md#waiting-for-changes). |

### Read-only update check for any install

| Change | What to do |
Expand Down
34 changes: 34 additions & 0 deletions docs/reference/project-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,3 +88,37 @@ writes. Ordinary JSON listing and daemon listing use live native reads when
native runtime selection is configured; those reads do not start a VM or graph.

![Shared state projection](./project-state.svg)

## Waiting for changes

hackd's `GET /v1/projects` response also carries `state_token`, an opaque
32-character digest of its content. It leaves out values that change without a
state change: `generated_at`, `profiling`, `runtime_checked_at`,
`runtime_last_ok_at` and the elapsed time in Docker's container `status` text.
The status's leading word, health and exit code still count ("Up 12 seconds
(healthy)" is digested as "Up (healthy)"), as do container `state` and the
service state and health fields above. The CLI's `hack projects --json` output
does not include it.

A client can send the token back to wait for a change:

```text
GET /v1/projects?wait=<state_token>&wait_timeout_ms=25000
```

- A different current token answers at once.
- Otherwise hackd recomputes after each runtime refresh (Docker events and its
30-second periodic refresh) and answers when the token changes, or at
`wait_timeout_ms` with a fresh build whose token may be unchanged. Ask again
with the latest token.
- Use the same query parameters as the request that produced the token.
- `wait_timeout_ms` defaults to 25000 and must be 1000–55000. A malformed
`wait` or timeout returns `400` (`invalid_wait`, `invalid_wait_timeout_ms`,
`wait_timeout_requires_wait`).
- At most 16 requests wait at once; beyond that a request is answered
immediately, as without `wait`.
- Registry-only changes, such as a new registration, show up at the next refresh
or at the timeout. Waiting ends when the client disconnects.

hackd's socket keeps idle connections for up to 70 seconds, so a wait is not
cut off at Bun's default 10 seconds. The unsupported gateway route does not wait.
4 changes: 3 additions & 1 deletion src/commands/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -775,7 +775,9 @@ async function outputDaemonProjects({
daemon.json &&
(!opts.summary || daemon.json.detail_level === "summary")
) {
const { profiling, ...payload } = daemon.json;
// `state_token` is the daemon's long-poll marker (`?wait=`), not part of
// the published `projects --json` shape.
const { profiling, state_token: _stateToken, ...payload } = daemon.json;
writeProfiledProjects({
payload,
profiler,
Expand Down
183 changes: 183 additions & 0 deletions src/daemon/projects-wait.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
/**
* Optional long-poll for `GET /v1/projects` (HACK-2218 R9).
*
* Every projects payload carries `state_token`, a digest of its content
* without the per-response `generated_at` and `profiling` fields. A client that
* sends `wait=<token>` gets an answer as soon as the token differs. Otherwise
* the request waits for runtime cache refreshes (Docker events and the daemon's
* periodic refresh) and recomputes after each one, until the token changes or
* `wait_timeout_ms` passes. A timeout answers with the unchanged payload, so a
* client simply asks again. Registry-only changes are seen at the next refresh.
*/
import { createHash } from "node:crypto";

export const PROJECTS_WAIT_DEFAULT_MS = 25_000;
export const PROJECTS_WAIT_MIN_MS = 1000;
export const PROJECTS_WAIT_MAX_MS = 55_000;
/** Concurrent waiters; beyond this a request is answered at once. */
export const PROJECTS_WAIT_MAX_WAITERS = 16;

const TOKEN_PATTERN = /^[0-9a-f]{32}$/;
const DIGITS = /^\d+$/;

/**
* Top-level fields that differ between two builds of identical state: the
* per-response stamp and profile, and refresh timestamps that advance on
* every periodic refresh even when nothing changed.
*/
const VOLATILE_FIELDS = new Set([
"generated_at",
"profiling",
"state_token",
"runtime_checked_at",
"runtime_last_ok_at",
]);

const PARENTHESIZED = /\([^)]*\)/g;
const WHITESPACE = /\s+/;

/**
* Docker's container `status` mixes elapsed time, which changes on every
* refresh, with health and exit code, which are state: "Up 12 seconds
* (healthy)", "Exited (0) 3 minutes ago". Keep the leading word and every
* parenthesized part ("Up (healthy)", "Exited (0)"), so a health change still
* changes the token. Branch containers carry health only in this text.
*/
export function stableContainerStatus(status: string): string {
const head = status.trim().split(WHITESPACE, 1)[0] ?? "";
return [head, ...(status.match(PARENTHESIZED) ?? [])].join(" ");
}

function stableValue(_key: string, value: unknown): unknown {
if (
value !== null &&
typeof value === "object" &&
!Array.isArray(value) &&
"id" in value &&
"state" in value &&
"status" in value
) {
const container = value as Record<string, unknown>;
return typeof container.status === "string"
? { ...container, status: stableContainerStatus(container.status) }
: container;
}
return value;
}

export function projectsStateToken(payload: object): string {
const stable = Object.fromEntries(
Object.entries(payload).filter(([key]) => !VOLATILE_FIELDS.has(key))
);
return createHash("sha256")
.update(JSON.stringify(stable, stableValue))
.digest("hex")
.slice(0, 32);
}

export type ProjectsWaitRequest =
| { readonly kind: "none" }
| {
readonly kind: "wait";
readonly token: string;
readonly timeoutMs: number;
}
| { readonly kind: "invalid"; readonly error: string };

/** Parse `wait` and `wait_timeout_ms`; a timeout without `wait` is invalid. */
export function parseProjectsWait(
params: URLSearchParams
): ProjectsWaitRequest {
const token = params.get("wait");
const timeout = params.get("wait_timeout_ms");
if (token === null) {
return timeout === null
? { kind: "none" }
: { kind: "invalid", error: "wait_timeout_requires_wait" };
}
if (!TOKEN_PATTERN.test(token)) {
return { kind: "invalid", error: "invalid_wait" };
}
if (timeout === null) {
return { kind: "wait", token, timeoutMs: PROJECTS_WAIT_DEFAULT_MS };
}
const value = DIGITS.test(timeout) ? Number(timeout) : Number.NaN;
if (
!Number.isSafeInteger(value) ||
value < PROJECTS_WAIT_MIN_MS ||
value > PROJECTS_WAIT_MAX_MS
) {
return { kind: "invalid", error: "invalid_wait_timeout_ms" };
}
return { kind: "wait", token, timeoutMs: value };
}

let waiters = 0;

/**
* Build the payload, then, for a wait request whose token still matches,
* rebuild after each refresh until it changes, the deadline passes or the
* client goes away. Always resolves with the latest payload and its token.
*/
export async function resolveProjectsPayload<T extends object>(opts: {
readonly build: () => Promise<T>;
readonly wait: ProjectsWaitRequest;
/** Completed-refresh count, read before each build. */
readonly refreshGeneration: () => number;
/**
* Resolves once a refresh newer than `after` has completed (at once if one
* already has), at the deadline, or on abort.
*/
readonly nextRefresh: (opts: {
readonly after: number;
readonly signal: AbortSignal;
readonly deadlineMs: number;
}) => Promise<void>;
readonly signal: AbortSignal;
readonly now?: () => number;
}): Promise<T & { readonly state_token: string }> {
const now = opts.now ?? Date.now;
// The generation is read before each build, so a refresh that completes
// while building wakes the next wait at once instead of being missed.
let seen = opts.refreshGeneration();
const tokenOf = async () => {
seen = opts.refreshGeneration();
const payload = await opts.build();
return { payload, token: projectsStateToken(payload) };
};
let current = await tokenOf();
if (
opts.wait.kind !== "wait" ||
current.token !== opts.wait.token ||
waiters >= PROJECTS_WAIT_MAX_WAITERS
) {
return { ...current.payload, state_token: current.token };
}
const deadlineMs = now() + opts.wait.timeoutMs;
waiters++;
try {
while (!opts.signal.aborted && now() < deadlineMs) {
await opts.nextRefresh({ after: seen, signal: opts.signal, deadlineMs });
if (opts.signal.aborted || now() >= deadlineMs) {
break;
}
current = await tokenOf();
if (current.token !== opts.wait.token) {
break;
}
}
// A timeout still answers with fresh content: registry changes do not
// trigger a refresh, so rebuild once rather than resend the first build.
if (!opts.signal.aborted && current.token === opts.wait.token) {
current = await tokenOf();
}
} finally {
waiters--;
}
return { ...current.payload, state_token: current.token };
}

/** Test seam: current number of waiting requests. */
export function projectsWaiterCount(): number {
return waiters;
}
42 changes: 42 additions & 0 deletions src/daemon/runtime-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,18 @@ export interface RuntimeCache {
}): PsPayload;
getSnapshot(): RuntimeSnapshot | null;
getDiagnostics(): RuntimeCacheDiagnostics;
/** Count of completed refreshes; read it before building from the cache. */
refreshGeneration(): number;
/**
* Resolves once a refresh newer than generation `after` has completed (at
* once if one already has), at `deadlineMs`, or when `signal` aborts,
* whichever is first. Never rejects.
*/
nextRefresh(opts: {
readonly after: number;
readonly signal: AbortSignal;
readonly deadlineMs: number;
}): Promise<void>;
}

export type RuntimeCacheDiagnostics = {
Expand Down Expand Up @@ -162,6 +174,8 @@ export function createRuntimeCache(opts: {

let lastRefreshPhasesMs: Readonly<Record<string, number>> = {};
let snapshot: RuntimeSnapshot | null = null;
const refreshWaiters = new Set<() => void>();
let generation = 0;
let refreshTask: Promise<void> | null = null;
let pendingRefresh: QueuedRefresh | null = null;
let lastRefreshDurationMs: number | null = null;
Expand Down Expand Up @@ -281,6 +295,10 @@ export function createRuntimeCache(opts: {
health = refreshed.health;
snapshot = nextSnapshot;
opts.onRefresh?.(nextSnapshot);
generation++;
for (const wake of [...refreshWaiters]) {
wake();
}
} finally {
const durationMs = Math.max(0, Date.now() - startedAtMs);
lastRefreshPhasesMs = { ...profiler.timings };
Expand Down Expand Up @@ -466,10 +484,34 @@ export function createRuntimeCache(opts: {
}
}

const nextRefresh: RuntimeCache["nextRefresh"] = ({
after,
signal,
deadlineMs,
}) =>
new Promise<void>((resolve) => {
const wake = () => {
clearTimeout(timer);
signal.removeEventListener("abort", wake);
refreshWaiters.delete(wake);
resolve();
};
const timer = setTimeout(wake, Math.max(0, deadlineMs - Date.now()));
// A refresh that completed while the caller was building counts.
if (signal.aborted || generation > after) {
wake();
return;
}
signal.addEventListener("abort", wake, { once: true });
refreshWaiters.add(wake);
});

return {
refresh,
getProjectsPayload,
getPsPayload,
nextRefresh,
refreshGeneration: () => generation,
getSnapshot: () => snapshot,
getDiagnostics: () => {
const inspect = getRuntimeInspectCacheDiagnostics({
Expand Down
Loading
Loading