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
17 changes: 14 additions & 3 deletions src/core/cli/wizard/fork.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -124,6 +124,8 @@ export async function legacyForkPrompt(opts, options) {
*
* A missing or invalid config is the first-run path, not the gate: the
* caller falls straight through to `runWizardFork` (no pathway preset).
* `managed` is still reported truthfully on that path, so a machine with
* a central layer keeps its org rows locked (see the derivation below).
* Once a valid config exists, a **managed** machine (the merged config
* carries a central layer, LLP 0031) offers a scoped "adjust what this
* machine collects" entry instead of dropping Reconfigure outright - no
Expand All@@ -148,12 +150,21 @@ export async function evaluateReturningGate(opts) {
const collectStatus = opts.collectStatus ?? collectHypAwareStatus
const report = await collectStatus(/** @type {CollectStatusOptions} */ ({ env: opts.env, runtime: opts.runtime }))

// Read `managed` before the first-run early return: a central layer on
// disk is a property of the machine (LLP 0031), not of whether the
// merged config currently validates. A central layer that stops merging
// cleanly (a server-side config change, client/server schema drift)
// still owns its rows, and the caller reads `managed` to decide whether
// to compute the locked set at all. Reporting `false` here left the
// org's rows editable, so picking one composed it into the local layer.
// @ref LLP 0129#join-before-picker [implements]: central rows lock whenever a central layer exists, invalid merge included
const managed = !!(report.layered && report.layered.hasCentral)

if (!report.configExists || !report.configValid) {
log.info('wizard.returning_gate', { [Attr.COMPONENT]: 'wizard', action: 'first-run', managed: false })
return { action: 'first-run', managed: false, report }
log.info('wizard.returning_gate', { [Attr.COMPONENT]: 'wizard', action: 'first-run', managed })
return { action: 'first-run', managed, report }
}

const managed = !!(report.layered && report.layered.hasCentral)
renderConfigSummary({ report, locked: managed, stdout: opts.stdout })
const options = buildReturningGateOptions(managed)
const action = await promptReturningGateChoice(opts, options, managed)
Expand Down
19 changes: 16 additions & 3 deletions src/core/cli/wizard/index.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -83,6 +83,16 @@ export async function runInitWizard(opts) {
pathway = 'scoped'
managed = true
locked = await computeLockedSafe(catalog, opts)
} else if (gate.action === 'first-run' && gate.managed) {
// A managed machine whose config is missing or fails to merge lands
// on the first-run path (the gate has no config to summarise), but
// the central layer on disk still owns its rows. Lock them here too:
// an editable org row that the user picks composes into the local
// layer, and the next central pull overrides or collides with it.
// The pathway stays unset, so the fork still runs.
// @ref LLP 0129#join-before-picker [implements]: the first-run path locks the org rows from the on-disk central layer rather than offering them for composition
managed = true
locked = await computeLockedSafe(catalog, opts)
}

// 'first-run' and a solo machine's 'reconfigure' both enter here.
Expand DownExpand Up@@ -385,9 +395,12 @@ async function loadWizardCatalog() {
}

/**
* The scoped re-entry's locked-set computation, guarded: a resolution
* failure renders an unlocked picker (additions still compose; the
* export seam, not the picker, enforces the org boundary, LLP 0132).
* The locked-set computation for every entry that reaches the picker on
* an already-managed machine without a join (the scoped re-entry, and the
* first-run path a managed machine falls to when its merged config no
* longer validates), guarded: a resolution failure renders an unlocked
* picker (additions still compose; the export seam, not the picker,
* enforces the org boundary, LLP 0132).
*
* @param {PluginCatalog} catalog
* @param {Pick<RunInitWizardOptions, 'env'>} opts
Expand Down
7 changes: 5 additions & 2 deletions src/core/cli/wizard/join.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -118,8 +118,11 @@ async function runJoinFlow(opts, span) {
* two-layer config from disk and keep every catalog picker id whose
* owning plugin classifies `'central'`. The pick phase locks exactly this
* set (LLP 0129 #join-before-picker). Shared by the join phase (after
* convergence) and the wizard's scoped re-entry, where no join runs but a
* managed machine's org rows must still render locked.
* convergence) and by every wizard entry that reaches the picker on an
* already-managed machine without a join: the scoped re-entry, and the
* first-run path a managed machine falls to when its merged config no
* longer validates. In both, no join runs but the org's rows must still
* render locked.
*
* The classifier needs the catalog as its third argument to resolve a
* source id to its owning plugin (the design sketch elides it for
Expand Down
7 changes: 6 additions & 1 deletion src/core/cli/wizard/types.d.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -52,7 +52,12 @@ export type ReturningGateAction = 'first-run' | 'quit' | 'status' | 'reconfigure

export interface ReturningGateResult {
action: ReturningGateAction
/** True when the merged config carries a central layer (LLP 0031). */
/**
* True when a central layer is on disk (LLP 0031), independently of
* whether the merged config currently exists or validates: enrollment
* is a property of the machine, and the org's rows stay locked even on
* the `first-run` path a broken merge falls to.
*/
managed: boolean
report: HypAwareStatusReport
}
Expand Down
27 changes: 27 additions & 0 deletions test/core/cli/wizard/fork.test.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -127,6 +127,33 @@ test('evaluateReturningGate: an invalid config is also first-run', async () => {
assert.equal(gate.action, 'first-run')
})

// A central layer that stops merging cleanly (a server-side config change,
// client/server schema drift) must not relabel the machine as unmanaged:
// the caller reads `managed` to decide whether to lock the org's rows, and
// an editable org row composes into the local layer.
// @ref LLP 0129#join-before-picker [tests]:
test('evaluateReturningGate: a managed machine with an invalid config is still managed on the first-run path', async () => {
const { opts } = ctxWithStdin('\n')
opts.collectStatus = async () => fixtureReport({ configValid: false, hasCentral: true })
const gate = await evaluateReturningGate(opts)
assert.equal(gate.action, 'first-run')
assert.equal(gate.managed, true)
})

// Defensive coverage of the guard's *other* branch, not a state the
// collector emits: `collectHypAwareStatus` sets `configExists` from a
// non-null effective config, and `mergeConfigLayers` always returns one
// once a central layer loaded, so on a real machine `hasCentral` implies
// `configExists`. Pinned so a future rewrite of the `||` cannot make
// `managed` depend on `configExists` again.
test('evaluateReturningGate: a managed machine with no config at all is still managed', async () => {
const { opts } = ctxWithStdin('\n')
opts.collectStatus = async () => fixtureReport({ configExists: false, hasCentral: true })
const gate = await evaluateReturningGate(opts)
assert.equal(gate.action, 'first-run')
assert.equal(gate.managed, true)
})

test('evaluateReturningGate: managed machine, choosing the scoped entry presets a scoped re-entry (no fork)', async () => {
const { opts, stdout } = ctxWithStdin('1\n')
opts.collectStatus = async () => fixtureReport({ hasCentral: true })
Expand Down
35 changes: 35 additions & 0 deletions test/core/cli/wizard/index.test.js
Original file line numberDiff line numberDiff line change
Expand Up@@ -42,6 +42,21 @@ function emptyCatalog() {
})
}

/**
* Write a central layer (the join seed slot, LLP 0031) under a wizard
* home, so the locked-set computation resolves it from disk exactly as it
* does on a real enrolled machine.
*
* @param {string} home
* @param {string[]} plugins
*/
async function seedCentralLayer(home, plugins) {
const control = path.join(home, '.hyp', 'hypaware', 'config-control')
await fs.mkdir(control, { recursive: true })
const config = { version: 2, plugins: plugins.map((name) => ({ name, enabled: true, config: {} })) }
await fs.writeFile(path.join(control, 'seed.json'), JSON.stringify(config))
}

/** A completed pick result the finale and configure stubs can consume. */
function pickResult(over = {}) {
return /** @type {any} */ ({
Expand DownExpand Up@@ -144,6 +159,26 @@ test('runInitWizard: scoped re-entry skips the fork and picks scoped + managed',
assert.equal(result.pathway, 'scoped')
})

// A managed machine whose merged config no longer validates falls to the
// first-run path, but the central layer on disk still owns its rows. The
// locked set has to be computed there too, or the picker offers the org's
// rows for free composition into the local layer.
// @ref LLP 0129#join-before-picker [tests]:
test('runInitWizard: a managed first run locks the org rows from the on-disk central layer', async () => {
const home = await tmpHome()
await seedCentralLayer(home, ['@hypaware/claude'])
const catalog = emptyCatalog()
catalog.pickerDescriptors.set('claude', { plugin: '@hypaware/claude', id: 'claude', label: 'Claude' })
const { opts } = wizardOpts(home, {
catalog,
gate: async () => ({ action: 'first-run', managed: true, report: {} }),
})
const result = await runInitWizard(opts)
assert.equal(result.exitCode, 0)
assert.deepEqual(opts._pickOpts.locked, ['claude'])
assert.equal(opts._pickOpts.managed, true)
})

// --- the fork/join loop ---

test('runInitWizard: local pathway runs pick -> configure -> finale, no join', async () => {
Expand Down
Loading