diff --git a/docs/ACCEPTANCE.md b/docs/ACCEPTANCE.md index 8aa66678..cbacae8f 100644 --- a/docs/ACCEPTANCE.md +++ b/docs/ACCEPTANCE.md @@ -218,12 +218,16 @@ procedure has nothing to assert about a "deferred" turn. because the reader upstream of it has nothing to hand it. A red step 5 or 6 against an unmerged #552 is not evidence of a Lane B regression; confirm the merge before filing anything. -- **The `client_attach` status-row re-confirmation in steps 1 and 7 needs - PR #553 (fix/issue-544) merged.** Without it, a now-probed `openclaw` - (its `attach_probe` is real again as of this change set, for the first - time since [LLP 0143](../llp/0143-openclaw-registers-no-attach-probe.decision.md)) - falls back to whatever pre-#553 `hyp status` did for a client that used - to be probe-less, which this procedure was not written to describe. +- **The `client_attach` status-row re-confirmation in steps 1 and 7 needs no + pending PR.** `openclaw` declares a real `attach_probe` again as of this + change set, for the first time since + [LLP 0143](../llp/0143-openclaw-registers-no-attach-probe.decision.md) + removed it and [LLP 0169](../llp/0169-openclaw-attach-surface-returns.decision.md) + brought it back, so `hyp status` derives its row from disk like any other + probed client. The probe-less `attach n/a` rendering + ([LLP 0229 #status-derives-by-the-same-gate](../llp/0229-status-derives-attach-state-by-the-desired-gate.decision.md#status-derives-by-the-same-gate)) + applies to `claude-desktop`, not to `openclaw`, and is not what this + procedure checks. **Related:** [LLP 0167](../llp/0167-openclaw-capture-via-config-provider-override.rfc.md) (the override design and the verify-results this procedure re-confirms), @@ -269,9 +273,9 @@ procedure checks, R11 in particular), [LLP 0172](../llp/0172-openclaw-two-lane-c Pass condition: `hyp status` shows a running daemon and `openclaw [configured, attached]` among the clients, with no - `client_attach_missing` diagnostic (this is the PR #553 re-confirmation: - a probe-less `openclaw` used to be stuck reading as `attach n/a` - regardless of what was on disk). The `jq` output shows + `client_attach_missing` diagnostic (this is the LLP 0169 re-confirmation: + with the probe back, that row is read off disk again rather than reported + as not applicable). The `jq` output shows `anthropic.baseUrl` as the bare gateway origin and `openai.baseUrl` as the same origin plus `/v1`, both carrying `headers["x-hypaware-upstream"]` set to their own key, and **both carrying `models: []`**. That empty @@ -475,9 +479,10 @@ procedure checks, R11 in particular), [LLP 0172](../llp/0172-openclaw-two-lane-c relies on hot reload, it only prints the restart instruction. - Step 1 finds `client_attach_missing` still firing after a successful attach and restart: check `openclaw --version` against the floor in - **Requires** first, then confirm PR #553 is actually in the binary under - test (a probe-less-client `attach n/a` state is exactly what an unmerged - #553 reproduces here). + **Requires** first, then re-run step 1's `jq` check. The warning is + probe-derived, so it means the probe read `openclaw.json` and found no + HypAware-owned entry: either the write did not land, or it landed + somewhere other than the `OPENCLAW_HOME` the probe reads. - Step 4 finds no rows at all: check `hyp status` for a stopped daemon, then re-run step 1's `jq` check for a config that did not actually write (a concurrent edit under `openclaw.json` fails the write's mtime guard diff --git a/llp/0044-client-attach-on-join.decision.md b/llp/0044-client-attach-on-join.decision.md index 858ac689..98677123 100644 --- a/llp/0044-client-attach-on-join.decision.md +++ b/llp/0044-client-attach-on-join.decision.md @@ -7,7 +7,7 @@ **Date:** 2026-06-26 **Related:** LLP 0011, LLP 0016, LLP 0025, LLP 0031, LLP 0036, LLP 0037, LLP 0041 **Designed-by:** LLP 0045, client attach implementation design -**Extended-by:** LLP 0086, attach tracks the gateway's ephemeral port (the "attach once, done forever" model becomes endpoint-aware: re-attach on a daemon rebind, and manual `hyp attach` discovers the live port) +**Extended-by:** LLP 0086, attach tracks the gateway's ephemeral port (the "attach once, done forever" model becomes endpoint-aware: re-attach on a daemon rebind, and manual `hyp attach` discovers the live port); [LLP 0229](./0229-status-derives-attach-state-by-the-desired-gate.decision.md) (#status-derives-by-the-same-gate: the §Status surface `n/a` case is widened to a client `desired()` would never name, because `pending` is a claim the reconciler will act and must be derived by `desired()`'s own gate) **Extended-by:** LLP 0234, decryption follows the routing table and recording follows the path anchor (#context's "the gateway records only traffic a client actually routes to it" is narrowed to "only traffic a registered upstream's path anchor claims" once proxy-mode attach means the transport no longer enforces the aperture; the rows produced are unchanged) > When a machine joins a fleet the central config pulls and the gateway binds, @@ -177,8 +177,14 @@ central config or flip `overall` to `degraded` [LLP 0041](./0041-central-config-client-actions.design.md#idempotency-and-completion-state) `client_action` section, **per client**: `done` (attached, with when) / `failed` (reason + last attempt) / `pending` (named, not yet attached) / `n/a` -(`on_join: false` or non-joined). A failed or pending attach does **not** make -`overall` `degraded`. +(`on_join: false`, non-joined, or a client `desired()` would never name: see +[LLP 0229 #status-derives-by-the-same-gate](./0229-status-derives-attach-state-by-the-desired-gate.decision.md#status-derives-by-the-same-gate)). +A failed or pending attach does **not** make `overall` `degraded`. + +`pending` is a claim that the reconciler *will* act, so status must derive it +by `desired()`'s own rule, not a looser one. Where the two can drift, they +share a reader: `readAttachPolicy` for the `on_join` tri-state, and the +`attachProbe` presence check for attach-eligibility. ## Relationship to manual attach diff --git a/llp/0229-status-derives-attach-state-by-the-desired-gate.decision.md b/llp/0229-status-derives-attach-state-by-the-desired-gate.decision.md new file mode 100644 index 00000000..e9b5fd26 --- /dev/null +++ b/llp/0229-status-derives-attach-state-by-the-desired-gate.decision.md @@ -0,0 +1,167 @@ +# LLP 0229: status derives attach state by the reconciler's own desired() gate + +**Type:** Decision +**Status:** Accepted +**Systems:** CLI, Config, Plugins +**Author:** Phil / Claude +**Date:** 2026-08-14 +**Related:** LLP 0044 (#status-surface: the attach-on-join loop and the surface this constrains), LLP 0045 (the attach design `desired()` implements), LLP 0115 (#no-attach-on-join: Claude Desktop, the probe-less client this is derived from), LLP 0139 (#repair-must-be-runnable: the repair rule this leaves standing), LLP 0224 (#repair-surface: why the `client_attach_missing` diagnostic is deliberately outside this gate), LLP 0143 (superseded; made the same observation about OpenClaw, before LLP 0169 gave OpenClaw a probe back), LLP 0169 (the attach surface OpenClaw regained), [#544](https://github.com/hyparam/hypaware/issues/544) + +> `hyp status` and the attach reconciler answered "is this client an attach +> target?" by two different rules, so a client the reconciler will never act on +> was rendered as one it has not acted on *yet*: a `pending` that never +> resolves and a `not attached` that no command can change. Status derives +> attach state by `desired()`'s own gate, or it does not derive it. The +> `client_attach_missing` diagnostic is deliberately left outside the gate, +> because LLP 0224 made it something other than attach state. + +## Context {#context} + +Attach-on-join ([LLP 0044](./0044-client-attach-on-join.decision.md)) is a +reconciled, reversible action. Its precondition is a **reversible write to the +client's own settings file**, and the manifest's `attach_probe` is what makes +that write observable and therefore reversible. So +`action_attach.desired()` skips any client descriptor that declares no +`attach_probe` (`src/core/config/action_attach.js`): attach must be reversible, +and only the probe can reverse it. + +The consequence is total, not partial. For a probe-less client `perform()` +never runs, so **no marker is ever written**, and no marker will ever be +written, on any host, at any time. + +`hyp status` derived two surfaces of attach *state* against the attach contract +without that gate. Both read the reconciler's permanent silence as a permanent +negative: + +- `buildClientActionsReport` built its declared-attach set from every enabled + client descriptor on a joined host, so *no marker* plus *declared* plus + *joined* resolved to `pending`, forever. +- the clients row mapped a probe-less descriptor to `{ attached: false }` and + printed `not attached`, where nothing is attachable. + +This was a missed gate rather than a design choice: the doc comment above +`buildClientActionsReport` already stated the intended invariant, that +`pending` and `n/a` are derived for declared targets the reconciler *would act +on* but has not yet. + +The corpus already has the sibling of this rule. `readAttachPolicy` and +`readBackfillPolicy` exist so that status and the reconciler cannot disagree +about what an `on_join` block means. Attach-eligibility is the second thing the +two sides can disagree about, and until now nothing kept them together. + +## Decision {#decision} + +**Every `hyp status` surface that +reports attach *state* derives it by the same gate `action_attach.desired()` +uses.** A client descriptor with no `attach_probe` is one the reconciler will +never name; status says so rather than reporting a state that can never resolve +([#544](https://github.com/hyparam/hypaware/issues/544)). + +Concretely, and in the general form, not per client: + +- **Client actions.** A probe-less declared target carries `inert: true` and + derives `n/a`. That joins `on_join: false` and a non-joined host as the third + way of saying the same thing: the reconciler is a no-op for this target. It + is **not** dropped from the report. A vanished row is its own wrong answer, + since the reader cannot tell "nothing to do here" from "status forgot to + look". +- **The clients row.** `ClientAttachReport` carries a required + `attachable: boolean`, set from `!!descriptor.attachProbe` and never from a + probe *result*. The text surface prints `attach n/a` instead of + `not attached`. `hyp status --json` carries `attachable` beside an + **unchanged** `attached` boolean, so a consumer pinning `attached` does not + break, and one that wants to distinguish "no marker" from "no such thing as a + marker" reads the new key. + +**The `client_attach_missing` +diagnostic stays outside this gate, and keeps firing for a probe-less client.** +It looks like the third surface and is not one. +[LLP 0224 #repair-surface](./0224-desktop-setup-second-pass.decision.md#repair-surface) +made it the *standing incomplete-setup prompt*, and stopped the wizard +re-offering setup on every reconfigure specifically because that prompt exists. +Gating it here would leave a declined or failed Claude Desktop setup with **no +surface at all**, which is a worse answer than an imprecise one. + +LLP 0224 reached that position with its eyes open: it records, as a known +limitation, that Desktop declares no probe, so the diagnostic "keys on the +enabled plugin alone" and clears only via the reconciler, never by observing +the plist. The fix it names is to give Desktop a plist-reading `attach_probe`, +at which point the client becomes `attachable`, the warning becomes clearable +by observation, and it rejoins this rule with no further decision. That is the +follow-up, and it is the only thing that should retire this exception. Until +then the exception is recorded rather than argued away, because a warning that +over-fires is a nag and a warning that never fires is a silent failure. + +**A probe-less client is unattachable, +not unattached.** A negative we never observed must not be rendered as one we +did. This is a gate, not a new signal: nothing here reports whether a +probe-less client is in fact routing. That remains each adapter's own question, +answered by its own command (Claude Desktop's is +`hyp claude-desktop verify`), not by the attach surface. + +**The gate keys on the +descriptor, never on the probe's outcome.** A client whose probe resolves and +finds no marker, and a client whose probe *errors*, are both `attachable: true` +and keep the full `not attached` / `pending` / `client_attach_missing` trio. An +unresolvable probe is a real negative that a user can act on; the absence of a +probe is not. Collapsing the two would suppress exactly the warning the surface +exists to raise. + +**The rule is stated over the +manifest, not over a client roster.** Which clients are probe-less is a fact +about today's shipped set and changes under us: OpenClaw was probe-less under +[LLP 0143](./0143-openclaw-registers-no-attach-probe.decision.md) and is +probed again under [LLP 0169](./0169-openclaw-attach-surface-returns.decision.md); +`claude-desktop` is the only probe-less client at the time of writing +([LLP 0115 #no-attach-on-join](./0115-claude-desktop-managed-config-attach.decision.md#no-attach-on-join)), +and a fleet's third-party plugin can add another tomorrow without touching +core. Nothing in core enumerates them, and nothing should: the gate reads the +descriptor, so a client that gains or loses a probe changes its status answer +by construction. + +## Consequences {#consequences} + +- Claude Desktop's permanent `attach claude-desktop [pending]` becomes `n/a`, + and its clients row reads `attach n/a`. This is the remaining half of #544: + the OpenClaw half closed when + [LLP 0169](./0169-openclaw-attach-surface-returns.decision.md) gave OpenClaw + a probe back, which made its rows correct without any change here. +- [LLP 0139 #repair-must-be-runnable](./0139-desktop-picker-consent.decision.md#repair-must-be-runnable) + and [LLP 0224 #repair-surface](./0224-desktop-setup-second-pass.decision.md#repair-surface) + are untouched. `hyp status` still prints + `client_attach_missing: ... run 'hyp claude-desktop install'` for a declined + or failed Desktop setup, and the `configure_command` lookup is still reached. + A reader of the clients row now sees `attach n/a` beside that warning: the + two are answering different questions, and the warning's own text names the + command that answers its one. +- `ClientAttachReport.attachable` is **required**, not optional, so a new + construction site that forgets it fails `npm run typecheck` rather than + silently reintroducing the wrong negative. +- A client that regains a probe regains all three states with no core change, + which is what happened to OpenClaw between LLP 0143 and LLP 0169 and is the + reason this rule is worth stating separately from either. + +## Open questions {#open-questions} + +- `desired()` has a second skip this gate does not mirror: it also skips a + descriptor whose plugin registers no runtime client. Status deliberately does + not activate plugins, so it cannot observe that fact and cannot close the + rule the same way. It is unreachable in the shipped set today (the only + adapterless client is also probe-less, and so already gated), and it is named + here so "status derives by the same gate" is not read as fully closed. +- A `done` attach marker written before a client lost its probe still renders + `attach [done]` beside a clients row saying `attach n/a`. + Marker-derived, outside this gate's path, and arguably the honest reading of + both facts, but worth revisiting if a client ever loses a probe with an + installed base. + +## References {#references} + +- [LLP 0044](./0044-client-attach-on-join.decision.md): attach on join, and the status surface this constrains +- [LLP 0045](./0045-client-attach.design.md): the client attach design `desired()` realizes +- [LLP 0115](./0115-claude-desktop-managed-config-attach.decision.md): Claude Desktop registers no `attach_probe` +- [LLP 0139](./0139-desktop-picker-consent.decision.md): the repair-must-be-runnable rule this leaves standing +- [LLP 0224](./0224-desktop-setup-second-pass.decision.md): #repair-surface, the standing incomplete-setup prompt this gate is kept away from +- [LLP 0143](./0143-openclaw-registers-no-attach-probe.decision.md) (Superseded): where this observation was first written down, against OpenClaw +- [LLP 0169](./0169-openclaw-attach-surface-returns.decision.md): OpenClaw's probe returns, which is why the rule is stated over the manifest +- [#544](https://github.com/hyparam/hypaware/issues/544): a probe-less client reads as permanently unattached diff --git a/src/core/commands/status.js b/src/core/commands/status.js index cd5648fc..7b553fd6 100644 --- a/src/core/commands/status.js +++ b/src/core/commands/status.js @@ -180,6 +180,10 @@ export function renderStatusJson({ report, clientNames, datasets, cacheRoot }) { client_attach: report.clients.map((c) => ({ name: c.name, configured: c.configured, + // `attached` stays a boolean for every row so a consumer can keep + // pinning it; `attachable: false` is what says the boolean carries no + // information for this client (#544). + attachable: c.attachable !== false, attached: c.attached, ...(report.layered ? { provenance: report.layered.centralPlugins.includes(c.plugin) ? 'central' : 'local' } @@ -359,7 +363,11 @@ export function renderStatusText({ report, clientNames, datasets, cacheRoot, std seen.add(c.name) const state = [] state.push(c.configured ? 'configured' : 'not in config') - state.push(c.attached ? 'attached' : 'not attached') + // A client with no attach probe has no attach state to report: printing + // `not attached` for it invites a `hyp attach` that is a documented + // no-op and can never change the line (#544). + // @ref LLP 0229#status-derives-by-the-same-gate [implements]: the clients row says attach n/a, not "not attached", for a probe-less client + state.push(c.attachable === false ? 'attach n/a' : c.attached ? 'attached' : 'not attached') stdout.write(` - ${c.name} [${state.join(', ')}]${provenanceTag(report.layered, isCentralPlugin(report.layered, c.plugin))}\n`) if (c.error) stdout.write(` error: ${c.error}\n`) } diff --git a/src/core/daemon/status.js b/src/core/daemon/status.js index ec137dd7..5c5bfe1a 100644 --- a/src/core/daemon/status.js +++ b/src/core/daemon/status.js @@ -893,19 +893,41 @@ export async function collectHypAwareStatus(opts = {}) { const clientDescriptors = catalog?.clientDescriptors ?? new Map() for (const [clientName, descriptor] of clientDescriptors) { const configured = activePlugins.includes(descriptor.plugin) - const probe = descriptor.attachProbe + // Attach state is only a real state for a client that declares an + // `attach_probe`. Without one there is no settings-file write to read back, + // `action_attach.desired()` skips the descriptor for exactly that reason + // (attach must be reversible), so no attach is ever performed and no marker + // is ever written. Deriving `attached: false` from that silence is the wrong + // negative indistinguishable from a right one (#544): the honest answer is + // "not applicable", so the two surfaces that report attach *state* (the + // clients row, and the attach action in `buildClientActionsReport`) read + // this flag rather than a probe result that was never taken. The + // `client_attach_missing` diagnostic just below deliberately does not. + // @ref LLP 0229#status-derives-by-the-same-gate [implements]: a probe-less client is unattachable, not unattached + const attachable = !!descriptor.attachProbe + const probe = attachable ? await probeClientAttachFromDescriptor({ descriptor, homeDir, env }) : { attached: false } clients.push({ name: clientName, plugin: descriptor.plugin, configured, + attachable, attached: probe.attached, ...(probe.settingsPath ? { settingsPath: probe.settingsPath } : {}), ...(probe.version !== undefined ? { version: probe.version } : {}), ...(probe.port !== undefined ? { port: probe.port } : {}), ...(probe.error !== undefined ? { error: probe.error } : {}), }) + // Deliberately ungated by `attachable`, unlike the two derived-state + // surfaces above and below. This is not attach state: it is the standing + // incomplete-setup prompt LLP 0224 #repair-surface leans on after it + // stopped the wizard re-offering setup on every reconfigure. For a + // probe-less client it cannot be cleared by observation, which LLP 0224 + // records as a known limitation with its own named follow-up (give Desktop + // a plist-reading probe); until that lands, an unclearable prompt beats the + // only alternative, which is no surface at all. + // @ref LLP 0229#diagnostic-is-out-of-scope [constrained-by]: the gate governs derived attach state, not the setup-completeness prompt if (configured && !probe.attached) { // The repair is `hyp attach` only for a client whose plugin registers a // runtime adapter the generic reconciler can drive. A client that @@ -1239,25 +1261,33 @@ function buildClientActionsReport({ status, config, hasCentral, clientDescriptor if (entry.enabled === false) continue enabledByPlugin.set(entry.name, entry) } - /** @type {Map} */ + /** @type {Map} */ const declaredAttach = new Map() for (const [clientName, descriptor] of clientDescriptors ?? new Map()) { const entry = enabledByPlugin.get(descriptor.plugin) if (!entry) continue + // A probe-less descriptor is the third way the reconciler is a no-op, next + // to `on_join: false` and a non-joined host. `desired()` skips it because + // attach must be reversible and only the probe can reverse it, so no marker + // will ever appear and `pending` would be permanent (#544). Same shape as + // the `readAttachPolicy` sharing above: status must not derive a target the + // reconciler would never name. + // @ref LLP 0229#status-derives-by-the-same-gate [implements]: a probe-less attach target is n/a, never pending + const inert = !descriptor.attachProbe const raw = entry.config?.attach const hasBlock = !!raw && typeof raw === 'object' && !Array.isArray(raw) if (hasBlock) { const onJoin = readAttachPolicy(entry).onJoin !== false - declaredAttach.set(clientName, { onJoin }) + declaredAttach.set(clientName, { onJoin, inert }) } else if (hasCentral) { - declaredAttach.set(clientName, { onJoin: true }) + declaredAttach.set(clientName, { onJoin: true, inert }) } } // Kinds to render: every kind the markers record, plus a kind for each // handler that declared a target (so a configured-but-unrun target shows even // with no marker yet). `backfill` keys by plugin, `attach` by client name. - /** @type {Record>} */ + /** @type {Record>} */ const declaredByKind = { backfill: declared, attach: declaredAttach } /** @type {Set} */ const kinds = new Set(Object.keys(byKind)) @@ -1303,10 +1333,11 @@ function buildClientActionsReport({ status, config, hasCentral, clientDescriptor }) } else { // No marker: a declared backfill or attach target. Suppressed - // (on_join:false) or inert (host never joined → the reconciler is a - // no-op) → n/a; otherwise desired and simply not run yet → pending. + // (on_join:false), inert (host never joined, or the handler's own + // `desired()` would skip this target) → the reconciler is a no-op → + // n/a; otherwise desired and simply not run yet → pending. const decl = declaredForKind?.get(requestKey) - const suppressed = decl ? !decl.onJoin : false + const suppressed = decl ? !decl.onJoin || decl.inert === true : false const state = suppressed || !hasCentral ? 'n/a' : 'pending' actions.push({ kind, requestKey, state }) } diff --git a/src/core/daemon/types.d.ts b/src/core/daemon/types.d.ts index 16551df2..26bbeb2a 100644 --- a/src/core/daemon/types.d.ts +++ b/src/core/daemon/types.d.ts @@ -205,7 +205,17 @@ export interface ClientAttachReport { plugin: string /** Plugin enabled in config. */ configured: boolean - /** Settings file carries the HypAware marker. */ + /** + * The client declares an `attach_probe`, so attach is a state that can be + * observed (and reversed). False for a probe-less client (`claude-desktop` + * is the only one shipping today, LLP 0115 #no-attach-on-join), whose attach + * state is not applicable rather than negative: `attached` is then + * structurally false and means nothing (LLP 0229 + * #status-derives-by-the-same-gate). Read off the descriptor, so a client + * that gains or loses a probe changes this answer with no core change. + */ + attachable: boolean + /** Settings file carries the HypAware marker. Only meaningful when `attachable`. */ attached: boolean /** Path the probe inspected. */ settingsPath?: string diff --git a/test/core/init-configured-entry.test.js b/test/core/init-configured-entry.test.js index 2c388e77..9793131c 100644 --- a/test/core/init-configured-entry.test.js +++ b/test/core/init-configured-entry.test.js @@ -76,9 +76,9 @@ test('renderConfigSummary: a fleet-managed install marks each client synced vs l const report = makeReport({ layered: /** @type {any} */ ({ hasCentral: true, centralPlugins: [], centralSinks: [], drops: [], centralQueryIgnored: false }), clients: [ - { name: 'claude', plugin: '@hypaware/claude', configured: true, attached: true }, - { name: 'codex', plugin: '@hypaware/codex', configured: true, attached: true }, - { name: 'openclaw', plugin: '@hypaware/openclaw', configured: true, attached: false }, + { name: 'claude', plugin: '@hypaware/claude', configured: true, attachable: true, attached: true }, + { name: 'codex', plugin: '@hypaware/codex', configured: true, attachable: true, attached: true }, + { name: 'openclaw', plugin: '@hypaware/openclaw', configured: true, attachable: true, attached: false }, ], clientSync: { syncing: ['claude', 'codex'], localOnly: ['openclaw'] }, sinks: [{ instance: 'central', plugin: '@hypaware/central', kind: 'request' }], diff --git a/test/core/status-client-error.test.js b/test/core/status-client-error.test.js index 70c6d3e6..d4563d0f 100644 --- a/test/core/status-client-error.test.js +++ b/test/core/status-client-error.test.js @@ -61,6 +61,10 @@ async function reportWithProbeError(hypHome) { name: 'claude-desktop', plugin: '@hypaware/claude-desktop', configured: false, + // The row under test is one whose probe *was* declared and then failed to + // resolve, which is the whole point of `error`: an unresolvable probe is + // not the same as no probe (LLP 0229 #status-derives-by-the-same-gate). + attachable: true, attached: false, error: PROBE_ERROR, }) diff --git a/test/core/status-probeless-client.test.js b/test/core/status-probeless-client.test.js new file mode 100644 index 00000000..e29a0a79 --- /dev/null +++ b/test/core/status-probeless-client.test.js @@ -0,0 +1,155 @@ +// @ts-check + +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' + +import { collectHypAwareStatus } from '../../src/core/daemon/status.js' +import { defaultConfigPath } from '../../src/core/config/schema.js' +import { centralSeedPath } from '../../src/core/config/apply.js' +import { renderStatusJson, renderStatusText } from '../../src/core/commands/status.js' + +/** + * @import { ClientActionReport } from '../../src/core/daemon/types.js' + */ + +// A probe-less client (`contributes.client` with no `attach_probe`: +// claude-desktop per LLP 0115 #no-attach-on-join, the only one shipping today) +// is never named by the attach reconciler's `desired()`, so no marker is ever +// written for it. Status must derive by that same rule or it reports two states +// that can never resolve: a permanent `pending` attach action and a +// `not attached` client row (#544). +// @ref LLP 0229#decision [tests]: attach-on-join stays inert for a probe-less client, and status says so +// @ref LLP 0229#diagnostic-is-out-of-scope [tests]: the gate stops at attach state, so the incomplete-setup prompt still fires + +async function makeHome() { + const hypHome = await fs.mkdtemp(path.join(os.tmpdir(), 'hyp-status-probeless-')) + await fs.mkdir(path.join(hypHome, 'hypaware'), { recursive: true }) + return hypHome +} + +/** @param {string} hypHome */ +function env(hypHome) { + return { ...process.env, HYP_HOME: hypHome, HYP_CONFIG: '' } +} + +function makeBuf() { + let value = '' + return { write(/** @type {string} */ chunk) { value += String(chunk); return true }, text() { return value } } +} + +/** @param {ClientActionReport[]} actions */ +function attachByKey(actions) { + /** @type {Map} */ + const m = new Map() + for (const a of actions) if (a.kind === 'attach') m.set(a.requestKey, a) + return m +} + +/** + * A joined host whose central layer enables the probe-less client adapter + * (claude-desktop) and two probed ones: claude, whose probe format is `json`, + * and openclaw, whose `json_path` probe returned with LLP 0169. No attach + * markers yet. The probed clients are the over-suppression guards: whatever + * gates claude-desktop must leave their pending / `not attached` / + * `client_attach_missing` trio intact. openclaw earns its place twice over, + * since it is the client that crossed this gate in the other direction. + * + * @param {string} hypHome + */ +async function joinedWithDesktop(hypHome) { + const stateRoot = path.join(hypHome, 'hypaware') + const seedPath = centralSeedPath(stateRoot) + await fs.mkdir(path.dirname(seedPath), { recursive: true }) + await fs.writeFile(seedPath, JSON.stringify({ + version: 2, + plugins: [ + { name: '@hypaware/central' }, + { name: '@hypaware/ai-gateway' }, + { name: '@hypaware/claude-desktop' }, + { name: '@hypaware/claude' }, + { name: '@hypaware/openclaw' }, + ], + sinks: { central: { plugin: '@hypaware/central', config: {} } }, + }) + '\n') + await fs.writeFile(defaultConfigPath(hypHome), JSON.stringify({ version: 2, plugins: [] }) + '\n') + // A home with no client settings files at all: nothing carries a marker, so + // the probed clients are genuinely unattached and the probe-less one has + // nothing that could ever carry one. + const homeDir = path.join(hypHome, 'home') + await fs.mkdir(homeDir, { recursive: true }) + return await collectHypAwareStatus({ env: env(hypHome), homeDir }) +} + +test('a probe-less client on a joined host renders attach n/a, never a permanent pending (#544)', async () => { + const hypHome = await makeHome() + const report = await joinedWithDesktop(hypHome) + + const attach = attachByKey(report.clientActions?.actions ?? []) + // The reconciler's `desired()` skips a probe-less descriptor, so `perform()` + // never runs and no marker is ever written: `pending` would be permanent. + assert.equal(attach.get('claude-desktop')?.state, 'n/a') + // Over-suppression guards: a probed client with no marker is still pending. + assert.equal(attach.get('claude')?.state, 'pending') + assert.equal(attach.get('openclaw')?.state, 'pending') + + const stdout = makeBuf() + renderStatusText({ report, clientNames: [], datasets: [], cacheRoot: '/tmp/cache', stdout }) + const text = stdout.text() + assert.match(text, /attach claude-desktop\s+\[n\/a\]/) + assert.match(text, /attach claude\s+\[pending\]/) + assert.match(text, /attach openclaw\s+\[pending\]/) +}) + +test('the gate stops at attach state: client_attach_missing still fires for a probe-less client (#544)', async () => { + const hypHome = await makeHome() + const report = await joinedWithDesktop(hypHome) + + const missing = report.diagnostics.filter((d) => d.kind === 'client_attach_missing') + // Deliberately NOT gated. LLP 0224 #repair-surface made this the standing + // incomplete-setup prompt and stopped the wizard re-offering setup because + // it exists, so gating it here would leave a declined Desktop setup with no + // surface at all. Pinned so the exception cannot be closed by accident along + // with the two state surfaces above. + const desktop = missing.find((d) => d.message.includes('claude-desktop')) + assert.ok(desktop, `expected client_attach_missing for claude-desktop, got: ${missing.map((d) => d.message).join(' | ')}`) + assert.deepEqual(desktop.repair, ['hyp claude-desktop install']) + // The probed clients are unaffected in the other direction. + assert.ok(missing.some((d) => d.message.includes("'@hypaware/claude'"))) + assert.ok(missing.some((d) => d.message.includes("'@hypaware/openclaw'"))) +}) + +test('a probe-less client row reads attach n/a, not "not attached" (#544)', async () => { + const hypHome = await makeHome() + const report = await joinedWithDesktop(hypHome) + + const desktop = report.clients.find((c) => c.name === 'claude-desktop') + assert.ok(desktop, 'expected a claude-desktop client row') + assert.equal(desktop.configured, true) + assert.equal(desktop.attachable, false) + + for (const name of ['claude', 'openclaw']) { + const probed = report.clients.find((c) => c.name === name) + assert.ok(probed, `expected a ${name} client row`) + assert.equal(probed.attachable, true) + assert.equal(probed.attached, false) + } + + const stdout = makeBuf() + renderStatusText({ report, clientNames: [], datasets: [], cacheRoot: '/tmp/cache', stdout }) + const text = stdout.text() + assert.match(text, /- claude-desktop {2}\[configured, attach n\/a\]/) + assert.match(text, /- claude {2}\[configured, not attached\]/) + assert.match(text, /- openclaw {2}\[configured, not attached\]/) + + const json = renderStatusJson({ report, clientNames: [], datasets: [], cacheRoot: '/tmp/cache' }) + const rows = /** @type {any[]} */ (json.client_attach) + assert.equal(rows.find((r) => r.name === 'claude-desktop')?.attachable, false) + assert.equal(rows.find((r) => r.name === 'claude')?.attachable, true) + assert.equal(rows.find((r) => r.name === 'openclaw')?.attachable, true) + // `attached` keeps its type and its place for every row, so a consumer + // pinning it does not break on the new key beside it. + assert.equal(rows.find((r) => r.name === 'claude-desktop')?.attached, false) +})