Skip to content

[AI-2039] kcap daemon service ensure: the flow's daemon-install ladder - #656

Merged
George-Payne merged 6 commits into
mainfrom
ai-2039/daemon-service-ensure
Aug 24, 2026
Merged

[AI-2039] kcap daemon service ensure: the flow's daemon-install ladder#656
George-Payne merged 6 commits into
mainfrom
ai-2039/daemon-service-ensure

Conversation

@George-Payne

Copy link
Copy Markdown
Member

The first-run flow's Done detour ("reach this machine from anywhere") needs one action that makes the daemon service-installed and running. The ladder existed only in the Avalonia wizard (Capacitor.App, being retired by AI-2053): DaemonStepViewModel classifies a fresh service status --json, DaemonMutationLane dispatches and classifies the mutation, and ReasonRouting maps start_gate_reason= tokens to a recovery surface. The CLI had all the primitives but nothing composing them into the ladder the flow drives. This adds that composition.

  • kcap daemon service ensure — from a fresh status read, install when there is no unit or start when the unit is stopped, and report "already enabled" when the daemon is running. Ambiguous states (unknown probe, active transaction, orphan label, stale marker) fail closed to attention with a coded reason — never guessed, never mutated into.
  • Born prompt — the install bakes KCAP_CONSENT_SEED_DEFAULT=prompt (plus the expected-server pin) exactly as the app's MutationEnv does, so an app-installed daemon is born prompt: nothing runs unattended on someone else's say-so.
  • Gated start — on launchd, the start runs the verified transaction with the seed directive in its gate env, so a unit not baked prompt is refused with verify_start_gate (28) and one start_gate_reason= line, mapped machine-readably to recovery_surface=takeover|reinstall|attention via the pinned ReasonRouting table — never derived from prose.
  • Degraded end state off-macOS — Windows/Linux get plain install/start (no gates, no rollback); ensure --json reports "verified":false so the flow's copy can say so.
  • ReasonRouting/RecoverySurface move to Capacitor.Cli.Core — the CLI cannot reference the app, both reference Core; the pinned token→surface table now has one home (same rescue shape as AI-2167), with the app's references updated.
  • Machine-readable result--json emits a snake_case ServiceEnsureJson (service id, fresh state, action, outcome, recovery, reason, verified) through the shared ServiceJsonContext, including on the pre-flight refusals.

Windows answer to AI-2039's open question, established while wiring the install: the daemon itself is fully cross-platform (ConPTY, Scheduled Task service, win-x64 npm, Windows CI), hosted agents and the server→daemon SignalR path work there; what is Windows-gated is the local kcap agent drive and the launchd-only verified transaction. The detour therefore shows everywhere; the copy reflects plain install off-macOS.

Tests: 28 new/moved unit tests (classifier rows incl. the launchd stopped-but-installed shape, born-prompt bake, JSON render, failure→recovery mapping, dispatch rows); ReasonRouting tests moved with the type. Full CLI suite 3481, Core 2009, App 1157, all green; AOT publish clean.

Adds 'kcap daemon service ensure': from a fresh status read, install when
there is no unit or start when the unit is stopped, baking the born-prompt
consent directive on install and gating the start exactly as an app-managed
start is. A gate refusal exits with the verify transaction's coded exit plus
one start_gate_reason= line, mapped machine-readably to
recovery_surface=takeover|reinstall|attention via the pinned ReasonRouting
table — never guessed at from prose. On non-launchd the ladder degrades to
plain install/start; --json reports verified:false so the flow's copy can say
so. Ambiguous states (unknown probe, active transaction, orphan label, stale
marker) fail closed to attention with a coded reason.

Moves ReasonRouting/RecoverySurface from the retiring Capacitor.App into
Capacitor.Cli.Core so the CLI and the app share one pinned mapping (the same
rescue shape as AI-2167). Adds --json output (ServiceEnsureJson), pure
classifier + failure-map, and unit tests.
@linear-code

