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
153 changes: 106 additions & 47 deletions src/core/cli/walkthrough.js
Original file line number Diff line number Diff line change
Expand Up @@ -1501,38 +1501,21 @@ export function resolveSingleSourceEnablement(descriptor) {
}

/**
* Wait for the proxy CA before the finale attaches clients, when the config
* that will govern the freshly installed daemon runs the gateway in proxy
* mode. Adapters pick their mode by whether the CA file exists (LLP 0232
* #proxy-attach-preflight) and the daemon mints it asynchronously on gateway
* start, so attaching without waiting races the mint and silently lands
* every client back on base-URL mode. Bounded, and a timeout degrades to a
* warning: base-URL attach still captures, and a re-run of `hyp attach`
* repairs the mode.
* Whether the gateway that will govern this machine runs in proxy mode.
*
* "Will govern" is the effective view, not the just-written local file: on a
* fleet-joined machine the central layer names the gateway and the LLP 0031
* merge drops the local entry, so a local `proxy_mode: true` is dead and
* waiting on it could only time out. When the central layer names the
* gateway, its own `proxy_mode` value decides the wait instead.
* reading it would answer for a config nothing runs. When the central layer
* names the gateway, its own `proxy_mode` value is the answer instead.
*
* @ref LLP 0243#composed-default [implements]: a fresh proxy-mode install must attach in proxy mode, not lose the race to the CA mint
* @ref LLP 0244#central-managed [constrained-by]: the central layer owning the gateway block decides the mode, locally written keys prove nothing
*
* @param {{
* config: HypAwareV2Config,
* env: NodeJS.ProcessEnv,
* stderr: { write(chunk: string): unknown },
* waitForCaFn?: (args: {
* stateRoot: string,
* timeoutMs?: number,
* sleep?: (ms: number) => Promise<void>,
* now?: () => number,
* }) => Promise<{ ready: boolean, certPath?: string }>,
* timeoutMs?: number,
* }} args
* @returns {Promise<{ waited: boolean, ready: boolean }>}
* @param {{ config: HypAwareV2Config, env: NodeJS.ProcessEnv }} args
* @returns {Promise<boolean>}
*/
export async function waitForProxyCaBeforeAttach({ config, env, stderr, waitForCaFn, timeoutMs }) {
export async function governingGatewayProxyMode({ config, env }) {
const stateRoot = defaultStateRoot(env)

/** @type {PluginConfigInstance | undefined} */
Expand All @@ -1553,7 +1536,35 @@ export async function waitForProxyCaBeforeAttach({ config, env, stderr, waitForC

const governing = centralGateway
?? (config.plugins ?? []).find((p) => p.name === GATEWAY_PLUGIN)
if (governing?.config?.proxy_mode !== true) return { waited: false, ready: false }
return governing?.config?.proxy_mode === true
}

/**
* Wait for the proxy CA before the finale attaches clients, when the config
* that will govern the daemon runs the gateway in proxy mode. Adapters pick
* their mode by whether the CA file exists (LLP 0232 #proxy-attach-preflight)
* and the daemon mints it asynchronously on gateway start, so attaching
* without waiting races the mint and silently lands every client back on
* base-URL mode. Bounded, and a timeout degrades to a warning: base-URL
* attach still captures, and a re-run of `hyp attach` repairs the mode.
*
* @param {{
* config: HypAwareV2Config,
* env: NodeJS.ProcessEnv,
* stderr: { write(chunk: string): unknown },
* waitForCaFn?: (args: {
* stateRoot: string,
* timeoutMs?: number,
* sleep?: (ms: number) => Promise<void>,
* now?: () => number,
* }) => Promise<{ ready: boolean, certPath?: string }>,
* timeoutMs?: number,
* }} args
* @returns {Promise<{ waited: boolean, ready: boolean }>}
*/
export async function waitForProxyCaBeforeAttach({ config, env, stderr, waitForCaFn, timeoutMs }) {
const stateRoot = defaultStateRoot(env)
if (!(await governingGatewayProxyMode({ config, env }))) return { waited: false, ready: false }

const waitFn = waitForCaFn ?? waitForLocalCa
const caWait = await waitFn({
Expand All @@ -1578,8 +1589,9 @@ export async function waitForProxyCaBeforeAttach({ config, env, stderr, waitForC
* Exported for the wizard orchestrator (LLP 0135 #finale), which wraps
* it with the team-pathway skips: `finale.skipDaemonInstall` skips only
* the install step (the restart still runs so the just-written local
* config takes effect), and `skipAttachClients` names picked clients the
* join lane already attached.
* config takes effect, and moves ahead of attach when that config puts the
* gateway in proxy mode), and `skipAttachClients` names picked clients the
* join lane already attached in the mode this install uses.
*
* @param {{
* finale: PickerFinaleActions,
Expand All @@ -1602,6 +1614,7 @@ export async function waitForProxyCaBeforeAttach({ config, env, stderr, waitForC
* skipAttachClients?: Set<string>,
* progress?: string,
* installDaemonFn?: (options: DaemonInstallOptions) => Promise<DaemonInstallPlan>,
* restartDaemonFn?: (options: { homeDir?: string }) => Promise<void>,
* waitForCaFn?: (args: {
* stateRoot: string,
* timeoutMs?: number,
Expand All @@ -1623,6 +1636,12 @@ export async function runPickerFinale(args) {
if (args.progress) stdout.write(`${args.progress}\n`)
const homeDir = env.HOME ?? ''
const skipInstall = finale.skipDaemon === true || finale.skipDaemonInstall === true
// The finale restarts the daemon exactly once, so that the config written
// just before it takes effect. `willRestart` records that the restart is
// coming; `restartedEarly` records that it has already been spent by the
// proxy-readiness step below, which moves it rather than adding one.
const willRestart = finale.skipDaemon !== true && finale.skipDaemonRestart !== true && !dryRun
let restartedEarly = false

// The attach/start cutoff: backfill imports history strictly before
// this instant so it never overlaps with live gateway capture, which
Expand Down Expand Up @@ -1715,16 +1734,31 @@ export async function runPickerFinale(args) {
}

if (clientsPicked.length > 0 && capabilities.has('hypaware.ai-gateway')) {
// Skipped when no daemon was installed (the join lane restarts only
// after attach, so no CA can appear before it) and on dry runs; the
// wait-or-skip decision itself lives in the helper.
if (!dryRun && !skipInstall) {
await waitForProxyCaBeforeAttach({
config,
env,
stderr,
...(args.waitForCaFn ? { waitForCaFn: args.waitForCaFn } : {}),
})
// Proxy attach preflights on the CA file (LLP 0232
// #proxy-attach-preflight), and only a daemon running the governing
// proxy-mode config mints one. An install just above started such a
// daemon, so the wait alone is enough. A *skipped* install means the
// daemon predates the config this run wrote - the upgrade-on-an-enrolled
// machine shape - and the restart that would put proxy mode on the wire
// sat at the end of the finale, after the only chance to attach. Bring
// that restart forward instead of adding one: the tail restart below
// stands down, backfill still runs against the `backfillUntil` cutoff
// taken before any of this, and the ordering matches the fresh-install
// path, where the install starts the daemon before attach too.
// @ref LLP 0243#composed-default [implements]: an install whose gateway runs in proxy mode attaches in proxy mode, whether or not this run installed the daemon
if (!dryRun) {
if (skipInstall && willRestart && await governingGatewayProxyMode({ config, env })) {
await restartFinaleDaemon({ homeDir, stderr, summary, ...(args.restartDaemonFn ? { restartDaemonFn: args.restartDaemonFn } : {}) })
restartedEarly = true
}
if (!skipInstall || restartedEarly) {
await waitForProxyCaBeforeAttach({
config,
env,
stderr,
...(args.waitForCaFn ? { waitForCaFn: args.waitForCaFn } : {}),
})
}
}
/** @type {AiGatewayCapability} */
const gateway = capabilities.require('hyp-core/walkthrough', 'hypaware.ai-gateway', '^2.0.0')
Expand Down Expand Up @@ -1877,16 +1911,8 @@ export async function runPickerFinale(args) {
writeAttachedNotConfiguredWarning({ clients: summary.attachedNotConfigured, stdout, dryRun })
}

if (!finale.skipDaemon && !finale.skipDaemonRestart && !dryRun) {
try {
const { restartServiceDaemon } = await import('../daemon/install.js')
await restartServiceDaemon({ ...(homeDir ? { homeDir } : {}) })
summary.daemonRestart = { skipped: false, dryRun: false, ok: true }
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
stderr.write(`daemon restart failed: ${message}\n`)
summary.daemonRestart = { skipped: false, dryRun: false, ok: false }
}
if (willRestart && !restartedEarly) {
await restartFinaleDaemon({ homeDir, stderr, summary, ...(args.restartDaemonFn ? { restartDaemonFn: args.restartDaemonFn } : {}) })
} else if (dryRun && !finale.skipDaemon) {
summary.daemonRestart = { skipped: false, dryRun: true, ok: true }
stdout.write(`(dry-run) Would restart the daemon\n`)
Expand All @@ -1895,6 +1921,39 @@ export async function runPickerFinale(args) {
return summary
}

/**
* The finale's one daemon restart, wherever in the lane it falls. A failure
* is reported and recorded, never thrown: the config is written and the
* clients are attached either way, and `hyp daemon restart` is the repair.
*
* Injectable for the same reason the install seam is: the real service-manager
* call refuses to spawn launchd or systemd under the test runner (LLP 0181),
* so the ordering this function participates in would otherwise be untestable.
*
* @param {{
* homeDir: string,
* stderr: { write(chunk: string): unknown },
* summary: FinaleSummary,
* restartDaemonFn?: (options: { homeDir?: string }) => Promise<void>,
* }} args
* @returns {Promise<void>}
*/
async function restartFinaleDaemon({ homeDir, stderr, summary, restartDaemonFn }) {
try {
const options = { ...(homeDir ? { homeDir } : {}) }
if (restartDaemonFn) await restartDaemonFn(options)
else {
const { restartServiceDaemon } = await import('../daemon/install.js')
await restartServiceDaemon(options)
}
summary.daemonRestart = { skipped: false, dryRun: false, ok: true }
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
stderr.write(`daemon restart failed: ${message}\n`)
summary.daemonRestart = { skipped: false, dryRun: false, ok: false }
}
}

/**
* The plugin names the org's central layer declares, read-only and
* best-effort. A centrally named client is attached and reversed by the
Expand Down
49 changes: 47 additions & 2 deletions src/core/cli/wizard/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
/**
* @import { PluginCatalog } from '../../../../src/core/types.js'
* @import { FinaleSummary, PickerSource } from '../../../../src/core/cli/types.js'
* @import { CollectStatusOptions, HypAwareStatusReport } from '../../../../src/core/daemon/types.js'
* @import { ClientAttachReport, CollectStatusOptions, HypAwareStatusReport } from '../../../../src/core/daemon/types.js'
* @import {
* FirstAskResult,
* FirstLookOutcome,
Expand All @@ -29,6 +29,7 @@ import {
buildWalkthroughClientDescriptorMap,
defaultConfirmSelectPromptFactory,
defaultPickerDetect,
governingGatewayProxyMode,
runPickerFinale,
writeAttachedNotConfiguredReminder,
writeWalkthroughRunSummary,
Expand Down Expand Up @@ -861,6 +862,12 @@ function printJoinFailure(opts, join) {
* already-attached clients skip attach (LLP 0134 #login-lane: the finale
* detects and skips what enrollment already did).
*
* "Already attached" has to mean attached *the way this install attaches*.
* A marker alone does not say that: on a machine upgrading from a base-URL
* version to a proxy-mode one, every picked client carries a marker, and
* reading it as done is what left the upgrade in base-URL mode with no
* further prompt. So a marker recording the other mode is not a skip.
*
* @param {{
* opts: RunInitWizardOptions,
* picked: WizardPickResult,
Expand All @@ -876,7 +883,18 @@ async function runWizardFinale({ opts, picked, joinedAlready, progress }) {
if (joinedAlready) {
const report = await collectStatusSafe(opts)
if (report?.daemon?.installed) finaleActions.skipDaemonInstall = true
const attached = (report?.clients ?? []).filter((c) => c.attached).map((c) => c.name)
const proxyMode = await governingGatewayProxyMode({ config: picked.config, env: opts.env })
// The picked rows that attach their client through the proxy, by plugin:
// the status report names each client's plugin, and the picker row is
// where `gateway_proxy_mode` is declared (LLP 0243 #composed-default).
const proxyAttachPlugins = new Set(
picked.descriptors
.filter((descriptor) => descriptor.compose?.gateway_proxy_mode === true)
.map((descriptor) => descriptor.plugin)
)
const attached = (report?.clients ?? [])
.filter((client) => client.attached && !attachModeIsStale({ client, proxyMode, proxyAttachPlugins }))
.map((client) => client.name)
if (attached.length > 0) skipAttachClients = new Set(attached)
}

Expand Down Expand Up @@ -919,6 +937,33 @@ async function runWizardFinale({ opts, picked, joinedAlready, progress }) {
)
}

/**
* Whether an existing attach marker records a mode this install no longer
* attaches in, which makes re-attaching the point rather than the waste the
* skip exists to avoid.
*
* Only one direction is stale: proxy mode is on and the client's row attaches
* through the proxy, but the marker says base URL (or predates modes and says
* nothing, which is the same base-URL attach without the label). The reverse
* is not this function's call - a proxy marker on an install whose gateway
* dropped proxy mode is the LLP 0244 offer's business, and re-attaching a
* client whose row never attaches by proxy would only rewrite the file it
* already has.
*
* @ref LLP 0244 [implements]: a base-URL attach on a proxy-mode install is unfinished migration, not a completed attach
* @param {{
* client: ClientAttachReport,
* proxyMode: boolean,
* proxyAttachPlugins: Set<string>,
* }} args
* @returns {boolean}
*/
function attachModeIsStale({ client, proxyMode, proxyAttachPlugins }) {
if (!proxyMode) return false
if (!proxyAttachPlugins.has(client.plugin)) return false
return client.mode !== 'proxy'
}

/**
* State, clearly, that nothing has been uploaded and when the first
* upload happens. Reads the first-sync hold the join lane's login wrote
Expand Down
10 changes: 9 additions & 1 deletion src/core/daemon/status.js
Original file line number Diff line number Diff line change
Expand Up @@ -999,6 +999,7 @@ export async function collectHypAwareStatus(opts = {}) {
...(probe.settingsPath ? { settingsPath: probe.settingsPath } : {}),
...(probe.version !== undefined ? { version: probe.version } : {}),
...(probe.port !== undefined ? { port: probe.port } : {}),
...(probe.mode !== undefined ? { mode: probe.mode } : {}),
...(probe.error !== undefined ? { error: probe.error } : {}),
})
// Deliberately ungated by `attachable`, unlike the two derived-state
Expand Down Expand Up @@ -1590,9 +1591,15 @@ function readRetention(config) {
* "not attached" off a path the manifest never named is the one answer a
* probe must never give: it looks identical to a correct negative.
*
* The marker's `mode` comes back beside its version and port. Two attaches
* that both leave a marker are not the same attach: a `base_url` marker on a
* proxy-mode install is a stale attach still waiting to be migrated, and a
* caller that can only see `attached: true` reads it as finished.
*
* @ref LLP 0045#settings_file-is-home-relative-and-a-violation-is-loud [implements]: an unresolvable settings_file is an error result, not a silent not-attached
* @ref LLP 0244 [implements]: a base-URL attach on a proxy-capable install is a distinct state, so the probe reports the mode rather than a bare attached flag
* @param {{ descriptor: ClientDescriptor, homeDir: string, env?: NodeJS.ProcessEnv }} args
* @returns {Promise<{ attached: boolean, settingsPath?: string, version?: string, port?: string, error?: string }>}
* @returns {Promise<{ attached: boolean, settingsPath?: string, version?: string, port?: string, mode?: string, error?: string }>}
*/
export async function probeClientAttachFromDescriptor({ descriptor, homeDir, env }) {
if (!homeDir || !descriptor.attachProbe) return { attached: false }
Expand Down Expand Up @@ -1622,6 +1629,7 @@ export async function probeClientAttachFromDescriptor({ descriptor, homeDir, env
settingsPath,
version: typeof markerObj.version === 'string' ? markerObj.version : undefined,
port: typeof markerObj.port === 'number' ? String(markerObj.port) : undefined,
mode: typeof markerObj.mode === 'string' ? markerObj.mode : undefined,
}
}

Expand Down
8 changes: 8 additions & 0 deletions src/core/daemon/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,14 @@ export interface ClientAttachReport {
version?: string
/** Local gateway port the adapter routes through, when recorded. */
port?: string
/**
* Attach mode recorded in the marker (`proxy` or `base_url`), when the
* adapter recorded one. Absent for a marker written before modes existed
* and for probe formats that carry no marker object. Read it as evidence
* of *which* attach happened: `attached` alone cannot tell a proxy attach
* from the base-URL attach LLP 0244 migrates away from.
*/
mode?: string
/** Probe error string, when the file was unreadable. */
error?: string
}
Expand Down
Loading
Loading