@cydm/happy-elves 0.1.0-beta.66 → 0.1.0-beta.68

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/apps/cli/dist/commands/lib/bootstrap-config.js +5 -2
  2. package/apps/cli/dist/commands/lib/bootstrap-daemon.d.ts +44 -2
  3. package/apps/cli/dist/commands/lib/bootstrap-daemon.js +160 -9
  4. package/apps/cli/dist/commands/lib/bootstrap.js +21 -6
  5. package/apps/daemon/dist/gateway/commands.d.ts +3 -0
  6. package/apps/daemon/dist/gateway/commands.js +540 -0
  7. package/apps/daemon/dist/gateway/runtime.d.ts +5 -0
  8. package/apps/daemon/dist/gateway/runtime.js +19 -1
  9. package/apps/daemon/dist/relay/connection.js +24 -0
  10. package/apps/daemon/dist/relay/register.js +1 -0
  11. package/apps/daemon/dist/start.js +32 -0
  12. package/apps/daemon/package.json +1 -1
  13. package/apps/relay/dist/controller-handlers.js +93 -7
  14. package/apps/relay/dist/machine-command-result-handlers.js +16 -1
  15. package/apps/relay/dist/projections.js +2 -0
  16. package/apps/relay/dist/relay-context.js +3 -1
  17. package/apps/relay/dist/scope.js +6 -0
  18. package/apps/relay/dist/websocket.js +2 -0
  19. package/build-identity.json +2 -2
  20. package/npm-shrinkwrap.json +2 -2
  21. package/package.json +1 -1
  22. package/packages/client/dist/client.d.ts +18 -1
  23. package/packages/client/dist/client.js +137 -2
  24. package/packages/client/dist/errors.d.ts +2 -0
  25. package/packages/client/dist/errors.js +2 -0
  26. package/packages/client/dist/http.js +7 -3
  27. package/packages/client/dist/index.d.ts +1 -0
  28. package/packages/client/dist/live.js +4 -2
  29. package/packages/client/dist/transport.d.ts +5 -0
  30. package/packages/client/dist/transport.js +5 -1
  31. package/packages/shared/dist/crypto.d.ts +102 -0
  32. package/packages/shared/dist/crypto.js +213 -0
  33. package/packages/shared/dist/node/gateway-store.d.ts +3 -0
  34. package/packages/shared/dist/node/gateway-store.js +10 -0
  35. package/packages/shared/dist/protocol-schemas.d.ts +4 -2
  36. package/packages/shared/dist/protocol-schemas.js +1 -0
  37. package/packages/shared/dist/protocol-types.d.ts +132 -0
  38. package/packages/shared/dist/protocol.d.ts +31 -2
  39. package/packages/shared/dist/protocol.js +34 -0
@@ -3,8 +3,9 @@ import path from "node:path";
3
3
  import { ControllerClient } from "../../../../../packages/client/dist/index.js";
4
4
  import { generateAccountSecret, randomId } from "../../../../../packages/shared/dist/index.js";
5
5
  import { CliError } from "../../errors.js";
6
- import { configDir, configPath, defaultHostedControllerUrl, defaultHostedRelayUrl } from "./paths.js";
6
+ import { configDir, configPath, defaultHostedControllerUrl, defaultHostedRelayUrl, startDaemonTimeoutMs } from "./paths.js";
7
7
  import { normalizeRelayUrl, requireRelayUrl, writeConfig, writeDaemonConfig } from "./config.js";
8
+ import { readControllerSnapshotWithRetry } from "./bootstrap-daemon.js";
8
9
  import { cliErrorFromFetch, isAuthTokenInvalid, parsePairingClaimResponse, parsePairingStartResponse, readExistingControllerConfig, readExistingDaemonConfig, readRelayJson, throwRelayHttpError } from "./relay-http.js";