linear-code Bot commented Aug 24, 2026

Copy link
Copy Markdown

AI-2039

@qodo-code-review

qodo-code-review Bot commented Aug 24, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Ensure gate missing profile ✓ Resolved 🐞 Bug ≡ Correctness
Description
daemon service ensure extracts --profile but does not fall back to the resolved active profile
name, and EnsureGateEnv then returns null for KCAP_PROFILE; the launchd start gate treats an
empty KCAP_PROFILE as IdentityMismatch, so ensure cannot start a stopped-but-installed service
unless --profile is explicitly supplied.
Code

src/Capacitor.Cli/Commands/DaemonServiceCommands.cs[R300-303]

+    internal async Task<int> Ensure(string[] args) {
+        var json        = args.Contains("--json");
+        var profileName = DaemonCommands.ExtractFlagValue(args, "--profile");
+
Evidence
The new ensure verb only reads --profile and passes it into EnsureGateEnv. EnsureGateEnv
forces KCAP_PROFILE to that value (null when no flag). The start gate’s identity check fails
closed when the invoking profile is null/empty, producing IdentityMismatch and a
verify_start_gate refusal instead of a verified start.

src/Capacitor.Cli/Commands/DaemonServiceCommands.cs[300-307]
src/Capacitor.Cli/Commands/DaemonServiceCommands.cs[457-465]
src/Capacitor.Cli/Services/ServiceVerify.cs[545-550]
src/Capacitor.Cli/Commands/DaemonServiceCommands.cs[92-94]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`kcap daemon service ensure` currently computes `profileName` only from the `--profile` flag. When omitted, `EnsureGateEnv` returns `null` for `KCAP_PROFILE`, which triggers the launchd start gate’s identity check to fail closed (`IdentityMismatch`). This breaks the primary macOS “unit present but stopped → start --verify” ensure arm.

### Issue Context
- `Install` already uses `ExtractFlagValue(..) ?? AppConfig.ResolvedProfile?.ProfileName` to default the profile pin.
- The start gate requires the invoking environment to carry a non-empty `KCAP_PROFILE`.

### Fix Focus Areas
- src/Capacitor.Cli/Commands/DaemonServiceCommands.cs[300-303]
- src/Capacitor.Cli/Commands/DaemonServiceCommands.cs[460-465]

### Suggested change
- Compute an `effectiveProfileName` for ensure similar to install:
 - `var effectiveProfileName = ExtractFlagValue(args, "--profile") ?? AppConfig.ResolvedProfile?.ProfileName;`
- Use `effectiveProfileName` (not the raw flag value) when:
 - building the unit env (`EnsureUnitEnv` / `ServiceEnvironment.Capture`)
 - building the gate env (`EnsureGateEnv`), ideally letting `KCAP_PROFILE` fall through to process env when `effectiveProfileName` is null.
- Add/adjust unit tests to cover: ensure start on launchd with no `--profile` does not fail the gate due to missing env profile.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Drift JSON contract broken ✓ Resolved 🐞 Bug ≡ Correctness
Description
EnsureFailureMap returns a non-null recovery and a null reason for verify_start_gate_drift,
contradicting ServiceEnsureJson’s documented contract (recovery only on gate refusal; drift should
carry a verify_* token) and producing an empty reason string in non-JSON output.
Code

src/Capacitor.Cli/Commands/ServiceEnsure.cs[R104-105]

+        if (exit == VerifyExit.StartGateDrift)
+            return (RecoverySurfaceTokens.Token(RecoverySurface.Attention), null);
Evidence
The ensure JSON type explicitly documents that Recovery is only for gate refusals and that other
verified failures (like drift) should carry a verify_* token as Reason. However, the new mapping
returns recovery and drops the reason for drift. The verify engine defines drift as
VerifyExit.StartGateDriftToken (verify_start_gate_drift), which is what ensure should surface in
its JSON.

src/Capacitor.Cli/Commands/ServiceStatusJson.cs[20-26]
src/Capacitor.Cli/Commands/ServiceEnsure.cs[93-110]
src/Capacitor.Cli/Services/ServiceVerify.cs[66-72]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
For `VerifyExit.StartGateDrift` (29), `EnsureFailureMap.Map` currently returns `(recovery:"attention", reason:null)`. This violates the documented `ServiceEnsureJson` contract (recovery non-null only on gate refusal; reason should carry a `verify_*` token for other verified failures) and leads to degraded human output like `Service 'id':  — attention needed.`

### Issue Context
- `ServiceEnsureJson` docs state `Recovery` is non-null only on gate refusal, and `Reason` is a `verify_*` token for other verified failures.
- The verify engine defines `verify_start_gate_drift` as the stable token for drift.

### Fix Focus Areas
- src/Capacitor.Cli/Commands/ServiceEnsure.cs[85-110]
- src/Capacitor.Cli/Commands/ServiceStatusJson.cs[20-26]

### Suggested change
- Remove the special-case branch for `VerifyExit.StartGateDrift` and let it fall through to the existing verified-path handling so it yields:
 - `recovery = null`
 - `reason = "verify_start_gate_drift"`
 (or explicitly return `(null, VerifyExitToken(exit))`).
- Update/extend tests to assert drift serializes with `reason:"verify_start_gate_drift"` and `recovery:null`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

3. Ensure tests lack NotInParallel ✓ Resolved 📘 Rule violation ☼ Reliability
Description
DaemonCommandsServiceEnsureTests calls DaemonServiceCommands.Ensure(--json), which writes to
process-global Console.Out, but the new tests are not marked [NotInParallel]. This can race with
other tests that capture/redirect console streams and cause flaky or polluted test output under
parallel execution.
Code

test/Capacitor.Cli.Tests.Unit/Commands/DaemonCommandsServiceEnsureTests.cs[R30-36]

+    [Test]
+    public async Task Unknown_probe_fails_closed_with_reason() {
+        var manager = new FakeManager {
+            QueryResult = new ServiceQuery(LabelProbe.Unknown, false, ServiceState.NotInstalled, null, null)
+        };
+        var exit = await new DaemonServiceCommands(Daemons.Store, manager, "test-id").Ensure(["--json"]);
+        await Assert.That(exit).IsEqualTo(1);
Evidence
PR Compliance ID 12 requires tests that mutate or depend on process-global state like Console to
be serialized with bare [NotInParallel]. The new tests call Ensure(["--json"]), and
Ensure/Report writes to Console.Out, but the test file has no [NotInParallel] annotation on
the class or test methods.

CLAUDE.md: Use Safe Parallelization Constraints for Tests
test/Capacitor.Cli.Tests.Unit/Commands/DaemonCommandsServiceEnsureTests.cs[30-49]
src/Capacitor.Cli/Commands/DaemonServiceCommands.cs[357-363]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
New unit tests invoke code that writes to `Console.Out` (process-global state) without any `[NotInParallel]` constraint. This can interfere with other tests that capture/redirect console output and may introduce flakiness under parallel test execution.

## Issue Context
`DaemonServiceCommands.Ensure(["--json"])` writes JSON to stdout via `Console.Out.WriteLineAsync(...)`. The added test file calls `Ensure` but does not apply `[NotInParallel]` at the class or method level.

## Fix Focus Areas
- test/Capacitor.Cli.Tests.Unit/Commands/DaemonCommandsServiceEnsureTests.cs[12-51]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Context sources
Review mode: ⚖️ Balanced

Grey Divider

Tip of the day
💡 Did you know, you can switch off images and animations for a plain-text comment

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread src/Capacitor.Cli/Commands/DaemonServiceCommands.cs
Comment thread src/Capacitor.Cli/Commands/ServiceEnsure.cs Outdated
- Default the profile to the resolved active one, so a bare 'ensure' on
  launchd still carries KCAP_PROFILE for the start gate's identity half
  (matches how Install resolves the pin).
- Drift now carries verify_start_gate_drift as its reason alongside the
  attention surface — the JSON and the human line no longer read empty.
- Mark the console-writing dispatch tests [NotInParallel].

@realtonyyoung realtonyyoung left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Peer review — automated code-review flow (reviewer vendor: codex, model gpt-5.6-sol)

Ran a 3-round structured code-review flow against this PR. The reviewer signed off after round 3 with seven actionable findings (1 High×3, Medium×3, Low×1). One round-1 finding was withdrawn after context, and findings fixed or superseded mid-flow are not reposted — what follows is only what was still open at sign-off.

Each finding is an inline thread below, anchored to an added line, so it can be resolved individually.

# Severity Summary
1 High EnsureUnitEnv keeps ambient KCAP_URL, so --profile P does not actually pin P
2 High Classifier success arm precedes the orphan_label / stale_marker fail-closed checks
3 High "Already enabled" never proves the service job owns the validated daemon pid
4 Medium ServiceStateToken ignores UnitPresent — a successful start reports state:"not_installed"
5 Low LastGateReason is never cleared on entry, violating its own documented contract
6 Medium EnsureFailure omits Verified, so refused launchd transactions always serialize verified:false
8 Medium Coded viability_reason / refusal_reason evidence is discarded outside exits 28/29

Withdrawn (no action needed): finding 7, "the new verb is absent from the Getting Started surface." The reviewer withdrew it once shown README.md:153 in full — that section points a human at kcap daemon service install and links to the daemon section, while ensure is explicitly flow-driven/machine-facing and is documented at README.md:744 and in help-daemon.txt. Reviewer's words: "Adding it to onboarding would blur the supported human workflow rather than repair missing documentation."

Findings 6 and 8 were deliberately kept separate at the reviewer's call: "Finding 6 drops the verified-path boolean from every refusal DTO; finding 8 drops specific cause/recovery evidence that the engine already determined. They have different fixes and different consumer impact."

Reviewer caveats, carried through verbatim: the PR head f922298 and the branch-only test/design files were not present in the reviewer's launch worktree, so it treated the submitted diff and test descriptions as authoritative and verified the referenced base implementations locally. Tests were not run. I independently confirmed every base-code claim against main before relaying — ServiceEnvironment.cs:15 really does list KCAP_URL among captured keys; LaunchdServiceManager.QueryCore really does yield Probe=Absent, State=NotInstalled, UnitPresent=true for a stopped-but-installed service; ServiceVerify.cs:407 and :710 really are stderr-only Say(...) emissions with no in-process equivalent.

Posted as a COMMENT review, not an approval or a change request.

Comment thread src/Capacitor.Cli/Commands/DaemonServiceCommands.cs
Comment thread src/Capacitor.Cli/Commands/ServiceEnsure.cs Outdated
Comment thread src/Capacitor.Cli/Commands/DaemonServiceCommands.cs Outdated
Comment thread src/Capacitor.Cli/Commands/DaemonServiceCommands.cs
Comment thread src/Capacitor.Cli/Services/ServiceVerify.cs
Comment thread src/Capacitor.Cli/Commands/DaemonServiceCommands.cs Outdated
Comment thread src/Capacitor.Cli/Commands/ServiceEnsure.cs
@realtonyyoung

Copy link
Copy Markdown
Collaborator

NO FINDINGS

@realtonyyoung realtonyyoung left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed the current head after the prior findings were addressed. No actionable findings. Static review only; no build or tests run.

@George-Payne
George-Payne merged commit 8b66aee into main Aug 24, 2026
6 checks passed
@George-Payne
George-Payne deleted the ai-2039/daemon-service-ensure branch August 24, 2026 15:20
George-Payne added a commit that referenced this pull request Aug 24, 2026
AI-2039's ensure ladder (#656) merged to main after this branch was cut;
both PRs added 'using Capacitor.Cli.Core;' to the same app test files, so
the merge ref carried the directive twice (position-independent additions —
git sees no textual conflict, the compiler does). Drop the duplicates.
George-Payne added a commit that referenced this pull request Aug 24, 2026
* feat: rescue LoginShellProbe and PathShimInstaller into Capacitor.Cli.Core

Move the process seam (IProcessRunner + records + the production ProcessRunner
implementation) and the two Avalonia-only setup classes into Core so the flow
can drive them after AI-2053 deletes the app. App consumers pick them up via
using; the installer's destination-override seam becomes public (Core has no
InternalsVisibleTo for the app). Tests move with the classes.

* feat: kcap daemon shim ensure — the flow's PATH-fix capability

The flow's Agents screen PATH warning offers 'fix it for me' and 'show me
the line'. The lane carries values, not paths or commands (retirement spec
6.1), so the fix is a named capability the CLI composes itself: it resolves
its own binary path (never a server-supplied one), probes the interactive
login shell, and on a positive absence links /usr/local/bin/kcap to itself
via the osascript admin prompt, then re-probes so success is never reported
on the symlink alone. Unknown probe, filesystem conflict, and non-macOS all
fail closed with a coded reason. --json emits the outcome the flow keys off.

