@cydm/happy-elves 0.1.0-beta.66 → 0.1.0-beta.67
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.
- package/apps/cli/dist/commands/lib/bootstrap-config.js +5 -2
- package/apps/cli/dist/commands/lib/bootstrap-daemon.d.ts +44 -2
- package/apps/cli/dist/commands/lib/bootstrap-daemon.js +160 -9
- package/apps/cli/dist/commands/lib/bootstrap.js +21 -6
- package/apps/daemon/dist/gateway/commands.d.ts +3 -0
- package/apps/daemon/dist/gateway/commands.js +540 -0
- package/apps/daemon/dist/gateway/runtime.d.ts +5 -0
- package/apps/daemon/dist/gateway/runtime.js +19 -1
- package/apps/daemon/dist/relay/connection.js +24 -0
- package/apps/daemon/dist/relay/register.js +1 -0
- package/apps/daemon/dist/start.js +32 -0
- package/apps/daemon/package.json +1 -1
- package/apps/relay/dist/controller-handlers.js +93 -7
- package/apps/relay/dist/machine-command-result-handlers.js +16 -1
- package/apps/relay/dist/projections.js +2 -0
- package/apps/relay/dist/relay-context.js +3 -1
- package/apps/relay/dist/scope.js +6 -0
- package/apps/relay/dist/websocket.js +2 -0
- package/build-identity.json +2 -2
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/packages/client/dist/client.d.ts +18 -1
- package/packages/client/dist/client.js +137 -2
- package/packages/client/dist/errors.d.ts +2 -0
- package/packages/client/dist/errors.js +2 -0
- package/packages/client/dist/http.js +7 -3
- package/packages/client/dist/index.d.ts +1 -0
- package/packages/client/dist/live.js +4 -2
- package/packages/client/dist/transport.d.ts +5 -0
- package/packages/client/dist/transport.js +5 -1
- package/packages/shared/dist/crypto.d.ts +102 -0
- package/packages/shared/dist/crypto.js +213 -0
- package/packages/shared/dist/node/gateway-store.d.ts +3 -0
- package/packages/shared/dist/node/gateway-store.js +10 -0
- package/packages/shared/dist/protocol-schemas.d.ts +4 -2
- package/packages/shared/dist/protocol-schemas.js +1 -0
- package/packages/shared/dist/protocol-types.d.ts +132 -0
- package/packages/shared/dist/protocol.d.ts +31 -2
- 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)
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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",
|