9
10
  function backupTimestamp() {
10
11
  return new Date().toISOString().replace(/[:.]/g, "-");
@@ -63,7 +64,9 @@ export async function ensureControllerConfig(params) {
63
64
  }
64
65
  else {
65
66
  try {
66
- await new ControllerClient(existing).snapshot();
67
+ await readControllerSnapshotWithRetry(new ControllerClient(existing), {
68
+ timeoutMs: startDaemonTimeoutMs,
69
+ });
67
70
  return { config: existing, created: false, reconfigured: false };
68
71
  }
69
72
  catch (error) {
@@ -1,4 +1,4 @@
1
- import type { ControllerClient } from "../../../../../packages/client/dist/index.js";
1
+ import { type ControllerClient, type ControllerSnapshot } from "../../../../../packages/client/dist/index.js";
2
2
  import type { MachineSnapshot } from "../../../../../packages/shared/dist/index.js";
3
3
  import { localDaemonStatus, stopLocalDaemon } from "./local-daemon.js";
4
4
  export declare function ensureDaemonRunning(relayUrl: string, cwd?: string): Promise<{
@@ -6,10 +6,52 @@ export declare function ensureDaemonRunning(relayUrl: string, cwd?: string): Pro
6
6
  started: boolean;
7
7
  startedPid?: number;
8
8
  }>;
9
+ export type DaemonReadinessResult = {
10
+ phase: "relay-authenticated" | "process-exited" | "relay-pending";
11
+ local: Awaited<ReturnType<typeof localDaemonStatus>>;
12
+ machine?: MachineSnapshot;
13
+ lastRelayErrorCode?: string;
14
+ };
15
+ type DaemonReadinessOptions = {
16
+ timeoutMs?: number;
17
+ pollIntervalMs?: number;
18
+ /**
19
+ * A newly spawned or restarted daemon must advance the relay projection
20
+ * before it is considered ready. This prevents an online snapshot left by
21
+ * the previous connection from satisfying a new start.
22
+ */
23
+ baseline?: Pick<MachineSnapshot, "online" | "lastSeen">;
24
+ requireFreshProjection?: boolean;
25
+ readLocalStatus?: typeof localDaemonStatus;
26
+ sleep?: (timeoutMs: number) => Promise<void>;
27
+ };
28
+ type BaselineSnapshotOptions = {
29
+ timeoutMs?: number;
30
+ pollIntervalMs?: number;
31
+ sleep?: (timeoutMs: number) => Promise<void>;
32
+ now?: () => number;
33
+ };
34
+ /**
35
+ * Capture the relay projection that predates a possible daemon spawn. This is
36
+ * a separate bounded phase so a transient relay restart cannot make `start`
37
+ * fail before the daemon is even launched, while the later readiness phase
38
+ * still receives its full independent budget.
39
+ */
40
+ export declare function readDaemonProjectionBaseline(client: Pick<ControllerClient, "snapshot">, machineId: string, options?: BaselineSnapshotOptions): Promise<MachineSnapshot | undefined>;
41
+ /** Retry the authenticated bootstrap read without allowing one hung fetch to
42
+ * escape the overall startup deadline. */
43
+ export declare function readControllerSnapshotWithRetry(client: Pick<ControllerClient, "snapshot">, options?: BaselineSnapshotOptions): Promise<ControllerSnapshot>;
9
44
  export declare function restartDaemonForNewConfig(relayUrl: string, cwd?: string): Promise<{
10
45
  stopped: Awaited<ReturnType<typeof stopLocalDaemon>>;
11
46
  local: Awaited<ReturnType<typeof localDaemonStatus>>;
12
47
  started: boolean;
13
48
  startedPid?: number;
14
49
  }>;
15
- export declare function waitForMachineProjection(client: ControllerClient, machineId: string, timeoutMs?: number): Promise<MachineSnapshot | undefined>;
50
+ /**
51
+ * Wait for the authority boundary that actually makes the daemon usable: an
52
+ * online machine projection emitted after the relay authenticates its machine
53
+ * token. A verified PID alone only means the process reached its startup
54
+ * window; runtime discovery and relay authentication may still be pending.
55
+ */
56
+ export declare function waitForDaemonReadiness(client: Pick<ControllerClient, "snapshot">, machineId: string, options?: DaemonReadinessOptions): Promise<DaemonReadinessResult>;
57
+ export {};
@@ -1,3 +1,4 @@
1
+ import { ControllerClientError } from "../../../../../packages/client/dist/index.js";
1
2
  import { CliError } from "../../errors.js";
2
3
  import { startDaemonTimeoutMs } from "./paths.js";
3
4
  import { readDaemonConfig } from "./config.js";
@@ -24,6 +25,43 @@ export async function ensureDaemonRunning(relayUrl, cwd = process.cwd()) {
24
25
  }
25
26
  throw new CliError("Daemon did not become ready in time. Run happy-elves daemon doctor and happy-elves daemon logs.", "DAEMON_START_FAILED");
26
27
  }
28
+ /**
29
+ * Capture the relay projection that predates a possible daemon spawn. This is
30
+ * a separate bounded phase so a transient relay restart cannot make `start`
31
+ * fail before the daemon is even launched, while the later readiness phase
32
+ * still receives its full independent budget.
33
+ */
34
+ export async function readDaemonProjectionBaseline(client, machineId, options = {}) {
35
+ const snapshot = await readControllerSnapshotWithRetry(client, options);
36
+ return snapshot.machines.find((machine) => machine.id === machineId);
37
+ }
38
+ /** Retry the authenticated bootstrap read without allowing one hung fetch to
39
+ * escape the overall startup deadline. */
40
+ export async function readControllerSnapshotWithRetry(client, options = {}) {
41
+ const timeoutMs = options.timeoutMs ?? startDaemonTimeoutMs;
42
+ const pollIntervalMs = options.pollIntervalMs ?? 300;
43
+ const wait = options.sleep ?? ((durationMs) => new Promise((resolve) => setTimeout(resolve, durationMs)));
44
+ const now = options.now ?? Date.now;
45
+ const deadline = now() + Math.max(0, timeoutMs);
46
+ let attempted = false;
47
+ let lastTransientError;
48
+ while (true) {
49
+ const remainingMs = Math.max(0, deadline - now());
50
+ if (attempted && remainingMs === 0) {
51
+ throw lastTransientError ?? snapshotDeadlineError();
52
+ }
53
+ attempted = true;
54
+ try {
55
+ return await settleSnapshotWithin(client, remainingMs);
56
+ }
57
+ catch (error) {
58
+ if (!retryableReadinessSnapshotError(error) || now() >= deadline)
59
+ throw error;
60
+ lastTransientError = error;
61
+ await wait(Math.min(pollIntervalMs, Math.max(0, deadline - now())));
62
+ }
63
+ }
64
+ }
27
65
  export async function restartDaemonForNewConfig(relayUrl, cwd = process.cwd()) {
28
66
  const stopped = await stopLocalDaemon();
29
67
  if (stopped.running) {
@@ -32,15 +70,128 @@ export async function restartDaemonForNewConfig(relayUrl, cwd = process.cwd()) {
32
70
  const started = await ensureDaemonRunning(relayUrl, cwd);
33
71
  return { stopped, ...started };
34
72
  }
35
- export async function waitForMachineProjection(client, machineId, timeoutMs = 5000) {
73
+ /**
74
+ * Wait for the authority boundary that actually makes the daemon usable: an
75
+ * online machine projection emitted after the relay authenticates its machine
76
+ * token. A verified PID alone only means the process reached its startup
77
+ * window; runtime discovery and relay authentication may still be pending.
78
+ */
79
+ export async function waitForDaemonReadiness(client, machineId, options = {}) {
80
+ const timeoutMs = options.timeoutMs ?? startDaemonTimeoutMs;
81
+ const pollIntervalMs = options.pollIntervalMs ?? 300;
82
+ const readLocalStatus = options.readLocalStatus ?? localDaemonStatus;
83
+ const wait = options.sleep ?? ((durationMs) => new Promise((resolve) => setTimeout(resolve, durationMs)));
36
84
  const deadline = Date.now() + timeoutMs;
37
- let lastSeen;
38
- while (Date.now() <= deadline) {
39
- const snapshot = await client.snapshot();
40
- lastSeen = snapshot.machines.find((machine) => machine.id === machineId);
41
- if (lastSeen?.online)
42
- return lastSeen;
43
- await new Promise((resolve) => setTimeout(resolve, 300));
85
+ let machine;
86
+ let local;
87
+ let lastRelayErrorCode;
88
+ let attempted = false;
89
+ while (!attempted || Date.now() < deadline) {
90
+ attempted = true;
91
+ local = await readLocalStatus();
92
+ assertLocalDaemonVerified(local);
93
+ if (!local.running) {
94
+ return { phase: "process-exited", local, machine };
95
+ }
96
+ let snapshot;
97
+ try {
98
+ snapshot = await settleSnapshotWithin(client, Math.max(0, deadline - Date.now()));
99
+ lastRelayErrorCode = undefined;
100
+ }
101
+ catch (error) {
102
+ if (!retryableReadinessSnapshotError(error))
103
+ throw error;
104
+ lastRelayErrorCode = error.code;
105
+ const remainingMs = Math.max(0, deadline - Date.now());
106
+ if (remainingMs === 0)
107
+ break;
108
+ await wait(Math.min(pollIntervalMs, remainingMs));
109
+ continue;
110
+ }
111
+ machine = snapshot.machines.find((candidate) => candidate.id === machineId);
112
+ if (machine?.online && projectionIsFresh(machine, options)) {
113
+ // Close the process/projection TOCTOU window: the relay may still expose
114
+ // a fresh online row for a daemon that exited while snapshot() was in
115
+ // flight. Re-verify the local owner before declaring startup complete.
116
+ const confirmedLocal = await readLocalStatus();
117
+ assertLocalDaemonVerified(confirmedLocal);
118
+ if (!confirmedLocal.running) {
119
+ return { phase: "process-exited", local: confirmedLocal, machine };
120
+ }
121
+ return { phase: "relay-authenticated", local: confirmedLocal, machine };
122
+ }
123
+ await wait(Math.min(pollIntervalMs, Math.max(0, deadline - Date.now())));
124
+ }
125
+ return {
126
+ phase: "relay-pending",
127
+ local: local ?? await readLocalStatus(),
128
+ machine,
129
+ ...(lastRelayErrorCode ? { lastRelayErrorCode } : {}),
130
+ };
131
+ }
132
+ function retryableReadinessSnapshotError(error) {
133
+ if (!(error instanceof ControllerClientError))
134
+ return false;
135
+ if (error.code.startsWith("AUTH_") ||
136
+ error.code.startsWith("SCOPED_TOKEN_") ||
137
+ error.code === "ORIGIN_FORBIDDEN" ||
138
+ error.code === "INVALID_ARGUMENT" ||
139
+ error.code === "RELAY_RESPONSE_INVALID") {
140
+ return false;
44
141
  }
45
- return lastSeen;
142
+ // Structured relay failures retain their stable code (for example,
143
+ // INTERNAL_ERROR), so HTTP status is the authority for retrying 5xx. Never
144
+ // infer retryability from a server error code alone: authentication and all
145
+ // other 4xx failures must fail immediately.
146
+ if (error.httpStatus !== undefined)
147
+ return error.httpStatus >= 500 && error.httpStatus <= 599;
148
+ return error.code === "RELAY_UNREACHABLE";
149
+ }
150
+ function snapshotDeadlineError() {
151
+ return new ControllerClientError("Timed out waiting for the relay bootstrap snapshot", "RELAY_UNREACHABLE");
152
+ }
153
+ async function settleSnapshotWithin(client, timeoutMs) {
154
+ return await new Promise((resolve, reject) => {
155
+ let settled = false;
156
+ const abortController = new AbortController();
157
+ const timer = setTimeout(() => {
158
+ if (settled)
159
+ return;
160
+ settled = true;
161
+ abortController.abort();
162
+ reject(snapshotDeadlineError());
163
+ }, timeoutMs);
164
+ let snapshot;
165
+ try {
166
+ snapshot = client.snapshot({ signal: abortController.signal });
167
+ }
168
+ catch (error) {
169
+ settled = true;
170
+ clearTimeout(timer);
171
+ reject(error);
172
+ return;
173
+ }
174
+ void snapshot.then((value) => {
175
+ if (settled)
176
+ return;
177
+ settled = true;
178
+ clearTimeout(timer);
179
+ resolve(value);
180
+ }, (error) => {
181
+ if (settled)
182
+ return;
183
+ settled = true;
184
+ clearTimeout(timer);
185
+ reject(error);
186
+ });
187
+ });
188
+ }
189
+ function projectionIsFresh(machine, options) {
190
+ if (options.requireFreshProjection !== true)
191
+ return true;
192
+ if (!options.baseline)
193
+ return true;
194
+ if (options.baseline.online !== true)
195
+ return true;
196
+ return machine.lastSeen > options.baseline.lastSeen;
46
197
  }
@@ -2,12 +2,12 @@ import os from "node:os";
2
2
  import path from "node:path";
3
3
  import { ControllerClient } from "../../../../../packages/client/dist/index.js";
4
4
  import { CliError } from "../../errors.js";
5
- import { configDir, configPath } from "./paths.js";
5
+ import { configDir, configPath, startDaemonTimeoutMs } from "./paths.js";
6
6
  import { ok } from "./json.js";
7
7
  import { assertLocalDaemonVerified, localDaemonStatus } from "./local-daemon.js";
8
8
  import { cliErrorFromFetch, readExistingControllerConfig } from "./relay-http.js";
9
9
  import { controllerBaseUrl, ensureControllerConfig, ensureDaemonPaired, startRelayUrl } from "./bootstrap-config.js";
10
- import { ensureDaemonRunning, restartDaemonForNewConfig, waitForMachineProjection } from "./bootstrap-daemon.js";
10
+ import { ensureDaemonRunning, readDaemonProjectionBaseline, restartDaemonForNewConfig, waitForDaemonReadiness } from "./bootstrap-daemon.js";
11
11
  import { openUrl, writeHumanStartOutput } from "./bootstrap-output.js";
12
12
  export async function startBootstrap(flags) {
13
13
  const existingController = await readExistingControllerConfig().catch((error) => {
@@ -27,13 +27,28 @@ export async function startBootstrap(flags) {
27
27
  deviceName,
28
28
  });
29
29
  const daemonState = await ensureDaemonPaired(controllerState.config, machineName);
30
+ const client = new ControllerClient(controllerState.config);
31
+ const baseline = await readDaemonProjectionBaseline(client, daemonState.config.machineId, {
32
+ timeoutMs: startDaemonTimeoutMs,
33
+ });
30
34
  const daemon = daemonBefore.running && daemonState.reconfigured
31
35
  ? await restartDaemonForNewConfig(controllerState.config.relayUrl, cwd)
32
36
  : await ensureDaemonRunning(controllerState.config.relayUrl, cwd);
33
- const client = new ControllerClient(controllerState.config);
34
- const machine = await waitForMachineProjection(client, daemonState.config.machineId);
35
- if (!machine?.online) {
36
- throw new CliError("Daemon started, but this machine did not appear online in the relay snapshot. Run happy-elves doctor and happy-elves daemon logs.", "DAEMON_ONLINE_TIMEOUT");
37
+ const readiness = await waitForDaemonReadiness(client, daemonState.config.machineId, {
38
+ baseline,
39
+ requireFreshProjection: daemon.started,
40
+ // Lifetime-lock acquisition/reclaim and relay-authenticated readiness are
41
+ // separate bounded phases. A legitimate slow lease recovery must not
42
+ // consume the runtime-discovery/relay budget that follows it.
43
+ timeoutMs: startDaemonTimeoutMs,
44
+ });
45
+ const machine = readiness.machine;
46
+ if (readiness.phase === "process-exited") {
47
+ throw new CliError("Daemon exited before it authenticated with the relay. Run happy-elves doctor and happy-elves daemon logs.", "DAEMON_START_FAILED");
48
+ }
49
+ if (readiness.phase !== "relay-authenticated" || !machine?.online) {
50
+ const relayDetail = readiness.lastRelayErrorCode ? ` Last relay error: ${readiness.lastRelayErrorCode}.` : "";
51
+ throw new CliError(`Daemon is running, but it did not authenticate with the relay before the startup deadline.${relayDetail} Run happy-elves doctor and happy-elves daemon logs.`, "DAEMON_ONLINE_TIMEOUT");
37
52
  }
38
53
  const invite = await client.createControllerInvite({
39
54
  deviceName: "Web controller",
@@ -0,0 +1,3 @@
1
+ import { type MachineCommand } from "../../../../packages/shared/dist/index.js";
2
+ import type { DaemonConfig } from "../types.js";
3
+ export declare function handleGatewayCommand(ws: WebSocket, config: DaemonConfig, command: MachineCommand): Promise<void>;