* fix: address review findings on the shim ensure verb and the Core rescue

- Fail closed on a null post-install re-probe (was asserted as a definitive
  not-on-path diagnosis — the one guess in an otherwise fail-closed ladder).
- Add an independent preflight seam so the conflict row is stubbable; the
  conflict refusal is now covered by a test instead of being untestable.
- Reject unknown flags (--help, typos) before any probe or prompt.
- Make the isMacOs seam nullable so the off-macOS arm can be forced on a
  macOS host (a bool default could not distinguish unspecified from false).
- Reuse the probe instead of constructing a second one for the installer;
  make the classifier types internal.
- Sanitize control bytes from human console output (the JSON arm is already
  escaped by System.Text.Json).
- Fix stale doc references (ServiceProcess comment, ShimOfferCoordinator
  wording, installer class doc) and move the README shim section out of the
  middle of the service prose; design doc no longer cites the unmerged
  service-ensure sibling branch.

* fix: address qodo findings on the shim ensure PR

- Share FakeLoginShellProbe via Capacitor.Tests.Helpers instead of
  duplicating it in the Core and App test suites (the repo rule: cross-suite
  helpers live in Helpers with a public surface).
- Off-macOS refusal (unsupported_platform) now beats an unknown probe in the
  classifier — the flow expects a stable platform row, not a probe-dependent
  one — and the daemon usage line lists the reviewer subcommand it dispatches.

* fix: address review — preserve the npm launcher and the coded conflict row

Two flow-contract corrections from review:

- Link the shim to the npm launcher (kcap.js) when this CLI is part of an
  npm-global install, not to the native binary kcap.js spawned. The launcher
  is what intercepts 'kcap update' and runs npm; linking the native image
  would have made /usr/local/bin/kcap update a no-op. The launcher is a
  sibling package, so its path is derived from the running binary's own
  location with no environment lookup; a standalone binary links to itself.

- Re-preflight after a failed install: the outer preflight and the
  installer's checks are not atomic, so an entry that appears mid-flight (or
  a non-forcing ln -s that loses the race) now still surfaces the coded
  refused/conflict row instead of a generic failed.

* Merge main into ai-2167/let-the-flow-fix-a-broken-kcap-path

AI-2039's ensure ladder (#656) merged to main after this branch was cut;
both PRs added 'using Capacitor.Cli.Core;' to the same app test files, so
the merge ref carried the directive twice (position-independent additions —
git sees no textual conflict, the compiler does). Drop the duplicates.

* fix: platform-neutral launcher-resolution test

GetFullPath both sides of the npm-launcher assertion — on Windows a
hardcoded POSIX path normalizes against the current drive's root, so the
comparison failed on the Windows CI leg.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants