From 150dc306b889dd927a32a8d06dfa3f1971d54f74 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Sat, 19 Sep 2026 01:00:01 -0400 Subject: [PATCH 1/7] feat(control): serve a control API over a unix socket `--control-socket ` attaches an HTTP/1.1 server that reads one store and commits through its setters: health, state with long polling, actions, credentials, run arming on the TUI surface, detection and independent runs on the headless surface, a run ledger, and shutdown. The store owns the server, the projection, the generic actions, and the ledger; programs add their own actions through FlowStep.controlActions; the cli supplies the hooks that resolve credentials, run detection, and start one agent run with explicit context. Published builds keep the server for headless runs and refuse the flag on the TUI, which never loads it; the smoke test audits the built chunks and CI diffs the chunk manifests against the committed fixtures. `--e2e-ask` replaces the E2E_ASK environment read in dev builds. Generated-By: PostHog Desktop Task-Id: d14e92bb-6ee1-49b5-8502-39cb80079589 --- .github/workflows/surfaces.yml | 4 + .../__tests__/control-actions-parity.test.ts | 54 +++ scripts/__fixtures__/chunk-manifest.ci.json | 315 ++++++++++++--- scripts/__fixtures__/chunk-manifest.prod.json | 315 ++++++++++++--- scripts/smoke-test.sh | 40 ++ .../boundary-contracts.test.ts.snap | 9 + .../architecture/import-boundaries.test.ts | 16 + .../architecture/startup-graph.test.ts | 7 + src/cli/README.md | 6 + src/cli/__tests__/control-hooks.test.ts | 139 +++++++ src/cli/__tests__/control-refusal.test.ts | 107 +++++ src/cli/__tests__/control-surface.test.ts | 59 +++ src/cli/commands/basic-integration/index.ts | 4 + src/cli/commands/factories/shared.ts | 3 + src/cli/control-hooks.ts | 105 +++++ src/cli/runners/run-non-interactive.ts | 53 ++- src/cli/runners/run-wizard.ts | 56 ++- src/cli/wizard.ts | 60 ++- src/store/README.md | 11 + src/store/control/__tests__/actions.test.ts | 370 +++++++++++++++++ src/store/control/__tests__/server.test.ts | 378 ++++++++++++++++++ src/store/control/__tests__/state.test.ts | 139 +++++++ src/store/control/actions.ts | 231 +++++++++++ src/store/control/client.ts | 157 ++++++++ src/store/control/driver.ts | 64 +++ src/store/control/index.ts | 19 + src/store/control/marker.ts | 5 + src/store/control/params.ts | 27 ++ src/store/control/runs.ts | 61 +++ src/store/control/server.ts | 359 +++++++++++++++++ src/store/control/state.ts | 139 +++++++ src/store/control/types.ts | 141 +++++++ .../steps.ts | 41 +- src/store/programs/error-tracking/index.ts | 5 + src/store/programs/self-driving/steps.ts | 44 ++ src/store/programs/shared/control-actions.ts | 41 ++ src/store/session/wizard-session.ts | 9 +- src/store/shared/environment.ts | 2 +- src/store/state/__tests__/invariants.test.ts | 1 + src/store/state/flow.ts | 6 + src/store/state/store-api.ts | 2 + src/store/state/store.ts | 6 + src/store/types.ts | 19 + src/tui/start-tui.ts | 4 + 44 files changed, 3496 insertions(+), 137 deletions(-) create mode 100644 e2e-harness/__tests__/control-actions-parity.test.ts create mode 100644 src/cli/__tests__/control-hooks.test.ts create mode 100644 src/cli/__tests__/control-refusal.test.ts create mode 100644 src/cli/__tests__/control-surface.test.ts create mode 100644 src/cli/control-hooks.ts create mode 100644 src/store/control/__tests__/actions.test.ts create mode 100644 src/store/control/__tests__/server.test.ts create mode 100644 src/store/control/__tests__/state.test.ts create mode 100644 src/store/control/actions.ts create mode 100644 src/store/control/client.ts create mode 100644 src/store/control/driver.ts create mode 100644 src/store/control/index.ts create mode 100644 src/store/control/marker.ts create mode 100644 src/store/control/params.ts create mode 100644 src/store/control/runs.ts create mode 100644 src/store/control/server.ts create mode 100644 src/store/control/state.ts create mode 100644 src/store/control/types.ts create mode 100644 src/store/programs/shared/control-actions.ts diff --git a/.github/workflows/surfaces.yml b/.github/workflows/surfaces.yml index 173edd1d5..63fb12450 100644 --- a/.github/workflows/surfaces.yml +++ b/.github/workflows/surfaces.yml @@ -81,6 +81,10 @@ jobs: run: npx tsx scripts/chunk-manifest.no-jest.ts dist > chunk-manifest.ci.json + - name: Chunk layout matches the committed fixtures + run: | + diff scripts/__fixtures__/chunk-manifest.prod.json chunk-manifest.prod.json + diff scripts/__fixtures__/chunk-manifest.ci.json chunk-manifest.ci.json - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: chunk-manifests-${{ github.sha }} diff --git a/e2e-harness/__tests__/control-actions-parity.test.ts b/e2e-harness/__tests__/control-actions-parity.test.ts new file mode 100644 index 000000000..e99c31f61 --- /dev/null +++ b/e2e-harness/__tests__/control-actions-parity.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { + ACTION_REGISTRY, + NO_ACTION_SCREENS as HARNESS_NO_ACTION, +} from '@e2e-harness/action-registry'; +import { Interrupt } from '@store'; +import { actionsFor, NO_ACTION_SCREENS } from '@store/control'; +import { flowFor, PROGRAM_REGISTRY } from '@store/programs'; + +/** The store adds the pick the e2e host used to inject through raw setters. */ +const STORE_ONLY: Record = { + 'self-driving-integration-detect': ['pick_integration_target'], + 'error-tracking-detect': ['pick_integration_target'], +}; + +describe('control actions parity with the e2e action registry', () => { + it('offers every harness action on every flow, plus only the documented picks', () => { + const drift: string[] = []; + for (const config of PROGRAM_REGISTRY) { + const { flow } = flowFor(config.id); + const screens = new Set([ + ...flow.steps.flatMap((s) => (s.screenId ? [s.screenId] : [])), + ...Object.values(Interrupt), + ]); + for (const screen of screens) { + const harness = ( + ( + ACTION_REGISTRY as Record | undefined> + )[screen] ?? [] + ).map((a) => a.id); + const store = actionsFor(flow, screen).map((a) => a.id); + for (const id of harness) { + if (!store.includes(id)) + drift.push(`${config.id}:${screen} lost ${id}`); + } + for (const id of store) { + if ( + !harness.includes(id) && + !(STORE_ONLY[screen] ?? []).includes(id) + ) { + drift.push(`${config.id}:${screen} added ${id}`); + } + } + } + } + expect(drift).toEqual([]); + }); + + it('keeps every no-action screen the harness lists, minus the detect screens the picks now cover', () => { + for (const screen of NO_ACTION_SCREENS) { + expect(HARNESS_NO_ACTION.has(screen as never), screen).toBe(true); + } + }); +}); diff --git a/scripts/__fixtures__/chunk-manifest.ci.json b/scripts/__fixtures__/chunk-manifest.ci.json index 49af3ed2a..457f72acf 100644 --- a/scripts/__fixtures__/chunk-manifest.ci.json +++ b/scripts/__fixtures__/chunk-manifest.ci.json @@ -1,10 +1,6 @@ { "add-mcp-server-to-clients.js": { "sources": [ - "src/store/agent-protocol/agent-signals.ts", - "src/store/api.ts", - "src/store/host-resolution.ts", - "src/store/services/oauth/program-scopes.ts", "src/store/services/steps/add-mcp-server-to-clients/MCPClient.ts", "src/store/services/steps/add-mcp-server-to-clients/clients/claude-code.ts", "src/store/services/steps/add-mcp-server-to-clients/clients/claude-web.ts", @@ -16,31 +12,11 @@ "src/store/services/steps/add-mcp-server-to-clients/defaults.ts", "src/store/services/steps/add-mcp-server-to-clients/index.ts", "src/store/services/steps/add-mcp-server-to-clients/plugin-client.ts", - "src/store/services/steps/add-mcp-server-to-clients/results.ts", - "src/store/shared/bounded-fs.ts", - "src/store/shared/errors/agent-map.ts", - "src/store/shared/errors/auth.ts", - "src/store/shared/errors/catalog.ts", - "src/store/shared/errors/codes.ts", - "src/store/shared/errors/detect-map.ts", - "src/store/shared/errors/emit.ts", - "src/store/shared/errors/run-failure.ts", - "src/store/shared/errors/sanitize.ts", - "src/store/shared/errors/skill-map.ts", - "src/store/shared/links.ts", - "src/store/shared/oauth-errors.ts", - "src/store/shared/oauth.ts", - "src/store/shared/package-manager.ts", - "src/store/shared/project-resolution.ts", - "src/store/shared/provisioning.ts", - "src/store/shared/semver.ts", - "src/store/shared/setup-utils.ts", - "src/store/shared/telemetry.ts", - "src/store/shared/urls.ts", - "src/store/shared/wizard-abort.ts" + "src/store/services/steps/add-mcp-server-to-clients/results.ts" ], "imports": [ - "analytics.js" + "analytics.js", + "package-manager.js" ] }, "agent.js": { @@ -80,11 +56,12 @@ "src/agent/runner/switchboard/sequence.ts" ], "imports": [ - "add-mcp-server-to-clients.js", "agent-interface.js", "agent-prompt-loader.js", "analytics.js", + "package-manager.js", "pi.js", + "program-registry.js", "programs.js", "queue-tools.js", "store.js" @@ -105,8 +82,9 @@ "src/agent/triage-provider.ts" ], "imports": [ - "add-mcp-server-to-clients.js", "analytics.js", + "package-manager.js", + "program-registry.js", "queue-tools.js", "store.js" ] @@ -117,24 +95,12 @@ ], "imports": [ "analytics.js", - "queue-tools.js", - "store.js" + "program-registry.js", + "queue-tools.js" ] }, "analytics.js": { - "sources": [ - "src/env.ts", - "src/store/local-dev.ts", - "src/store/session/wizard-session.ts", - "src/store/shared/analytics.ts", - "src/store/shared/ci-flag-overrides.ts", - "src/store/shared/constants.ts", - "src/store/shared/debug.ts", - "src/store/shared/paths.ts", - "src/store/shared/version.ts", - "src/store/ui/index.ts", - "src/store/ui/null-ui.ts" - ], + "sources": [], "imports": [ "analytics.js" ] @@ -152,21 +118,31 @@ "src/cli/commands/basic-integration/ci-install.ts" ], "imports": [ - "add-mcp-server-to-clients.js", "analytics.js", "console.js", - "runners.js", - "store.js" + "package-manager.js", + "program-registry.js", + "runners.js" ] }, "console.js": { + "sources": [], + "imports": [ + "console.js" + ] + }, + "control.js": { "sources": [ - "src/tui/console/headless-ui.ts", - "src/tui/console/logging-ui.ts" + "src/store/control/actions.ts", + "src/store/control/driver.ts", + "src/store/control/marker.ts", + "src/store/control/runs.ts", + "src/store/control/server.ts", + "src/store/control/state.ts" ], "imports": [ "analytics.js", - "store.js" + "program-registry.js" ] }, "interactive.js": { @@ -174,8 +150,8 @@ "src/cli/commands/basic-integration/interactive.ts" ], "imports": [ - "runners.js", - "store.js" + "program-registry.js", + "runners.js" ] }, "main.js": { @@ -214,14 +190,15 @@ "src/cli/wizard.ts" ], "imports": [ - "add-mcp-server-to-clients.js", "agent.js", "analytics.js", "ci-install.js", "console.js", "interactive.js", "non-interactive.js", + "package-manager.js", "playground.js", + "program-registry.js", "runners.js", "store.js", "tui.js" @@ -240,8 +217,8 @@ "src/cli/commands/basic-integration/non-interactive.ts" ], "imports": [ - "add-mcp-server-to-clients.js", - "analytics.js" + "analytics.js", + "package-manager.js" ] }, "orchestrator-tools.js": { @@ -259,6 +236,38 @@ "package-json.js" ] }, + "package-manager.js": { + "sources": [ + "src/store/agent-protocol/agent-signals.ts", + "src/store/api.ts", + "src/store/host-resolution.ts", + "src/store/services/oauth/program-scopes.ts", + "src/store/shared/bounded-fs.ts", + "src/store/shared/errors/agent-map.ts", + "src/store/shared/errors/auth.ts", + "src/store/shared/errors/catalog.ts", + "src/store/shared/errors/codes.ts", + "src/store/shared/errors/detect-map.ts", + "src/store/shared/errors/emit.ts", + "src/store/shared/errors/run-failure.ts", + "src/store/shared/errors/sanitize.ts", + "src/store/shared/errors/skill-map.ts", + "src/store/shared/links.ts", + "src/store/shared/oauth-errors.ts", + "src/store/shared/oauth.ts", + "src/store/shared/package-manager.ts", + "src/store/shared/project-resolution.ts", + "src/store/shared/provisioning.ts", + "src/store/shared/semver.ts", + "src/store/shared/setup-utils.ts", + "src/store/shared/telemetry.ts", + "src/store/shared/urls.ts", + "src/store/shared/wizard-abort.ts" + ], + "imports": [ + "analytics.js" + ] + }, "pi.js": { "sources": [ "src/agent/aio-capture.ts", @@ -266,10 +275,10 @@ "src/agent/runner/harness/pi/index.ts" ], "imports": [ - "add-mcp-server-to-clients.js", "agent-interface.js", "analytics.js", "mcp.js", + "package-manager.js", "security.js", "store.js", "subagent.js", @@ -287,6 +296,141 @@ "tui.js" ] }, + "program-registry.js": { + "sources": [ + "src/store/auth-session-state.ts", + "src/store/control/params.ts", + "src/store/detection/agentic.ts", + "src/store/detection/context.ts", + "src/store/detection/features.ts", + "src/store/detection/framework.ts", + "src/store/detection/package-manager.ts", + "src/store/detection/project-scope.ts", + "src/store/fetch-retry.ts", + "src/store/framework-config.ts", + "src/store/frameworks/android/android-wizard-agent.ts", + "src/store/frameworks/android/utils.ts", + "src/store/frameworks/angular/angular-wizard-agent.ts", + "src/store/frameworks/angular/utils.ts", + "src/store/frameworks/astro/astro-wizard-agent.ts", + "src/store/frameworks/astro/utils.ts", + "src/store/frameworks/django/django-wizard-agent.ts", + "src/store/frameworks/django/utils.ts", + "src/store/frameworks/elixir/elixir-wizard-agent.ts", + "src/store/frameworks/fastapi/fastapi-wizard-agent.ts", + "src/store/frameworks/fastapi/utils.ts", + "src/store/frameworks/flask/flask-wizard-agent.ts", + "src/store/frameworks/flask/utils.ts", + "src/store/frameworks/flutter/flutter-wizard-agent.ts", + "src/store/frameworks/go/go-wizard-agent.ts", + "src/store/frameworks/java/java-wizard-agent.ts", + "src/store/frameworks/javascript-node/javascript-node-wizard-agent.ts", + "src/store/frameworks/javascript-web/javascript-web-wizard-agent.ts", + "src/store/frameworks/javascript-web/utils.ts", + "src/store/frameworks/kmp/kmp-wizard-agent.ts", + "src/store/frameworks/laravel/laravel-wizard-agent.ts", + "src/store/frameworks/laravel/utils.ts", + "src/store/frameworks/nextjs/nextjs-wizard-agent.ts", + "src/store/frameworks/nextjs/utils.ts", + "src/store/frameworks/nuxt/nuxt-wizard-agent.ts", + "src/store/frameworks/python/python-wizard-agent.ts", + "src/store/frameworks/python/utils.ts", + "src/store/frameworks/rails/rails-wizard-agent.ts", + "src/store/frameworks/rails/utils.ts", + "src/store/frameworks/react-native/react-native-wizard-agent.ts", + "src/store/frameworks/react-native/utils.ts", + "src/store/frameworks/react-router/react-router-wizard-agent.ts", + "src/store/frameworks/react-router/utils.ts", + "src/store/frameworks/ruby/ruby-wizard-agent.ts", + "src/store/frameworks/ruby/utils.ts", + "src/store/frameworks/rust/rust-wizard-agent.ts", + "src/store/frameworks/svelte/svelte-wizard-agent.ts", + "src/store/frameworks/swift/swift-wizard-agent.ts", + "src/store/frameworks/swift/utils.ts", + "src/store/frameworks/tanstack-router/tanstack-router-wizard-agent.ts", + "src/store/frameworks/tanstack-router/utils.ts", + "src/store/frameworks/tanstack-start/tanstack-start-wizard-agent.ts", + "src/store/frameworks/tanstack-start/utils.ts", + "src/store/frameworks/vue/vue-wizard-agent.ts", + "src/store/health-checks/endpoints.ts", + "src/store/health-checks/readiness.ts", + "src/store/programs/agent-skill/index.ts", + "src/store/programs/agent-skill/steps.ts", + "src/store/programs/ai-observability/index.ts", + "src/store/programs/audit/detect.ts", + "src/store/programs/audit/index.ts", + "src/store/programs/audit/seed.ts", + "src/store/programs/audit/types.ts", + "src/store/programs/error-tracking-upload-source-maps/detect.ts", + "src/store/programs/error-tracking-upload-source-maps/index.ts", + "src/store/programs/error-tracking-upload-source-maps/prompt.ts", + "src/store/programs/error-tracking-upload-source-maps/steps.ts", + "src/store/programs/error-tracking/detect-agentic.ts", + "src/store/programs/error-tracking/index.ts", + "src/store/programs/events-audit/constants.ts", + "src/store/programs/events-audit/index.ts", + "src/store/programs/events-audit/seed.ts", + "src/store/programs/events-audit/steps.ts", + "src/store/programs/mcp-analytics/index.ts", + "src/store/programs/mcp/index.ts", + "src/store/programs/metrics/index.ts", + "src/store/programs/migration/index.ts", + "src/store/programs/migration/steps.ts", + "src/store/programs/posthog-doctor/fetch.ts", + "src/store/programs/posthog-doctor/index.ts", + "src/store/programs/posthog-doctor/kind-metadata.ts", + "src/store/programs/posthog-doctor/steps.ts", + "src/store/programs/posthog-doctor/types.ts", + "src/store/programs/posthog-integration/constants.ts", + "src/store/programs/posthog-integration/detect.ts", + "src/store/programs/posthog-integration/handoff.ts", + "src/store/programs/posthog-integration/index.ts", + "src/store/programs/posthog-integration/steps.ts", + "src/store/programs/program-registry.ts", + "src/store/programs/replay-vision/index.ts", + "src/store/programs/revenue-analytics/detect.ts", + "src/store/programs/revenue-analytics/index.ts", + "src/store/programs/revenue-analytics/steps.ts", + "src/store/programs/self-driving/detect-agentic.ts", + "src/store/programs/self-driving/detect.ts", + "src/store/programs/self-driving/index.ts", + "src/store/programs/self-driving/pricing.ts", + "src/store/programs/self-driving/prompt.ts", + "src/store/programs/self-driving/step-keys.ts", + "src/store/programs/self-driving/steps.ts", + "src/store/programs/shared/control-actions.ts", + "src/store/programs/shared/health-check-step.ts", + "src/store/programs/shared/package-scanning.ts", + "src/store/programs/shared/posthog-cli-preinstall.ts", + "src/store/programs/slack/index.ts", + "src/store/programs/warehouse-source/detect.ts", + "src/store/programs/warehouse-source/index.ts", + "src/store/programs/warehouse-source/steps.ts", + "src/store/programs/web-analytics-doctor/detect.ts", + "src/store/programs/web-analytics-doctor/index.ts", + "src/store/programs/web-analytics-doctor/steps.ts", + "src/store/registry.ts", + "src/store/security/yara-hooks.ts", + "src/store/security/yara-policy.ts", + "src/store/services/authenticate.ts", + "src/store/services/steps/install-cli-steering/index.ts", + "src/store/services/warehouse-sources/detect.ts", + "src/store/services/warehouse-sources/registry.ts", + "src/store/session/ask-policy.ts", + "src/store/session/wizard-ask-bridge.ts", + "src/store/shared/atomic-ledger.ts", + "src/store/shared/env-scan.ts", + "src/store/shared/headless-mode.ts", + "src/store/skill-install.ts", + "src/store/tools/tools.ts" + ], + "imports": [ + "analytics.js", + "package-json.js", + "package-manager.js", + "steps.js" + ] + }, "programs.js": { "sources": [ "src/store/programs/ai-opt-in-gate.ts", @@ -296,8 +440,9 @@ "src/store/programs/run-config.ts" ], "imports": [ - "add-mcp-server-to-clients.js", "analytics.js", + "package-manager.js", + "program-registry.js", "store.js" ] }, @@ -309,11 +454,12 @@ ], "imports": [ "analytics.js", - "store.js" + "program-registry.js" ] }, "runners.js": { "sources": [ + "src/cli/control-hooks.ts", "src/cli/runners/resolve-no-telemetry.ts", "src/cli/runners/run-non-interactive.ts", "src/cli/runners/run-wizard-ci.ts", @@ -321,10 +467,12 @@ "src/cli/runners/run-wizard.ts" ], "imports": [ - "add-mcp-server-to-clients.js", "agent.js", "analytics.js", "console.js", + "control.js", + "package-manager.js", + "program-registry.js", "programs.js", "store.js", "tui.js" @@ -337,7 +485,7 @@ "imports": [ "agent-interface.js", "analytics.js", - "store.js" + "program-registry.js" ] }, "steps.js": { @@ -347,16 +495,48 @@ "src/store/services/steps/upload-environment-variables/providers/vercel.ts" ], "imports": [ - "add-mcp-server-to-clients.js", - "analytics.js" + "analytics.js", + "package-manager.js" ] }, "store.js": { - "sources": [], + "sources": [ + "src/store/agent-protocol/agent-env-isolation.ts", + "src/store/agent-protocol/agent-phase.ts", + "src/store/agent-protocol/mcp-prompt.ts", + "src/store/agent-protocol/token-pricing.ts", + "src/store/file-watcher.ts", + "src/store/mcp-project-profile.ts", + "src/store/mcp-role-prompts.copy.json", + "src/store/mcp-role-prompts.ts", + "src/store/mcp-seed-events.ts", + "src/store/safe-tools.ts", + "src/store/services/claude-settings.ts", + "src/store/services/steps/add-mcp-server-to-clients/browser-client.ts", + "src/store/services/steps/add-mcp-server-to-clients/login-client.ts", + "src/store/session/secret-vault.ts", + "src/store/shared/clipboard.ts", + "src/store/shared/custom-headers.ts", + "src/store/shared/env-api-key.ts", + "src/store/shared/environment.ts", + "src/store/shared/terminal-bell.ts", + "src/store/state/flow-resolution.ts", + "src/store/state/run-failure.ts", + "src/store/state/store.ts", + "src/store/task-stream/audit-areas.ts", + "src/store/task-stream/destinations/file.ts", + "src/store/task-stream/destinations/posthog.ts", + "src/store/task-stream/event-plan-watcher.ts", + "src/store/task-stream/task-stream-push.ts", + "src/store/tools/handoff.ts", + "src/store/ui/store-ui.ts", + "src/store/ui/wizard-ui.ts", + "src/store/wizard-spellbook.ts" + ], "imports": [ - "add-mcp-server-to-clients.js", "analytics.js", - "store.js" + "package-manager.js", + "program-registry.js" ] }, "subagent.js": { @@ -372,12 +552,12 @@ "src/agent/runner/harness/pi/task.ts" ], "imports": [ - "add-mcp-server-to-clients.js", "agent-interface.js", "agent-prompt-loader.js", "analytics.js", "mcp.js", "orchestrator-tools.js", + "package-manager.js", "pi.js", "queue-tools.js", "security.js", @@ -400,6 +580,7 @@ "imports": [ "analytics.js", "pi.js", + "program-registry.js", "store.js" ] }, @@ -588,6 +769,8 @@ "imports": [ "add-mcp-server-to-clients.js", "analytics.js", + "package-manager.js", + "program-registry.js", "programs.js", "store.js" ] diff --git a/scripts/__fixtures__/chunk-manifest.prod.json b/scripts/__fixtures__/chunk-manifest.prod.json index 49af3ed2a..aaa066d54 100644 --- a/scripts/__fixtures__/chunk-manifest.prod.json +++ b/scripts/__fixtures__/chunk-manifest.prod.json @@ -1,10 +1,6 @@ { "add-mcp-server-to-clients.js": { "sources": [ - "src/store/agent-protocol/agent-signals.ts", - "src/store/api.ts", - "src/store/host-resolution.ts", - "src/store/services/oauth/program-scopes.ts", "src/store/services/steps/add-mcp-server-to-clients/MCPClient.ts", "src/store/services/steps/add-mcp-server-to-clients/clients/claude-code.ts", "src/store/services/steps/add-mcp-server-to-clients/clients/claude-web.ts", @@ -16,31 +12,11 @@ "src/store/services/steps/add-mcp-server-to-clients/defaults.ts", "src/store/services/steps/add-mcp-server-to-clients/index.ts", "src/store/services/steps/add-mcp-server-to-clients/plugin-client.ts", - "src/store/services/steps/add-mcp-server-to-clients/results.ts", - "src/store/shared/bounded-fs.ts", - "src/store/shared/errors/agent-map.ts", - "src/store/shared/errors/auth.ts", - "src/store/shared/errors/catalog.ts", - "src/store/shared/errors/codes.ts", - "src/store/shared/errors/detect-map.ts", - "src/store/shared/errors/emit.ts", - "src/store/shared/errors/run-failure.ts", - "src/store/shared/errors/sanitize.ts", - "src/store/shared/errors/skill-map.ts", - "src/store/shared/links.ts", - "src/store/shared/oauth-errors.ts", - "src/store/shared/oauth.ts", - "src/store/shared/package-manager.ts", - "src/store/shared/project-resolution.ts", - "src/store/shared/provisioning.ts", - "src/store/shared/semver.ts", - "src/store/shared/setup-utils.ts", - "src/store/shared/telemetry.ts", - "src/store/shared/urls.ts", - "src/store/shared/wizard-abort.ts" + "src/store/services/steps/add-mcp-server-to-clients/results.ts" ], "imports": [ - "analytics.js" + "analytics.js", + "package-manager.js" ] }, "agent.js": { @@ -80,11 +56,12 @@ "src/agent/runner/switchboard/sequence.ts" ], "imports": [ - "add-mcp-server-to-clients.js", "agent-interface.js", "agent-prompt-loader.js", "analytics.js", + "package-manager.js", "pi.js", + "program-registry.js", "programs.js", "queue-tools.js", "store.js" @@ -105,8 +82,9 @@ "src/agent/triage-provider.ts" ], "imports": [ - "add-mcp-server-to-clients.js", "analytics.js", + "package-manager.js", + "program-registry.js", "queue-tools.js", "store.js" ] @@ -117,24 +95,12 @@ ], "imports": [ "analytics.js", - "queue-tools.js", - "store.js" + "program-registry.js", + "queue-tools.js" ] }, "analytics.js": { - "sources": [ - "src/env.ts", - "src/store/local-dev.ts", - "src/store/session/wizard-session.ts", - "src/store/shared/analytics.ts", - "src/store/shared/ci-flag-overrides.ts", - "src/store/shared/constants.ts", - "src/store/shared/debug.ts", - "src/store/shared/paths.ts", - "src/store/shared/version.ts", - "src/store/ui/index.ts", - "src/store/ui/null-ui.ts" - ], + "sources": [], "imports": [ "analytics.js" ] @@ -152,11 +118,11 @@ "src/cli/commands/basic-integration/ci-install.ts" ], "imports": [ - "add-mcp-server-to-clients.js", "analytics.js", "console.js", - "runners.js", - "store.js" + "package-manager.js", + "program-registry.js", + "runners.js" ] }, "console.js": { @@ -166,7 +132,21 @@ ], "imports": [ "analytics.js", - "store.js" + "program-registry.js" + ] + }, + "control.js": { + "sources": [ + "src/store/control/actions.ts", + "src/store/control/driver.ts", + "src/store/control/marker.ts", + "src/store/control/runs.ts", + "src/store/control/server.ts", + "src/store/control/state.ts" + ], + "imports": [ + "analytics.js", + "program-registry.js" ] }, "interactive.js": { @@ -174,8 +154,8 @@ "src/cli/commands/basic-integration/interactive.ts" ], "imports": [ - "runners.js", - "store.js" + "program-registry.js", + "runners.js" ] }, "main.js": { @@ -214,14 +194,15 @@ "src/cli/wizard.ts" ], "imports": [ - "add-mcp-server-to-clients.js", "agent.js", "analytics.js", "ci-install.js", "console.js", "interactive.js", "non-interactive.js", + "package-manager.js", "playground.js", + "program-registry.js", "runners.js", "store.js", "tui.js" @@ -240,8 +221,8 @@ "src/cli/commands/basic-integration/non-interactive.ts" ], "imports": [ - "add-mcp-server-to-clients.js", - "analytics.js" + "analytics.js", + "package-manager.js" ] }, "orchestrator-tools.js": { @@ -259,6 +240,38 @@ "package-json.js" ] }, + "package-manager.js": { + "sources": [ + "src/store/agent-protocol/agent-signals.ts", + "src/store/api.ts", + "src/store/host-resolution.ts", + "src/store/services/oauth/program-scopes.ts", + "src/store/shared/bounded-fs.ts", + "src/store/shared/errors/agent-map.ts", + "src/store/shared/errors/auth.ts", + "src/store/shared/errors/catalog.ts", + "src/store/shared/errors/codes.ts", + "src/store/shared/errors/detect-map.ts", + "src/store/shared/errors/emit.ts", + "src/store/shared/errors/run-failure.ts", + "src/store/shared/errors/sanitize.ts", + "src/store/shared/errors/skill-map.ts", + "src/store/shared/links.ts", + "src/store/shared/oauth-errors.ts", + "src/store/shared/oauth.ts", + "src/store/shared/package-manager.ts", + "src/store/shared/project-resolution.ts", + "src/store/shared/provisioning.ts", + "src/store/shared/semver.ts", + "src/store/shared/setup-utils.ts", + "src/store/shared/telemetry.ts", + "src/store/shared/urls.ts", + "src/store/shared/wizard-abort.ts" + ], + "imports": [ + "analytics.js" + ] + }, "pi.js": { "sources": [ "src/agent/aio-capture.ts", @@ -266,10 +279,10 @@ "src/agent/runner/harness/pi/index.ts" ], "imports": [ - "add-mcp-server-to-clients.js", "agent-interface.js", "analytics.js", "mcp.js", + "package-manager.js", "security.js", "store.js", "subagent.js", @@ -287,6 +300,141 @@ "tui.js" ] }, + "program-registry.js": { + "sources": [ + "src/store/auth-session-state.ts", + "src/store/control/params.ts", + "src/store/detection/agentic.ts", + "src/store/detection/context.ts", + "src/store/detection/features.ts", + "src/store/detection/framework.ts", + "src/store/detection/package-manager.ts", + "src/store/detection/project-scope.ts", + "src/store/fetch-retry.ts", + "src/store/framework-config.ts", + "src/store/frameworks/android/android-wizard-agent.ts", + "src/store/frameworks/android/utils.ts", + "src/store/frameworks/angular/angular-wizard-agent.ts", + "src/store/frameworks/angular/utils.ts", + "src/store/frameworks/astro/astro-wizard-agent.ts", + "src/store/frameworks/astro/utils.ts", + "src/store/frameworks/django/django-wizard-agent.ts", + "src/store/frameworks/django/utils.ts", + "src/store/frameworks/elixir/elixir-wizard-agent.ts", + "src/store/frameworks/fastapi/fastapi-wizard-agent.ts", + "src/store/frameworks/fastapi/utils.ts", + "src/store/frameworks/flask/flask-wizard-agent.ts", + "src/store/frameworks/flask/utils.ts", + "src/store/frameworks/flutter/flutter-wizard-agent.ts", + "src/store/frameworks/go/go-wizard-agent.ts", + "src/store/frameworks/java/java-wizard-agent.ts", + "src/store/frameworks/javascript-node/javascript-node-wizard-agent.ts", + "src/store/frameworks/javascript-web/javascript-web-wizard-agent.ts", + "src/store/frameworks/javascript-web/utils.ts", + "src/store/frameworks/kmp/kmp-wizard-agent.ts", + "src/store/frameworks/laravel/laravel-wizard-agent.ts", + "src/store/frameworks/laravel/utils.ts", + "src/store/frameworks/nextjs/nextjs-wizard-agent.ts", + "src/store/frameworks/nextjs/utils.ts", + "src/store/frameworks/nuxt/nuxt-wizard-agent.ts", + "src/store/frameworks/python/python-wizard-agent.ts", + "src/store/frameworks/python/utils.ts", + "src/store/frameworks/rails/rails-wizard-agent.ts", + "src/store/frameworks/rails/utils.ts", + "src/store/frameworks/react-native/react-native-wizard-agent.ts", + "src/store/frameworks/react-native/utils.ts", + "src/store/frameworks/react-router/react-router-wizard-agent.ts", + "src/store/frameworks/react-router/utils.ts", + "src/store/frameworks/ruby/ruby-wizard-agent.ts", + "src/store/frameworks/ruby/utils.ts", + "src/store/frameworks/rust/rust-wizard-agent.ts", + "src/store/frameworks/svelte/svelte-wizard-agent.ts", + "src/store/frameworks/swift/swift-wizard-agent.ts", + "src/store/frameworks/swift/utils.ts", + "src/store/frameworks/tanstack-router/tanstack-router-wizard-agent.ts", + "src/store/frameworks/tanstack-router/utils.ts", + "src/store/frameworks/tanstack-start/tanstack-start-wizard-agent.ts", + "src/store/frameworks/tanstack-start/utils.ts", + "src/store/frameworks/vue/vue-wizard-agent.ts", + "src/store/health-checks/endpoints.ts", + "src/store/health-checks/readiness.ts", + "src/store/programs/agent-skill/index.ts", + "src/store/programs/agent-skill/steps.ts", + "src/store/programs/ai-observability/index.ts", + "src/store/programs/audit/detect.ts", + "src/store/programs/audit/index.ts", + "src/store/programs/audit/seed.ts", + "src/store/programs/audit/types.ts", + "src/store/programs/error-tracking-upload-source-maps/detect.ts", + "src/store/programs/error-tracking-upload-source-maps/index.ts", + "src/store/programs/error-tracking-upload-source-maps/prompt.ts", + "src/store/programs/error-tracking-upload-source-maps/steps.ts", + "src/store/programs/error-tracking/detect-agentic.ts", + "src/store/programs/error-tracking/index.ts", + "src/store/programs/events-audit/constants.ts", + "src/store/programs/events-audit/index.ts", + "src/store/programs/events-audit/seed.ts", + "src/store/programs/events-audit/steps.ts", + "src/store/programs/mcp-analytics/index.ts", + "src/store/programs/mcp/index.ts", + "src/store/programs/metrics/index.ts", + "src/store/programs/migration/index.ts", + "src/store/programs/migration/steps.ts", + "src/store/programs/posthog-doctor/fetch.ts", + "src/store/programs/posthog-doctor/index.ts", + "src/store/programs/posthog-doctor/kind-metadata.ts", + "src/store/programs/posthog-doctor/steps.ts", + "src/store/programs/posthog-doctor/types.ts", + "src/store/programs/posthog-integration/constants.ts", + "src/store/programs/posthog-integration/detect.ts", + "src/store/programs/posthog-integration/handoff.ts", + "src/store/programs/posthog-integration/index.ts", + "src/store/programs/posthog-integration/steps.ts", + "src/store/programs/program-registry.ts", + "src/store/programs/replay-vision/index.ts", + "src/store/programs/revenue-analytics/detect.ts", + "src/store/programs/revenue-analytics/index.ts", + "src/store/programs/revenue-analytics/steps.ts", + "src/store/programs/self-driving/detect-agentic.ts", + "src/store/programs/self-driving/detect.ts", + "src/store/programs/self-driving/index.ts", + "src/store/programs/self-driving/pricing.ts", + "src/store/programs/self-driving/prompt.ts", + "src/store/programs/self-driving/step-keys.ts", + "src/store/programs/self-driving/steps.ts", + "src/store/programs/shared/control-actions.ts", + "src/store/programs/shared/health-check-step.ts", + "src/store/programs/shared/package-scanning.ts", + "src/store/programs/shared/posthog-cli-preinstall.ts", + "src/store/programs/slack/index.ts", + "src/store/programs/warehouse-source/detect.ts", + "src/store/programs/warehouse-source/index.ts", + "src/store/programs/warehouse-source/steps.ts", + "src/store/programs/web-analytics-doctor/detect.ts", + "src/store/programs/web-analytics-doctor/index.ts", + "src/store/programs/web-analytics-doctor/steps.ts", + "src/store/registry.ts", + "src/store/security/yara-hooks.ts", + "src/store/security/yara-policy.ts", + "src/store/services/authenticate.ts", + "src/store/services/steps/install-cli-steering/index.ts", + "src/store/services/warehouse-sources/detect.ts", + "src/store/services/warehouse-sources/registry.ts", + "src/store/session/ask-policy.ts", + "src/store/session/wizard-ask-bridge.ts", + "src/store/shared/atomic-ledger.ts", + "src/store/shared/env-scan.ts", + "src/store/shared/headless-mode.ts", + "src/store/skill-install.ts", + "src/store/tools/tools.ts" + ], + "imports": [ + "analytics.js", + "package-json.js", + "package-manager.js", + "steps.js" + ] + }, "programs.js": { "sources": [ "src/store/programs/ai-opt-in-gate.ts", @@ -296,8 +444,9 @@ "src/store/programs/run-config.ts" ], "imports": [ - "add-mcp-server-to-clients.js", "analytics.js", + "package-manager.js", + "program-registry.js", "store.js" ] }, @@ -309,11 +458,12 @@ ], "imports": [ "analytics.js", - "store.js" + "program-registry.js" ] }, "runners.js": { "sources": [ + "src/cli/control-hooks.ts", "src/cli/runners/resolve-no-telemetry.ts", "src/cli/runners/run-non-interactive.ts", "src/cli/runners/run-wizard-ci.ts", @@ -321,10 +471,12 @@ "src/cli/runners/run-wizard.ts" ], "imports": [ - "add-mcp-server-to-clients.js", "agent.js", "analytics.js", "console.js", + "control.js", + "package-manager.js", + "program-registry.js", "programs.js", "store.js", "tui.js" @@ -337,7 +489,7 @@ "imports": [ "agent-interface.js", "analytics.js", - "store.js" + "program-registry.js" ] }, "steps.js": { @@ -347,16 +499,48 @@ "src/store/services/steps/upload-environment-variables/providers/vercel.ts" ], "imports": [ - "add-mcp-server-to-clients.js", - "analytics.js" + "analytics.js", + "package-manager.js" ] }, "store.js": { - "sources": [], + "sources": [ + "src/store/agent-protocol/agent-env-isolation.ts", + "src/store/agent-protocol/agent-phase.ts", + "src/store/agent-protocol/mcp-prompt.ts", + "src/store/agent-protocol/token-pricing.ts", + "src/store/file-watcher.ts", + "src/store/mcp-project-profile.ts", + "src/store/mcp-role-prompts.copy.json", + "src/store/mcp-role-prompts.ts", + "src/store/mcp-seed-events.ts", + "src/store/safe-tools.ts", + "src/store/services/claude-settings.ts", + "src/store/services/steps/add-mcp-server-to-clients/browser-client.ts", + "src/store/services/steps/add-mcp-server-to-clients/login-client.ts", + "src/store/session/secret-vault.ts", + "src/store/shared/clipboard.ts", + "src/store/shared/custom-headers.ts", + "src/store/shared/env-api-key.ts", + "src/store/shared/environment.ts", + "src/store/shared/terminal-bell.ts", + "src/store/state/flow-resolution.ts", + "src/store/state/run-failure.ts", + "src/store/state/store.ts", + "src/store/task-stream/audit-areas.ts", + "src/store/task-stream/destinations/file.ts", + "src/store/task-stream/destinations/posthog.ts", + "src/store/task-stream/event-plan-watcher.ts", + "src/store/task-stream/task-stream-push.ts", + "src/store/tools/handoff.ts", + "src/store/ui/store-ui.ts", + "src/store/ui/wizard-ui.ts", + "src/store/wizard-spellbook.ts" + ], "imports": [ - "add-mcp-server-to-clients.js", "analytics.js", - "store.js" + "package-manager.js", + "program-registry.js" ] }, "subagent.js": { @@ -372,12 +556,12 @@ "src/agent/runner/harness/pi/task.ts" ], "imports": [ - "add-mcp-server-to-clients.js", "agent-interface.js", "agent-prompt-loader.js", "analytics.js", "mcp.js", "orchestrator-tools.js", + "package-manager.js", "pi.js", "queue-tools.js", "security.js", @@ -400,6 +584,7 @@ "imports": [ "analytics.js", "pi.js", + "program-registry.js", "store.js" ] }, @@ -588,6 +773,8 @@ "imports": [ "add-mcp-server-to-clients.js", "analytics.js", + "package-manager.js", + "program-registry.js", "programs.js", "store.js" ] diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index 241bcab22..32ca4e36b 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -101,3 +101,43 @@ if ! echo "$hl_output" | grep -qi 'Headless mode requires --api-key'; then echo "$hl_output" | head -5 >&2 exit 1 fi + +# ── 5. Control server pruned from the TUI path ─────────────────────────────── +# The control API ships for headless runs only. Exactly one chunk carries the +# server; the TUI entry chunk and bin.js never import it; the published binary +# refuses --control-socket without the headless flag and accepts it with it. +CONTROL_MARKER='wizard-control-server' +TUI_MARKER='wizard-tui-entry' +control_chunks=$(grep -l "$CONTROL_MARKER" ./dist/*.js || true) +control_count=$(printf '%s\n' "$control_chunks" | grep -c . || true) +if [ "$control_count" -ne 1 ]; then + echo "Smoke test failed: expected exactly one chunk with $CONTROL_MARKER, found $control_count" >&2 + exit 1 +fi +control_file=$(basename "$control_chunks") +for tui_chunk in $(grep -l "$TUI_MARKER" ./dist/*.js || true); do + if grep -q "$control_file" "$tui_chunk"; then + echo "Smoke test failed: TUI chunk $tui_chunk imports the control server chunk" >&2 + exit 1 + fi +done +if grep -q "$control_file" "$DIST_BIN"; then + echo 'Smoke test failed: bin.js imports the control server chunk' >&2 + exit 1 +fi +if ! grep -l "$control_file" ./dist/*.js | grep -qv "$control_chunks"; then + echo 'Smoke test failed: nothing imports the control server chunk (headless path dead)' >&2 + exit 1 +fi +cs_output=$(node "$DIST_BIN" --control-socket /tmp/wizard-smoke-probe.sock --install-dir /tmp/wizard-smoke-probe 2>&1) && cs_exit=0 || cs_exit=$? +if [ "$cs_exit" -eq 0 ] || ! echo "$cs_output" | grep -q 'only available with the experimental headless flag'; then + echo 'Smoke test failed: --control-socket without the headless flag must be refused' >&2 + echo "$cs_output" | head -5 >&2 + exit 1 +fi +cs_hl=$(node "$DIST_BIN" "$HEADLESS_FLAG" --control-socket /tmp/wizard-smoke-probe.sock --install-dir /tmp/wizard-smoke-probe 2>&1) || true +if ! echo "$cs_hl" | grep -qi 'Headless mode requires --api-key'; then + echo 'Smoke test failed: headless --control-socket did not reach the headless path' >&2 + echo "$cs_hl" | head -5 >&2 + exit 1 +fi diff --git a/src/__tests__/architecture/__snapshots__/boundary-contracts.test.ts.snap b/src/__tests__/architecture/__snapshots__/boundary-contracts.test.ts.snap index 971f551b1..af37db8de 100644 --- a/src/__tests__/architecture/__snapshots__/boundary-contracts.test.ts.snap +++ b/src/__tests__/architecture/__snapshots__/boundary-contracts.test.ts.snap @@ -192,6 +192,9 @@ exports[`boundary contracts > cli consumes exactly the recorded names from other "runAgent", "runMcpPromptViaSdk", ], + "@agent/types": [ + "RunAgent", + ], "@store": [ "ApiError", "CLI_STEERING_TARGETS", @@ -203,6 +206,7 @@ exports[`boundary contracts > cli consumes exactly the recorded names from other "PostHogDestination", "RunPhase", "Sequence", + "StoreUI", "TaskStreamPush", "VERSION", "WIZARD_TOOL_NAMES", @@ -279,12 +283,17 @@ exports[`boundary contracts > cli consumes exactly the recorded names from other "CliEntry", "CliSteeringTarget", "CloudRegion", + "ControlHooks", + "ControlServerHandle", + "DetectRequest", "ErrorCode", "Harness", "OutroData", "ProgramConfig", + "ProgramId", "ProvisioningResult", "RunPhase", + "RunRequest", "Sequence", "TaskStreamPush", "WizardSession", diff --git a/src/__tests__/architecture/import-boundaries.test.ts b/src/__tests__/architecture/import-boundaries.test.ts index 54f07dbf1..d49aad172 100644 --- a/src/__tests__/architecture/import-boundaries.test.ts +++ b/src/__tests__/architecture/import-boundaries.test.ts @@ -70,6 +70,15 @@ export const PUBLIC_ENTRIES: Record< /** The msw hook behind `NODE_ENV === 'test'`; tsdown inlines it away in published builds. */ const TEST_ONLY_EDGES = new Set(['bin.ts -> e2e-tests/mocks/server.ts']); +/** The only files that may load the control server, and only lazily. */ +const CONTROL_IMPORTERS = new Set([ + 'src/cli/runners/run-wizard.ts', + 'src/cli/runners/run-non-interactive.ts', +]); +const CONTROL_ENTRY = 'src/store/control/index.ts'; +const STATIC_CONTROL_IMPORT = + /(?:import|export)\s+(?:type\s+)?[^'"]*?from\s*['"]@store\/control['"]/; + /** Console renderers ship in headless builds and must stay Ink free. */ const INK_FREE_PREFIX = 'src/tui/console/'; @@ -318,6 +327,13 @@ function analyze(): Analysis { edges.add(key); const to = classifySurface(target); + if (target === CONTROL_ENTRY && from !== 'store') { + if (STATIC_CONTROL_IMPORT.test(text)) + violations.set(key, 'control-static-import'); + else if (!CONTROL_IMPORTERS.has(file)) + violations.set(key, 'control-importer'); + continue; + } if (to === 'harness') { if (!TEST_ONLY_EDGES.has(key)) violations.set(key, 'harness'); } else if (!allowed.includes(to)) diff --git a/src/__tests__/architecture/startup-graph.test.ts b/src/__tests__/architecture/startup-graph.test.ts index b9421600d..7ec8ec97d 100644 --- a/src/__tests__/architecture/startup-graph.test.ts +++ b/src/__tests__/architecture/startup-graph.test.ts @@ -97,6 +97,13 @@ describe('cli startup graph', () => { expect(ui).toEqual([]); }); + it('never loads the control server statically', () => { + const control = [...closure.files] + .map(rel) + .filter((f) => f === 'src/store/control/server.ts'); + expect(control).toEqual([]); + }); + it('does not load the agent runner before a command runs', () => { const agent = [...closure.files] .map(rel) diff --git a/src/cli/README.md b/src/cli/README.md index b89be84d7..821d0444d 100644 --- a/src/cli/README.md +++ b/src/cli/README.md @@ -10,6 +10,12 @@ surfaces' public entries. Holds no domain logic. preflight. - `wizard.ts`, `commands/`: yargs surface. `runners/`: TUI and headless runners that sequence independent agent runs and pass context between them. +- `control-hooks.ts`: what the store's control server asks the composition root + to do: resolve credentials, release the run, detect, start one independent run + with explicit context, shut down. `--control-socket` attaches the server in + `run-non-interactive.ts` (headless, every build) and in `run-wizard.ts` (TUI, + dev builds only). Published TUI runs refuse the flag unless the headless flag + is present. ## May import diff --git a/src/cli/__tests__/control-hooks.test.ts b/src/cli/__tests__/control-hooks.test.ts new file mode 100644 index 000000000..3072da6da --- /dev/null +++ b/src/cli/__tests__/control-hooks.test.ts @@ -0,0 +1,139 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const projectData = vi.hoisted(() => vi.fn()); +vi.mock('@store', async (importOriginal) => ({ + ...(await importOriginal()), + getOrAskForProjectData: projectData, +})); +vi.mock('@store/shared/analytics', () => ({ + analytics: { + setTag: vi.fn(), + wizardCapture: vi.fn(), + captureException: vi.fn(), + }, + sessionProperties: () => ({}), +})); + +import { buildSession, StoreUI, setUI } from '@store'; +import { Program } from '@store/programs'; +import { createControlHooks } from '../control-hooks.js'; +import { fakeRunAgent, fakeStartTUI } from '../testing/fake-surfaces.js'; + +const dirs: string[] = []; +afterEach(() => { + for (const d of dirs.splice(0)) + fs.rmSync(d, { recursive: true, force: true }); + vi.clearAllMocks(); +}); + +function setup(program = Program.PostHogIntegration) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wz-hooks-')); + dirs.push(dir); + const { store } = fakeStartTUI(program); + setUI(new StoreUI(store)); + store.session = buildSession({ + installDir: dir, + ci: true, + apiKey: 'phx_key', + projectId: '42', + }); + store.setFrameworkContext('shared', 'live'); + const agent = fakeRunAgent(); + const shutdown = vi.fn(() => Promise.resolve()); + const hooks = createControlHooks({ + store, + programId: program, + runAgent: agent.runAgent, + shutdown, + }); + return { store, hooks, agent, shutdown, dir }; +} + +describe('control hooks', () => { + it('armRun releases the runner through the store', () => { + const { store, hooks } = setup(); + hooks.armRun(); + expect(store.session.runRequested).toBe(true); + }); + + it('setCredentials resolves the API key and commits it', async () => { + const { store, hooks } = setup(); + projectData.mockResolvedValueOnce({ + accessToken: 'phx_key', + projectApiKey: 'phc_project', + host: { region: 'us' }, + projectId: 42, + }); + await hooks.setCredentials(); + expect(projectData).toHaveBeenCalledWith( + expect.objectContaining({ + ci: true, + signup: false, + apiKey: 'phx_key', + projectId: 42, + programId: Program.PostHogIntegration, + }), + ); + expect(store.session.credentials).toMatchObject({ + projectApiKey: 'phc_project', + projectId: 42, + }); + }); + + it('startRun scopes one independent run and passes context explicitly', async () => { + const { store, hooks, agent, dir } = setup(); + await hooks.startRun({ + programId: Program.Audit, + installDir: 'apps/web', + frameworkContext: { picked: 'yes' }, + skillId: 'audit-events', + }); + expect(agent.calls).toEqual([ + { + programId: Program.Audit, + installDir: path.join(dir, 'apps/web'), + frameworkContextKeys: ['shared', 'picked'], + composed: true, + }, + ]); + // The live session is untouched: the next run starts from the same place. + expect(store.session.installDir).toBe(dir); + expect(store.session.frameworkContext).toEqual({ shared: 'live' }); + }); + + it('startRun keeps an absolute install dir and falls back to the live one', async () => { + const { hooks, agent, dir } = setup(); + await hooks.startRun({ + programId: Program.PostHogIntegration, + installDir: '/abs/app', + }); + await hooks.startRun({ programId: Program.PostHogIntegration }); + expect(agent.calls.map((c) => c.installDir)).toEqual(['/abs/app', dir]); + }); + + it("detect runs the launched program's ready hooks against the store", async () => { + // Audit declares no ciPreRun, so detection is the store's onReady walk. + const { store, hooks } = setup(Program.Audit); + const before = store.getVersion(); + await hooks.detect({}); + expect(store.activeProgram).toBe(Program.Audit); + expect(store.getVersion()).toBeGreaterThanOrEqual(before); + }); + + it('detect switches program and install dir on request', async () => { + const { store, hooks, dir } = setup(); + fs.mkdirSync(path.join(dir, 'sub')); + await hooks.detect({ programId: Program.Audit, installDir: 'sub' }); + expect(store.activeProgram).toBe(Program.Audit); + expect(store.session.installDir).toBe(path.join(dir, 'sub')); + }); + + it('shutdown defers to the runner', async () => { + const { hooks, shutdown } = setup(); + await hooks.shutdown(); + expect(shutdown).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/cli/__tests__/control-refusal.test.ts b/src/cli/__tests__/control-refusal.test.ts new file mode 100644 index 000000000..0657a5ef0 --- /dev/null +++ b/src/cli/__tests__/control-refusal.test.ts @@ -0,0 +1,107 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@env', async (importOriginal) => ({ + ...(await importOriginal()), + IS_PRODUCTION_BUILD: true, +})); +vi.mock('@store/shared/errors/emit', () => ({ emitWizardError: vi.fn() })); + +import { HEADLESS_FLAG } from '@env'; +import { emitWizardError } from '@store/shared/errors/emit'; +import { basicIntegrationCommand } from '../commands/basic-integration/index.js'; +import { + CONTROL_SOCKET_UNAVAILABLE, + controlFlagRefusal, + E2E_ASK_UNAVAILABLE, + Wizard, +} from '../wizard.js'; + +class Exit extends Error {} + +describe('published-build control flag refusal', () => { + it.each([ + [ + '--control-socket alone', + ['--control-socket', '/tmp/c.sock'], + {}, + CONTROL_SOCKET_UNAVAILABLE, + ], + [ + '--control-socket= alone', + ['--control-socket=/tmp/c.sock'], + {}, + CONTROL_SOCKET_UNAVAILABLE, + ], + [ + 'env alone', + [], + { POSTHOG_WIZARD_CONTROL_SOCKET: '/tmp/c.sock' }, + CONTROL_SOCKET_UNAVAILABLE, + ], + [ + '--control-socket with headless', + [`--${HEADLESS_FLAG}`, '--control-socket', '/tmp/c.sock'], + {}, + null, + ], + [ + 'env with headless', + [`--${HEADLESS_FLAG}`], + { POSTHOG_WIZARD_CONTROL_SOCKET: '/tmp/c.sock' }, + null, + ], + ['--e2e-ask', ['--e2e-ask'], {}, E2E_ASK_UNAVAILABLE], + [ + '--e2e-ask env', + [], + { POSTHOG_WIZARD_E2E_ASK: 'true' }, + E2E_ASK_UNAVAILABLE, + ], + [ + '--e2e-ask even with headless', + [`--${HEADLESS_FLAG}`, '--e2e-ask'], + {}, + E2E_ASK_UNAVAILABLE, + ], + ['nothing', ['--install-dir', '/tmp/app'], {}, null], + ] as const)('%s', (_label, args, env, expected) => { + expect(controlFlagRefusal(args, env)).toBe(expected); + }); +}); + +describe('published-build init', () => { + const argv = process.argv; + let stderr: string[]; + beforeEach(() => { + stderr = []; + vi.spyOn(process.stderr, 'write').mockImplementation((chunk) => { + stderr.push(String(chunk)); + return true; + }); + vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Exit('exit'); + }); + delete process.env.POSTHOG_WIZARD_CONTROL_SOCKET; + }); + afterEach(() => { + process.argv = argv; + vi.restoreAllMocks(); + vi.mocked(emitWizardError).mockClear(); + }); + + it('prints the refusal and emits the machine readable error', () => { + process.argv = [ + 'node', + 'wizard', + '--control-socket', + '/tmp/c.sock', + '--install-dir', + '/tmp/app', + ]; + expect(() => Wizard.use(basicIntegrationCommand).init()).toThrow(Exit); + expect(stderr.join('')).toContain(CONTROL_SOCKET_UNAVAILABLE); + expect(emitWizardError).toHaveBeenCalledWith( + expect.objectContaining({ message: CONTROL_SOCKET_UNAVAILABLE }), + ); + }); +}); diff --git a/src/cli/__tests__/control-surface.test.ts b/src/cli/__tests__/control-surface.test.ts new file mode 100644 index 000000000..ee0178071 --- /dev/null +++ b/src/cli/__tests__/control-surface.test.ts @@ -0,0 +1,59 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Arguments } from 'yargs'; + +const runners = vi.hoisted(() => ({ + runWizard: vi.fn(), + runWizardCI: vi.fn(), + runWizardHeadless: vi.fn(), +})); +vi.mock('../runners/index.js', () => runners); + +import { HEADLESS_FLAG } from '@env'; +import { posthogIntegrationConfig } from '@store/programs'; +import { dispatchProgram } from '../commands/factories/shared.js'; +import { GLOBAL_OPTIONS } from '../wizard.js'; + +const argv = (extra: Record): Arguments => + ({ _: [], $0: 'wizard', ...extra } as Arguments); + +describe('control socket flag', () => { + it('is a hidden global string option', () => { + expect(GLOBAL_OPTIONS['control-socket']).toMatchObject({ + type: 'string', + hidden: true, + }); + }); +}); + +describe('surface dispatch', () => { + beforeEach(() => vi.clearAllMocks()); + afterEach(() => vi.clearAllMocks()); + + it.each([ + [ + 'headless + socket', + { [HEADLESS_FLAG]: true, controlSocket: '/tmp/c.sock' }, + 'runWizardHeadless', + ], + ['headless alone', { [HEADLESS_FLAG]: true }, 'runWizardHeadless'], + ['ci + socket', { ci: true, controlSocket: '/tmp/c.sock' }, 'runWizard'], + ['ci alone', { ci: true }, 'runWizardCI'], + ['socket alone', { controlSocket: '/tmp/c.sock' }, 'runWizard'], + ['no flags', {}, 'runWizard'], + ] as const)('%s routes to %s', (_label, flags, runner) => { + dispatchProgram(posthogIntegrationConfig, argv(flags)); + for (const name of [ + 'runWizard', + 'runWizardCI', + 'runWizardHeadless', + ] as const) { + expect(runners[name], name).toHaveBeenCalledTimes( + name === runner ? 1 : 0, + ); + } + expect(runners[runner]).toHaveBeenCalledWith( + posthogIntegrationConfig, + expect.objectContaining(flags), + ); + }); +}); diff --git a/src/cli/commands/basic-integration/index.ts b/src/cli/commands/basic-integration/index.ts index 84cbaf0a6..44e2c1241 100644 --- a/src/cli/commands/basic-integration/index.ts +++ b/src/cli/commands/basic-integration/index.ts @@ -64,6 +64,10 @@ export const basicIntegrationCommand: Command = { const { runHeadlessInstall } = await import('./ci-install.js'); return runHeadlessInstall(argv); } + if (argv.ci && argv.controlSocket) { + const { runInteractive } = await import('./interactive.js'); + return runInteractive(argv); + } if (argv.ci) { const { runCIInstall } = await import('./ci-install.js'); return runCIInstall(argv); diff --git a/src/cli/commands/factories/shared.ts b/src/cli/commands/factories/shared.ts index 97c775ee5..888ab693e 100644 --- a/src/cli/commands/factories/shared.ts +++ b/src/cli/commands/factories/shared.ts @@ -45,6 +45,9 @@ export function dispatchProgram(config: ProgramConfig, argv: Arguments): void { // Same non-interactive pipeline `--ci` uses; validation (api-key, // install-dir, region) is owned by runNonInteractive. runWizardHeadless(config, options); + } else if (options.ci && options.controlSocket) { + // A controlled TUI: the real screens, API-key auth, a parent on the socket. + runWizard(config, options); } else if (options.ci) { runWizardCI(config, options); } else { diff --git a/src/cli/control-hooks.ts b/src/cli/control-hooks.ts new file mode 100644 index 000000000..fde2347bf --- /dev/null +++ b/src/cli/control-hooks.ts @@ -0,0 +1,105 @@ +import * as path from 'node:path'; +import type { RunAgent } from '@agent/types'; +import { getOrAskForProjectData, logToFile } from '@store'; +import { flowFor, getProgramConfig, runConfigFor } from '@store/programs'; +import type { + ControlHooks, + DetectRequest, + ProgramId, + RunRequest, + WizardStore, +} from '@store/types'; + +export interface ControlHookDeps { + store: WizardStore; + /** The program this process launched with. */ + programId: ProgramId; + runAgent: RunAgent; + /** Flush and exit; the runner owns the exact steps. */ + shutdown: () => Promise; +} + +/** A sub-app path stays relative to the live install dir; absolute wins. */ +function resolveInstallDir( + live: string, + requested: string | undefined, +): string { + if (!requested) return live; + return path.isAbsolute(requested) ? requested : path.join(live, requested); +} + +/** + * What the composition root does when a parent drives the run. Every agent run + * is independent: the context a run receives is exactly what the request and + * the live session hold, merged here and nowhere else. + */ +export function createControlHooks(deps: ControlHookDeps): ControlHooks { + const { store } = deps; + return { + async setCredentials() { + const s = store.session; + const d = await getOrAskForProjectData({ + signup: false, + ci: true, + apiKey: s.apiKey, + projectId: s.projectId, + baseUrl: s.baseUrl, + programId: store.activeProgram, + }); + store.setCredentials({ + accessToken: d.accessToken, + projectApiKey: d.projectApiKey, + host: d.host, + projectId: d.projectId, + }); + }, + + armRun() { + store.requestRun(); + }, + + async detect(req: DetectRequest) { + const programId = req.programId ?? deps.programId; + if (programId !== store.activeProgram) { + store.switchProgram(flowFor(programId).flow); + } + if (req.installDir) { + store.session = { + ...store.session, + installDir: resolveInstallDir( + store.session.installDir, + req.installDir, + ), + }; + } + const config = getProgramConfig(programId); + if (config.ciPreRun) { + // ciPreRun writes to the session object directly, as the headless runner + // lets it; publish the result so pollers and gates see it. + await config.ciPreRun(store.session); + store.emitChange(); + } else { + await store.runReadyHooks(); + } + }, + + async startRun(req: RunRequest) { + const config = getProgramConfig(req.programId); + const live = store.session; + const runSession = { + ...live, + installDir: resolveInstallDir(live.installDir, req.installDir), + frameworkContext: { + ...live.frameworkContext, + ...(req.frameworkContext ?? {}), + }, + skillId: req.skillId ?? config.skillId ?? live.skillId, + programLabel: config.id, + }; + logToFile(`[control] run ${config.id} in ${runSession.installDir}`); + await deps.runAgent(runConfigFor(config), runSession, { composed: true }); + }, + + shutdown: deps.shutdown, + }; +} diff --git a/src/cli/runners/run-non-interactive.ts b/src/cli/runners/run-non-interactive.ts index 54c479685..c165a5e71 100644 --- a/src/cli/runners/run-non-interactive.ts +++ b/src/cli/runners/run-non-interactive.ts @@ -24,6 +24,7 @@ import type { import { LoggingUI } from '@tui/console'; import { runConfigFor, getAuditChecks, flowFor } from '@store/programs'; import { resolveNoTelemetry } from './resolve-no-telemetry.js'; +import { createControlHooks } from '../control-hooks.js'; import { join } from 'node:path'; /** @@ -205,8 +206,16 @@ export function runNonInteractive( const headlessStore = new WizardStore(flowFor(config.id).flow); store = headlessStore; + // A controlled run answers the agent's questions over the socket, so the + // ask bridge stays wired despite `ci`. + if (options.controlSocket) session.e2eAsk = true; headlessStore.session = session; - setUI(new HeadlessUI(headlessStore)); + if (options.controlSocket) { + const { StoreUI } = await import('@store'); + setUI(new StoreUI(headlessStore)); + } else { + setUI(new HeadlessUI(headlessStore)); + } taskStream = new TaskStreamPush({ store: headlessStore, programId: config.streamWorkflowId ?? config.id, @@ -220,7 +229,7 @@ export function runNonInteractive( enabled: destinations.length > 0, }); taskStream.attach(); - headlessStore.setRunPhase(RunPhase.Running); + if (!options.controlSocket) headlessStore.setRunPhase(RunPhase.Running); if (fileDestination) { logToFile(`[task-stream] ${mode} dump: ${fileDestination.path}`); } @@ -246,6 +255,46 @@ export function runNonInteractive( session.region ?? 'us', ); } + + // Controlled headless: nothing runs until the parent asks. Detection, + // independent runs, and the exit all arrive over the socket. + if (options.controlSocket && store) { + const controlledStore = store; + const { attachControlServer } = await import('@store/control'); + const { runAgent } = await import('@agent'); + const { VERSION } = await import('@store'); + let release: (() => void) | null = null; + const served = new Promise((resolve) => { + release = resolve; + }); + const handle = await attachControlServer(controlledStore, { + socketPath: options.controlSocket as string, + surface: 'headless', + version: VERSION, + program: config.id, + hooks: createControlHooks({ + store: controlledStore, + programId: config.id, + runAgent, + shutdown: () => { + release?.(); + return Promise.resolve(); + }, + }), + }); + const onSignal = (): void => release?.(); + process.once('SIGINT', onSignal); + process.once('SIGTERM', onSignal); + logToFile(`[control] serving ${config.id} on ${handle.socketPath}`); + await served; + process.off('SIGINT', onSignal); + process.off('SIGTERM', onSignal); + await handle.close(); + if (taskStream) await taskStream.shutdown(2000); + // Nothing else holds the loop: the process ends here with status 0. + return; + } + if (config.ciPreRun) { await config.ciPreRun(session); } else { diff --git a/src/cli/runners/run-wizard.ts b/src/cli/runners/run-wizard.ts index aabad35df..53d90adc3 100644 --- a/src/cli/runners/run-wizard.ts +++ b/src/cli/runners/run-wizard.ts @@ -28,6 +28,9 @@ import type { TaskStreamPush as TaskStreamPushClass, } from '@store/types'; import type { TuiHandle } from '@tui/types'; +import type { ControlServerHandle, CloudRegion } from '@store/types'; +import { IS_PRODUCTION_BUILD } from '@env'; +import { createControlHooks } from '../control-hooks.js'; import { resolveNoTelemetry } from './resolve-no-telemetry.js'; import { join } from 'node:path'; @@ -96,6 +99,7 @@ export function runWizard( options: Record, ): void { let tui: TuiHandle | null = null; + let control: ControlServerHandle | null = null; let taskStream: TaskStreamPushClass | null = null; let onSignal: (() => void) | null = null; let exitInProgress = false; @@ -136,7 +140,12 @@ export function runWizard( localMcp: options.localMcp as boolean | undefined, localPosthog: options.localPosthog as boolean | undefined, installDir, - ci: false, + // A controlled TUI (`--ci --control-socket`) authenticates with the API + // key; every other TUI run goes through OAuth. + ci: options.ci === true && Boolean(options.controlSocket), + e2eAsk: options.e2eAsk === true, + controlSocket: options.controlSocket as string | undefined, + region: options.region as CloudRegion | undefined, signup: options.signup as boolean | undefined, apiKey: options.apiKey as string | undefined, projectId: options.projectId as string | undefined, @@ -184,6 +193,7 @@ export function runWizard( } process.exit(130); }; + void control?.close(); const stream = taskStream; if (!stream) { teardown(); @@ -199,8 +209,40 @@ export function runWizard( process.on('SIGINT', onSignal); process.on('SIGTERM', onSignal); + // Dev only: the parent reads state, commits actions, and releases the run + // over the socket. Rolldown folds this branch out of published builds, so + // a shipped TUI never carries the server. + if (!IS_PRODUCTION_BUILD && options.controlSocket) { + const { attachControlServer } = await import('@store/control'); + control = await attachControlServer(activeTui.store, { + socketPath: options.controlSocket as string, + surface: 'tui', + version: WIZARD_VERSION, + program: config.id, + hooks: createControlHooks({ + store: activeTui.store, + programId: config.id, + runAgent: async (...args) => { + const { runAgent } = await import('@agent'); + return runAgent(...args); + }, + shutdown: async () => { + exitInProgress = true; + runCleanups(); + await taskStream?.shutdown(2000); + await control?.close(); + activeTui.unmount(); + process.exit(0); + }, + }), + }); + } + for (;;) { await activeTui.store.runReadyHooks(); + // Gates latch, so the parent may confirm setup and release the run in + // either order. Without a parent nothing waits here. + if (control) await activeTui.store.waitUntil((s) => s.runRequested); // Settle the pre-run screens; `integration-check` is a no-op gate here. await activeTui.store.getGate('intro'); @@ -246,6 +288,16 @@ export function runWizard( await activeTui.store.getGate('integration-check'); await activeTui.store.getGate('health-check'); + if (session.ci) { + // API-key sessions carry no OAuth token, so the gateway bearer comes + // from the CI environment, as it does for `--ci` and the e2e host. + const { configureGatewayFromCIEnvironment } = await import('@agent'); + configureGatewayFromCIEnvironment( + Number(session.projectId), + session.region ?? 'us', + ); + } + const skipAgent = config.run == null; const shown = (s: ProgramConfig['steps'][number]) => !s.show || s.show(activeTui.store.session); @@ -307,6 +359,7 @@ export function runWizard( exitInProgress = true; await activeStream.shutdown(2000); + await control?.close(); process.off('SIGINT', onSignal); process.off('SIGTERM', onSignal); if (runFailed) await analytics.shutdown('error'); @@ -332,6 +385,7 @@ export function runWizard( // ignore } } + await control?.close(); if (tui) { try { tui.unmount(); diff --git a/src/cli/wizard.ts b/src/cli/wizard.ts index aa15d05ee..1a0823686 100644 --- a/src/cli/wizard.ts +++ b/src/cli/wizard.ts @@ -1,7 +1,7 @@ import yargs from 'yargs'; import { hideBin } from 'yargs/helpers'; import type { Argv } from 'yargs'; -import { IS_PRODUCTION_BUILD } from '@env'; +import { HEADLESS_FLAG, IS_PRODUCTION_BUILD } from '@env'; import { Harness, Sequence, @@ -80,6 +80,14 @@ export const GLOBAL_OPTIONS = { type: 'boolean' as const, hidden: true, }, + // Always declared so the published headless path accepts it; published TUI + // runs refuse it in init(). HTTP/1.1 over the unix socket at this path. + 'control-socket': { + describe: + 'Serve the control API over this unix socket path\nenv: POSTHOG_WIZARD_CONTROL_SOCKET', + type: 'string' as const, + hidden: true, + }, }; export class Wizard { @@ -109,6 +117,15 @@ export class Wizard { type: 'boolean', hidden: true, }) + // Keeps wizard_ask wired in a --ci session so a controlling parent + // answers the agent's questions; see shouldDisableAsk. + .option('e2e-ask', { + default: false, + describe: + 'Answer wizard_ask over the control socket in a --ci run\nenv: POSTHOG_WIZARD_E2E_ASK', + type: 'boolean', + hidden: true, + }) // Runner overrides — dev/test only, same lifecycle as --ci. .option('harness', { describe: @@ -288,6 +305,16 @@ export class Wizard { process.exit(1); } + const controlRefusal = controlFlagRefusal(args, process.env); + if (controlRefusal) { + process.stderr.write(`\n\x1b[1;91m✖ ${controlRefusal}\x1b[0m\n\n`); + emitWizardError({ + code: ErrorCodes.CliFlagUnavailable, + message: controlRefusal, + }); + process.exit(1); + } + // `--local-mcp` used to be declared unconditionally, so published builds // accepted it and quietly aimed the run at localhost. Reject explicitly. const argvHasLocalTarget = args.some((a) => @@ -316,6 +343,37 @@ export class Wizard { } } +export const CONTROL_SOCKET_UNAVAILABLE = + '--control-socket is only available with the experimental headless flag in published builds.'; +export const E2E_ASK_UNAVAILABLE = + '--e2e-ask is not available in published builds.'; + +const hasFlag = (args: readonly string[], flag: string): boolean => + args.some( + (a) => + a === `--${flag}` || a === `--no-${flag}` || a.startsWith(`--${flag}=`), + ); +const hasEnv = (env: NodeJS.ProcessEnv, key: string): boolean => + env[key] != null && env[key] !== ''; + +/** + * Published builds: the control socket ships for headless runs only, and + * `--e2e-ask` never ships. Returns the refusal to print, or null to proceed. + */ +export function controlFlagRefusal( + args: readonly string[], + env: NodeJS.ProcessEnv, +): string | null { + const wantsControl = + hasFlag(args, 'control-socket') || + hasEnv(env, 'POSTHOG_WIZARD_CONTROL_SOCKET'); + if (wantsControl && !hasFlag(args, HEADLESS_FLAG)) + return CONTROL_SOCKET_UNAVAILABLE; + if (hasFlag(args, 'e2e-ask') || hasEnv(env, 'POSTHOG_WIZARD_E2E_ASK')) + return E2E_ASK_UNAVAILABLE; + return null; +} + /** Excludes bare `local`: `wizard mcp add --local` stays available in prod. */ const LOCAL_TARGET_FLAGS = [ 'local-dev', diff --git a/src/store/README.md b/src/store/README.md index 1f6b8c6ba..891541abd 100644 --- a/src/store/README.md +++ b/src/store/README.md @@ -12,6 +12,14 @@ Render-agnostic state and the contract between the agent and whatever renders. - `tools/`: wizard tool behavior shared by every harness facade. - `detection/`, `frameworks`, `services/`, `security/`, `task-stream/`, `shared/`. +- `control/`: the control API. An HTTP/1.1 server over a unix socket that reads + one store and commits through its setters: `GET /health`, `GET /state` (long + poll with `?wait=&since=`), `GET /runs`, `POST /actions/`, + `POST /credentials`, `POST /run` (TUI surface), `POST /detect` and + `POST /runs` (headless surface), `POST /shutdown`. The store never runs + agents; `ControlHooks` from the cli do. Generic actions live in + `control/actions.ts`; a program adds its own through + `FlowStep.controlActions`. ## Never contains @@ -25,6 +33,9 @@ Ink, console output, or any import of `@agent`, `@tui`, or `@cli`. - `index.ts`: runtime API. `types.ts`: every type another surface consumes. - `programs/index.ts`: program registry and definitions. +- `control/index.ts`: the control server and client. Loaded only through a + dynamic import from the two cli runners, so a published TUI never carries it; + `scripts/smoke-test.sh` audits the built chunks. - Agent implementations arrive by injection: `setUI`, `setDetectionAgent`, `setMcpPromptRunner`. diff --git a/src/store/control/__tests__/actions.test.ts b/src/store/control/__tests__/actions.test.ts new file mode 100644 index 000000000..76fc7f904 --- /dev/null +++ b/src/store/control/__tests__/actions.test.ts @@ -0,0 +1,370 @@ +import { describe, expect, it } from 'vitest'; +import { ERROR_TRACKING_PROJECT_PATH_KEY } from '../../programs/error-tracking/detect-agentic.js'; +import { SOURCE_MAPS_CONTEXT_KEYS } from '../../programs/error-tracking-upload-source-maps/detect.js'; +import { flowFor } from '../../programs/flow-for.js'; +import { Program, PROGRAM_REGISTRY } from '../../programs/program-registry.js'; +import { SELF_DRIVING_INTEGRATE_PATH_KEY } from '../../programs/self-driving/detect.js'; +import { buildSession, McpOutcome } from '../../session/wizard-session.js'; +import type { Flow } from '../../state/flow.js'; +import { Interrupt } from '../../state/interrupts.js'; +import { WizardStore } from '../../state/store.js'; +import { createTestStore } from '../../testing/index.js'; +import { setUI } from '../../ui/index.js'; +import { StoreUI } from '../../ui/store-ui.js'; +import { + actionsFor, + BadParamError, + GENERIC_ACTIONS, + MissingParamError, + NO_ACTION_SCREENS, + UnknownActionError, +} from '../actions.js'; +import { ControlDriver } from '../driver.js'; + +const IDLE = () => ({ status: 'idle' as const, error: null }); + +function storeFor(program = Program.PostHogIntegration): WizardStore { + const store = createTestStore(program); + setUI(new StoreUI(store)); + store.session = buildSession({ + installDir: '/tmp/control-actions', + ci: true, + }); + return store; +} + +function apply(store: WizardStore, screen: string, id: string, params = {}) { + const action = actionsFor(store.flow, screen).find((a) => a.id === id); + if (!action) throw new Error(`no ${id} on ${screen}`); + action.apply(store, params); +} + +describe('generic actions', () => { + it('every intro confirms setup', () => { + for (const screen of ['intro', 'audit-intro', 'self-driving-intro']) { + const store = storeFor(); + apply(store, screen, 'confirm_setup'); + expect(store.session.setupConfirmed).toBe(true); + } + }); + + it.each([ + [ + 'health-check', + 'dismiss_outage', + {}, + (s: WizardStore) => s.session.outageDismissed === true, + ], + [ + 'setup', + 'choose', + { key: 'router', value: 'app' }, + (s: WizardStore) => s.session.frameworkContext.router === 'app', + ], + [ + 'outro', + 'dismiss_outro', + {}, + (s: WizardStore) => s.session.outroDismissed, + ], + [ + 'audit-outro', + 'dismiss_outro', + {}, + (s: WizardStore) => s.session.outroDismissed, + ], + [ + 'source-maps-outro', + 'dismiss_outro', + {}, + (s: WizardStore) => s.session.outroDismissed, + ], + [ + 'mint-failure', + 'continue_setup', + {}, + (s: WizardStore) => s.session.mintHandoff === 'continue', + ], + [ + 'mint-failure', + 'dismiss_outro', + {}, + (s: WizardStore) => s.session.mintHandoff === 'exit', + ], + [ + 'mcp', + 'set_mcp_outcome', + { outcome: 'installed', clients: ['cursor'] }, + (s: WizardStore) => + s.session.mcpComplete && + s.session.mcpOutcome === McpOutcome.Installed && + s.session.mcpInstalledClients[0] === 'cursor', + ], + [ + 'mcp-add', + 'set_mcp_outcome', + { outcome: 'skipped' }, + (s: WizardStore) => s.session.mcpOutcome === McpOutcome.Skipped, + ], + [ + 'mcp-remove', + 'set_mcp_outcome', + {}, + (s: WizardStore) => s.session.mcpOutcome === McpOutcome.Skipped, + ], + [ + 'mcp-suggested-prompts', + 'dismiss', + {}, + (s: WizardStore) => s.session.mcpSuggestedPromptsDismissed, + ], + [ + 'slack-connect', + 'dismiss_slack', + {}, + (s: WizardStore) => s.session.slackStepDismissed, + ], + [ + 'slack-connect', + 'set_slack_connected', + { connected: true }, + (s: WizardStore) => s.session.slackConnected === true, + ], + [ + 'keep-skills', + 'keep_skills', + { kept: false }, + (s: WizardStore) => s.session.skillsComplete, + ], + ] as const)( + '%s / %s commits through its setter', + (screen, id, params, check) => { + const store = storeFor(); + apply(store, screen, id, params); + expect(check(store)).toBe(true); + }, + ); + + it('answers and cancels a pending wizard_ask', async () => { + const store = storeFor(); + const asked = store.requestQuestion({ + id: 'q', + subject: 'test', + questions: [{ id: 'color', question: 'Which?', kind: 'text' }], + } as never); + apply(store, Interrupt.WizardAsk, 'answer_question', { + answers: { color: 'red' }, + }); + await expect(asked).resolves.toEqual({ color: 'red' }); + + const cancelled = store.requestQuestion({ + id: 'q2', + subject: 'test', + questions: [{ id: 'size', question: 'How big?', kind: 'text' }], + } as never); + apply(store, Interrupt.WizardAsk, 'cancel_question'); + await expect(cancelled).resolves.toEqual({ size: '__cancelled__' }); + }); + + it('resolves a task notice, defaulting to keep', async () => { + const store = storeFor(); + const kept = store.showTaskNotice({ + title: 't', + items: [], + prompt: 'p', + } as never); + apply(store, Interrupt.TaskNotice, 'resolve_notice'); + await expect(kept).resolves.toBe(true); + const skipped = store.showTaskNotice({ + title: 't', + items: [], + prompt: 'p', + } as never); + apply(store, Interrupt.TaskNotice, 'resolve_notice', { keep: false }); + await expect(skipped).resolves.toBe(false); + }); + + it('resolves the port conflict and the manual auth code overlays', async () => { + const store = storeFor(); + void store.showPortConflict({ + command: 'x', + pid: '1', + port: 8010, + user: 'u', + }); + expect(store.hasInterrupt).toBe(true); + apply(store, Interrupt.PortConflict, 'resolve_port_conflict'); + expect(store.hasInterrupt).toBe(false); + + const code = store.waitForManualAuthCode(); + store.showManualAuthCode(); + apply(store, Interrupt.ManualAuthCode, 'submit_auth_code', { code: 'abc' }); + await expect(code).resolves.toBe('abc'); + store.showManualAuthCode(); + apply(store, Interrupt.ManualAuthCode, 'dismiss_auth_code'); + expect(store.hasInterrupt).toBe(false); + }); + + it('backs up and fixes a settings override through the store callback', async () => { + const store = storeFor(); + const fix = vi.fn(() => true); + const settled = store.showSettingsOverride( + [ + { + source: 'project', + writable: true, + keys: ['apiKeyHelper'], + path: '/p', + }, + ] as never, + fix, + ); + apply(store, Interrupt.SettingsOverride, 'backup_and_fix'); + expect(fix).toHaveBeenCalledTimes(1); + await expect(settled).resolves.toBeUndefined(); + }); + + it('rejects a missing required param', () => { + const store = storeFor(); + expect(() => apply(store, 'setup', 'choose', { key: 'router' })).toThrow( + MissingParamError, + ); + expect(() => + apply(store, Interrupt.ManualAuthCode, 'submit_auth_code', {}), + ).toThrow(MissingParamError); + }); +}); + +describe('program actions', () => { + it('self-driving picks the integration target and answers its check', () => { + const store = storeFor(Program.SelfDriving); + apply(store, 'self-driving-integration-check', 'set_integrate', { + integrate: true, + }); + expect(store.session.integrate).toBe(true); + apply(store, 'self-driving-integration-detect', 'pick_integration_target', { + path: 'apps/web', + integration: 'nextjs', + }); + expect( + store.session.frameworkContext[SELF_DRIVING_INTEGRATE_PATH_KEY], + ).toBe('apps/web'); + expect(store.session.integration).toBe('nextjs'); + apply(store, 'self-driving-handoff', 'confirm_self_driving_handoff'); + expect(store.session.selfDrivingHandoffConfirmed).toBe(true); + apply(store, 'self-driving-github', 'set_github_connected', { + connected: true, + }); + expect(store.session.githubConnected).toBe(true); + }); + + it('self-driving declines GitHub with the required-connection outro', () => { + const store = storeFor(Program.SelfDriving); + apply(store, 'self-driving-github', 'decline_github'); + expect(store.session.githubDeclined).toBe(true); + expect(store.session.outroData?.kind).toBe('cancel'); + }); + + it('error-tracking picks the project the run is scoped to', () => { + const store = storeFor(Program.ErrorTracking); + apply(store, 'error-tracking-detect', 'pick_integration_target', { + path: '.', + integration: 'django', + }); + expect( + store.session.frameworkContext[ERROR_TRACKING_PROJECT_PATH_KEY], + ).toBe('.'); + expect(store.session.integration).toBe('django'); + }); + + it('rejects an unknown framework id', () => { + const store = storeFor(Program.ErrorTracking); + expect(() => + apply(store, 'error-tracking-detect', 'pick_integration_target', { + path: '.', + integration: 'cobol', + }), + ).toThrow(BadParamError); + }); + + it('source-maps commits the pick the detect screen would', () => { + const store = storeFor(Program.ErrorTrackingUploadSourceMaps); + apply(store, 'source-maps-detect', 'pick_source_maps_project', { + variant: 'node', + path: '.', + }); + const ctx = store.session.frameworkContext; + expect(ctx[SOURCE_MAPS_CONTEXT_KEYS.selectedVariant]).toBe('node'); + expect(ctx[SOURCE_MAPS_CONTEXT_KEYS.selectedPath]).toBe('.'); + expect(typeof ctx[SOURCE_MAPS_CONTEXT_KEYS.selectedDisplayName]).toBe( + 'string', + ); + expect(() => + apply(store, 'source-maps-detect', 'pick_source_maps_project', { + variant: 'node', + }), + ).toThrow(MissingParamError); + }); + + it('a program action wins over a generic one with the same id', () => { + const own = { + id: 'confirm_setup', + description: 'own', + apply: () => undefined, + }; + const flow: Flow = { + programId: Program.PostHogIntegration, + skillId: null, + steps: [ + { + id: 'intro', + label: 'Intro', + screenId: 'intro', + controlActions: [own], + }, + ], + }; + const actions = actionsFor(flow, 'intro'); + expect(actions.map((a) => a.id)).toEqual(['confirm_setup']); + expect(actions[0]).toBe(own); + }); +}); + +describe('coverage', () => { + it('every flow key and interrupt is actionable or explicitly no-action', () => { + const missing: string[] = []; + for (const config of PROGRAM_REGISTRY) { + const { flow } = flowFor(config.id); + for (const step of flow.steps) { + if (!step.screenId) continue; + if ( + actionsFor(flow, step.screenId).length === 0 && + !NO_ACTION_SCREENS.has(step.screenId) + ) { + missing.push(`${config.id}:${step.screenId}`); + } + } + } + for (const interrupt of Object.values(Interrupt)) { + if (!(interrupt in GENERIC_ACTIONS) && !NO_ACTION_SCREENS.has(interrupt)) + missing.push(interrupt); + } + expect(missing).toEqual([]); + }); + + it('no screen is both actionable and no-action', () => { + const both = [...NO_ACTION_SCREENS].filter((s) => s in GENERIC_ACTIONS); + expect(both).toEqual([]); + }); + + it('the driver rejects an action the current screen does not offer', () => { + const store = storeFor(); + const driver = new ControlDriver(store, IDLE); + expect(() => driver.performAction('keep_skills')).toThrow( + UnknownActionError, + ); + expect(driver.performAction('confirm_setup').session.setupConfirmed).toBe( + true, + ); + }); +}); diff --git a/src/store/control/__tests__/server.test.ts b/src/store/control/__tests__/server.test.ts new file mode 100644 index 000000000..e0f5aa6b0 --- /dev/null +++ b/src/store/control/__tests__/server.test.ts @@ -0,0 +1,378 @@ +import * as fs from 'node:fs'; +import * as http from 'node:http'; +import * as net from 'node:net'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { Program } from '../../programs/program-registry.js'; +import { + buildSession, + OutroKind, + RunPhase, +} from '../../session/wizard-session.js'; +import { createTestStore } from '../../testing/index.js'; +import { setUI } from '../../ui/index.js'; +import { StoreUI } from '../../ui/store-ui.js'; +import { ControlClient, ControlClientError } from '../client.js'; +import { + attachControlServer, + MAX_BODY_BYTES, + type ControlServerHandle, +} from '../server.js'; +import type { ControlHooks, ControlSurface } from '../types.js'; + +const handles: ControlServerHandle[] = []; +const dirs: string[] = []; + +afterEach(async () => { + await Promise.all(handles.splice(0).map((h) => h.close())); + for (const dir of dirs.splice(0)) + fs.rmSync(dir, { recursive: true, force: true }); +}); + +function socketDir(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wz-ctl-')); + dirs.push(dir); + return dir; +} + +async function serve( + surface: ControlSurface = 'headless', + program = Program.PostHogIntegration, +) { + const store = createTestStore(program); + setUI(new StoreUI(store)); + store.session = buildSession({ installDir: '/tmp/control-server', ci: true }); + const hooks: ControlHooks = { + setCredentials: vi.fn(() => { + store.setCredentials({ + accessToken: 'phx_SECRET', + projectApiKey: 'phc_TOKEN', + host: {} as never, + projectId: 7, + }); + return Promise.resolve(); + }), + armRun: vi.fn(() => store.requestRun()), + detect: vi.fn(() => { + store.setDetectionComplete(); + return Promise.resolve(); + }), + startRun: vi.fn(async () => { + store.setRunPhase(RunPhase.Running); + await new Promise((r) => setTimeout(r, 30)); + store.setOutroData({ kind: OutroKind.Success, message: 'done' }); + store.setRunPhase(RunPhase.Completed); + }), + shutdown: vi.fn(() => Promise.resolve()), + }; + const socketPath = path.join(socketDir(), 'c.sock'); + const handle = await attachControlServer(store, { + socketPath, + surface, + hooks, + version: '0.0.0-test', + program, + }); + handles.push(handle); + return { + store, + hooks, + handle, + socketPath, + client: new ControlClient(socketPath), + }; +} + +/** A raw request, for the malformed cases the client cannot produce. */ +function raw( + socketPath: string, + method: string, + reqPath: string, + body?: string, + headers: Record = {}, +) { + return new Promise<{ status: number; json: Record }>( + (resolve, reject) => { + const req = http.request( + { socketPath, method, path: reqPath, headers }, + (res) => { + let text = ''; + res.on('data', (c: Buffer) => (text += c.toString())); + res.on('end', () => + resolve({ + status: res.statusCode ?? 0, + json: JSON.parse(text) as Record, + }), + ); + }, + ); + req.on('error', reject); + if (body !== undefined) req.write(body); + req.end(); + }, + ); +} + +describe('control server', () => { + it('answers health with the surface and program', async () => { + const { client } = await serve('tui', Program.Audit); + expect(await client.health()).toMatchObject({ + ok: true, + surface: 'tui', + program: Program.Audit, + pid: process.pid, + version: '0.0.0-test', + }); + }); + + it('serves the state and applies actions through their setters', async () => { + const { client, store } = await serve(); + const before = await client.state(); + expect(before.currentScreen).toBe('intro'); + expect(before.actions.map((a) => a.id)).toEqual(['confirm_setup']); + const after = await client.performAction('confirm_setup'); + expect(after.session.setupConfirmed).toBe(true); + expect(after.version).toBeGreaterThan(before.version); + expect(store.session.setupConfirmed).toBe(true); + }); + + it('maps bad input to 400, unknown routes to 404', async () => { + const { client, socketPath } = await serve(); + await expect(client.performAction('keep_skills')).rejects.toMatchObject({ + status: 400, + }); + expect((await raw(socketPath, 'GET', '/nope')).status).toBe(404); + expect( + ( + await raw(socketPath, 'POST', '/nope', '{}', { + 'content-type': 'application/json', + }) + ).status, + ).toBe(404); + expect( + ( + await raw(socketPath, 'POST', '/actions/confirm_setup', '{', { + 'content-type': 'application/json', + }) + ).status, + ).toBe(400); + expect( + ( + await raw(socketPath, 'POST', '/runs', '{}', { + 'content-type': 'application/json', + }) + ).status, + ).toBe(400); + }); + + it('rejects a missing param with 400 and the param name', async () => { + const { client, store } = await serve( + 'headless', + Program.ErrorTrackingUploadSourceMaps, + ); + store.completeSetup(); + store.setCredentials({ + accessToken: 't', + projectApiKey: 'k', + host: {} as never, + projectId: 1, + }); + expect((await client.state()).currentScreen).toBe('source-maps-detect'); + await expect( + client.performAction('pick_source_maps_project', { variant: 'node' }), + ).rejects.toMatchObject({ + status: 400, + message: expect.stringContaining('"path"'), + }); + }); + + it('caps the body and requires JSON', async () => { + const { socketPath } = await serve(); + const big = JSON.stringify({ + params: { pad: 'x'.repeat(MAX_BODY_BYTES + 1) }, + }); + const r = await raw(socketPath, 'POST', '/actions/confirm_setup', big, { + 'content-type': 'application/json', + }).catch(() => ({ status: 413, json: {} })); + expect(r.status).toBe(413); + expect( + ( + await raw(socketPath, 'POST', '/actions/confirm_setup', 'x', { + 'content-type': 'text/plain', + }) + ).status, + ).toBe(415); + }); + + it('long polls until the next commit, and returns on timeout', async () => { + const { client, store } = await serve(); + const since = (await client.state()).version; + const pending = client.waitForChange(since, 5000); + setTimeout(() => store.completeSetup(), 20); + const state = await pending; + expect(state.version).toBeGreaterThan(since); + expect(state.session.setupConfirmed).toBe(true); + + const started = Date.now(); + const timedOut = await client.waitForChange(state.version, 60); + expect(timedOut.version).toBe(state.version); + expect(Date.now() - started).toBeGreaterThanOrEqual(50); + }); + + it('wakes every concurrent poller on one commit', async () => { + const { client, store } = await serve(); + const since = (await client.state()).version; + const a = client.waitForChange(since, 5000); + const b = client.waitForChange(since, 5000); + setTimeout(() => store.completeSetup(), 20); + const [sa, sb] = await Promise.all([a, b]); + expect(sa.version).toBe(sb.version); + expect(sa.version).toBeGreaterThan(since); + }); + + it('owns its socket: 0600, stale files replaced, live sockets refused, unlinked on close', async () => { + const dir = socketDir(); + const stale = path.join(dir, 'stale.sock'); + fs.writeFileSync(stale, ''); + const store = createTestStore(); + setUI(new StoreUI(store)); + const hooks = { + setCredentials: vi.fn(), + armRun: vi.fn(), + detect: vi.fn(), + startRun: vi.fn(), + shutdown: vi.fn(), + } as unknown as ControlHooks; + const handle = await attachControlServer(store, { + socketPath: stale, + surface: 'tui', + hooks, + version: 't', + program: 'p', + }); + expect(fs.statSync(stale).mode & 0o777).toBe(0o600); + expect((await new ControlClient(stale).health()).ok).toBe(true); + + const live = path.join(dir, 'live.sock'); + const other = net.createServer().listen(live); + await new Promise((r) => other.once('listening', r)); + await expect( + attachControlServer(store, { + socketPath: live, + surface: 'tui', + hooks, + version: 't', + program: 'p', + }), + ).rejects.toThrow(/already served/); + other.close(); + + await handle.close(); + expect(fs.existsSync(stale)).toBe(false); + }); + + it('runs one independent run at a time and records it in the ledger', async () => { + const { client, hooks } = await serve('headless'); + const run = await client.startRun({ + programId: Program.PostHogIntegration, + installDir: '/tmp/app', + }); + expect(run).toMatchObject({ + status: 'running', + programId: Program.PostHogIntegration, + installDir: '/tmp/app', + }); + expect(hooks.startRun).toHaveBeenCalledWith({ + programId: Program.PostHogIntegration, + installDir: '/tmp/app', + }); + await expect( + client.startRun({ programId: Program.PostHogIntegration }), + ).rejects.toMatchObject({ status: 409 }); + expect((await client.state()).run.status).toBe('running'); + + await new Promise((r) => setTimeout(r, 80)); + const runs = await client.runs(); + expect(runs).toHaveLength(1); + expect(runs[0]).toMatchObject({ + runId: run.runId, + status: 'done', + error: null, + }); + expect(runs[0].result).toMatchObject({ + runPhase: RunPhase.Completed, + outroData: { kind: OutroKind.Success, message: 'done' }, + }); + expect(runs[0].finishedAt).not.toBeNull(); + expect((await client.state()).run.status).toBe('done'); + }); + + it('records a failed run with its error', async () => { + const { client, hooks } = await serve('headless'); + vi.mocked(hooks.startRun).mockRejectedValueOnce( + new Error('gateway refused'), + ); + await client.startRun({ programId: Program.PostHogIntegration }); + await new Promise((r) => setTimeout(r, 20)); + const [record] = await client.runs(); + expect(record).toMatchObject({ + status: 'failed', + error: 'gateway refused', + }); + expect((await client.state()).run).toEqual({ + status: 'failed', + error: 'gateway refused', + }); + }); + + it('serves each surface its own hooks and answers 501 for the other', async () => { + const headless = await serve('headless'); + await expect(headless.client.armRun()).rejects.toMatchObject({ + status: 501, + }); + expect( + (await headless.client.detect({ programId: Program.Audit })).session + .detectionComplete, + ).toBe(true); + expect(headless.hooks.detect).toHaveBeenCalledWith({ + programId: Program.Audit, + }); + + const tui = await serve('tui'); + await expect( + tui.client.startRun({ programId: Program.Audit }), + ).rejects.toMatchObject({ status: 501 }); + await expect(tui.client.detect()).rejects.toMatchObject({ status: 501 }); + expect(await tui.client.armRun()).toEqual({ + status: 'running', + error: null, + }); + expect(tui.store.session.runRequested).toBe(true); + expect(await tui.client.armRun()).toEqual({ + status: 'running', + error: null, + }); + }); + + it('commits credentials through the hook and shuts down once', async () => { + const { client, hooks } = await serve('tui'); + const state = await client.setCredentials(); + expect(hooks.setCredentials).toHaveBeenCalledTimes(1); + expect(state.session).toMatchObject({ hasCredentials: true, projectId: 7 }); + expect(JSON.stringify(state)).not.toContain('phx_SECRET'); + + await client.shutdown(); + await client.shutdown(); + await new Promise((r) => setTimeout(r, 10)); + expect(hooks.shutdown).toHaveBeenCalledTimes(1); + }); + + it('surfaces a hook failure as 500 with its message', async () => { + const { client, hooks } = await serve('tui'); + vi.mocked(hooks.setCredentials).mockRejectedValueOnce(new Error('no key')); + await expect(client.setCredentials()).rejects.toEqual( + new ControlClientError(500, 'no key'), + ); + }); +}); diff --git a/src/store/control/__tests__/state.test.ts b/src/store/control/__tests__/state.test.ts new file mode 100644 index 000000000..85d752ab6 --- /dev/null +++ b/src/store/control/__tests__/state.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from 'vitest'; +import { + buildSession, + OutroKind, + RunPhase, +} from '../../session/wizard-session.js'; +import { createTestStore } from '../../testing/index.js'; +import { setUI } from '../../ui/index.js'; +import { StoreUI } from '../../ui/store-ui.js'; +import { + contextDigest, + projectState, + redactContext, + runResult, +} from '../state.js'; + +const RUN = { status: 'idle' as const, error: null }; + +describe('projectState', () => { + it('never carries a credential, an api key, a user, or a vaulted answer', () => { + const store = createTestStore(); + setUI(new StoreUI(store)); + store.session = buildSession({ + installDir: '/tmp/control-state', + ci: true, + apiKey: 'phx_PERSONAL_SECRET', + }); + store.setCredentials({ + accessToken: 'phx_ACCESS_SECRET', + projectApiKey: 'phc_PROJECT_TOKEN', + host: {} as never, + projectId: 42, + }); + store.setApiUser({ + email: 'someone@example.com', + uuid: 'user-uuid', + } as never); + store.setFrameworkContext('router', 'app'); + store.setFrameworkContext('upload-api-key', 'phs_UPLOAD_SECRET'); + store.setFrameworkContext( + 'answer', + 'secret:0b7c6d2e-1a2b-4c3d-8e9f-0a1b2c3d4e5f', + ); + + const state = projectState(store, RUN); + const text = JSON.stringify(state); + for (const leak of [ + 'phx_', + 'phc_', + 'phs_', + 'secret:0b7c', + 'someone@example.com', + 'user-uuid', + ]) { + expect(text, leak).not.toContain(leak); + } + expect(state.session.hasCredentials).toBe(true); + expect(state.session.projectId).toBe(42); + expect(state.frameworkContext.values).toEqual({ + router: 'app', + 'upload-api-key': '[redacted]', + answer: '[secret-ref]', + }); + expect(state.frameworkContext.keys).toEqual([ + 'answer', + 'router', + 'upload-api-key', + ]); + expect(state.frameworkContext.digest).toBe( + contextDigest(store.session.frameworkContext), + ); + }); + + it('projects the screen, its actions, and the version', () => { + const store = createTestStore(); + setUI(new StoreUI(store)); + store.session = buildSession({ + installDir: '/tmp/control-state', + ci: true, + }); + const before = projectState(store, RUN); + expect(before.currentScreen).toBe('intro'); + expect(before.actions.map((a) => a.id)).toEqual(['confirm_setup']); + expect(before.hasOverlay).toBe(false); + store.completeSetup(); + const after = projectState(store, { status: 'running', error: null }); + expect(after.version).toBeGreaterThan(before.version); + expect(after.session.setupConfirmed).toBe(true); + expect(after.run).toEqual({ status: 'running', error: null }); + }); + + it('reduces the outro and reports the run result', () => { + const store = createTestStore(); + setUI(new StoreUI(store)); + store.session = buildSession({ + installDir: '/tmp/control-state', + ci: true, + }); + store.setOutroData({ + kind: OutroKind.Error, + message: 'boom', + body: 'long body copy', + errorCode: 'PHW_INTERNAL_UNHANDLED' as never, + }); + store.setDashboardUrl('https://us.posthog.com/project/1/dashboard/2'); + store.setRunPhase(RunPhase.Error); + const result = runResult(store); + expect(result).toEqual({ + runPhase: RunPhase.Error, + outroData: { + kind: OutroKind.Error, + errorCode: 'PHW_INTERNAL_UNHANDLED', + message: 'boom', + }, + dashboardUrl: 'https://us.posthog.com/project/1/dashboard/2', + notebookUrl: null, + handoffText: null, + }); + expect(JSON.stringify(projectState(store, RUN))).not.toContain( + 'long body copy', + ); + }); + + it('redacts by key and by secret ref only', () => { + expect( + redactContext({ + a: 1, + token: 'x', + nested: { k: 'v' }, + ref: 'secret:abcdef0123456789', + }), + ).toEqual({ + a: 1, + token: '[redacted]', + nested: { k: 'v' }, + ref: '[secret-ref]', + }); + }); +}); diff --git a/src/store/control/actions.ts b/src/store/control/actions.ts new file mode 100644 index 000000000..6b1d88d12 --- /dev/null +++ b/src/store/control/actions.ts @@ -0,0 +1,231 @@ +/** + * The commits a controlling parent may make, keyed by flow key or interrupt. + * Generic entries call one store setter each and know no program; a program + * adds its own through `FlowStep.controlActions`, merged per flow here. + */ +import { McpOutcome, type AskAnswers } from '../session/wizard-session.js'; +import type { Flow } from '../state/flow.js'; +import { Interrupt } from '../state/interrupts.js'; +import { requireString } from './params.js'; +import type { ActionView, DriverAction } from './types.js'; + +export { BadParamError, MissingParamError } from './params.js'; + +/** Thrown when an action is not legal on the current screen. Maps to 400. */ +export class UnknownActionError extends Error { + constructor(action: string, screen: string) { + super( + `No action "${action}" on screen "${screen}". ` + + 'Read state.actions first.', + ); + this.name = 'UnknownActionError'; + } +} + +/** + * Screens with no commit: the runner or the agent advances them, or they are + * terminal. Listed so the exhaustiveness test tells "empty" from "forgotten". + */ +export const NO_ACTION_SCREENS: ReadonlySet = new Set([ + 'auth', + 'run', + 'ai-opt-in', + 'exit', + 'audit-run', + 'doctor-report', + Interrupt.ManagedSettings, + Interrupt.AuthError, + Interrupt.SessionTimeout, +]); + +const confirmSetup: DriverAction = { + id: 'confirm_setup', + description: 'Confirm the intro and continue (sets setupConfirmed).', + apply: (store) => store.completeSetup(), +}; + +const dismissOutro: DriverAction = { + id: 'dismiss_outro', + description: 'Dismiss the outro (sets outroDismissed).', + apply: (store) => store.setOutroDismissed(), +}; + +const setMcpOutcome = (description: string): DriverAction => ({ + id: 'set_mcp_outcome', + description, + params: { + outcome: '"installed" | "skipped"', + clients: 'string[] (optional)', + }, + apply: (store, params) => { + const raw = (params.outcome as string) ?? 'skipped'; + const outcome = + raw === 'installed' ? McpOutcome.Installed : McpOutcome.Skipped; + const clients = Array.isArray(params.clients) + ? (params.clients as string[]) + : []; + store.setMcpComplete(outcome, clients); + }, +}); + +export const GENERIC_ACTIONS: Readonly< + Record +> = { + 'health-check': [ + { + id: 'dismiss_outage', + description: 'Dismiss the blocking outage screen and continue.', + apply: (store) => store.dismissOutage(), + }, + ], + setup: [ + { + id: 'choose', + description: + 'Answer one setup question by committing a framework-context value. ' + + 'Read state.setupQuestions for the key and allowed values.', + params: { key: 'setup question key', value: 'chosen option value' }, + apply: (store, params) => { + const key = requireString('choose', params, 'key'); + const value = requireString('choose', params, 'value'); + store.setFrameworkContext(key, value); + }, + }, + ], + outro: [dismissOutro], + 'audit-outro': [dismissOutro], + 'source-maps-outro': [dismissOutro], + 'mint-failure': [ + { + id: 'continue_setup', + description: 'Continue to MCP and Slack after the skill is saved.', + apply: (store) => store.setMintHandoff('continue'), + }, + { + id: 'dismiss_outro', + description: 'Exit the wizard from the mint failure screen.', + apply: (store) => store.setMintHandoff('exit'), + }, + ], + mcp: [ + setMcpOutcome( + 'Complete the MCP step. outcome is installed or skipped; clients optional.', + ), + ], + 'mcp-add': [setMcpOutcome('Complete the standalone MCP-add flow.')], + 'mcp-remove': [setMcpOutcome('Complete the standalone MCP-remove flow.')], + 'mcp-suggested-prompts': [ + { + id: 'dismiss', + description: 'Dismiss the suggested-prompts step.', + apply: (store) => store.setMcpSuggestedPromptsDismissed(), + }, + ], + 'slack-connect': [ + { + id: 'dismiss_slack', + description: 'Skip or finish the Connect-Slack step.', + apply: (store) => store.setSlackStepDismissed(), + }, + { + id: 'set_slack_connected', + description: 'Mark Slack as connected (then dismiss to advance).', + params: { connected: 'boolean' }, + apply: (store, params) => + store.setSlackConnected(params.connected !== false), + }, + ], + 'keep-skills': [ + { + id: 'keep_skills', + description: + 'Decide whether to keep installed skills; completes the run.', + params: { kept: 'boolean (default true)' }, + apply: (store, params) => store.setSkillsComplete(params.kept !== false), + }, + ], + [Interrupt.WizardAsk]: [ + { + id: 'answer_question', + description: + 'Resolve the pending wizard_ask request with a complete answers ' + + 'map: { [questionId]: string | string[] }. See state.pendingQuestion.', + params: { answers: 'Record' }, + apply: (store, params) => + store.resolvePendingQuestion((params.answers ?? {}) as AskAnswers), + }, + { + id: 'cancel_question', + description: 'Cancel the pending wizard_ask request (sentinel answers).', + apply: (store) => store.cancelPendingQuestion(), + }, + ], + [Interrupt.TaskNotice]: [ + { + id: 'resolve_notice', + description: + 'Resolve the task-notice overlay a program shows before an optional ' + + 'step. keep=true runs the step, keep=false skips it. See state.taskNotice.', + params: { keep: 'boolean (default true)' }, + apply: (store, params) => store.resolveTaskNotice(params.keep !== false), + }, + ], + [Interrupt.SettingsOverride]: [ + { + id: 'backup_and_fix', + description: 'Back up and fix conflicting .claude/settings.json.', + apply: (store) => { + store.backupAndFixSettingsOverride(); + }, + }, + ], + [Interrupt.PortConflict]: [ + { + id: 'resolve_port_conflict', + description: + 'Dismiss the port-conflict overlay and retry the OAuth port loop.', + apply: (store) => store.resolvePortConflict(), + }, + ], + [Interrupt.ManualAuthCode]: [ + { + id: 'submit_auth_code', + description: 'Submit a manually-entered OAuth authorization code.', + params: { code: 'authorization code' }, + apply: (store, params) => + store.submitManualAuthCode( + requireString('submit_auth_code', params, 'code'), + ), + }, + { + id: 'dismiss_auth_code', + description: 'Dismiss the manual auth-code overlay without submitting.', + apply: (store) => store.dismissManualAuthCode(), + }, + ], +}; + +/** Every program intro shares one shape: confirm and continue. */ +function isIntro(screen: string): boolean { + return screen === 'intro' || screen.endsWith('-intro'); +} + +/** Actions legal on `screen` in `flow`: the flow's own first, then generic. */ +export function actionsFor(flow: Flow, screen: string): DriverAction[] { + const own = flow.steps + .filter((step) => step.screenId === screen) + .flatMap((step) => step.controlActions ?? []); + const generic = isIntro(screen) + ? [confirmSetup] + : GENERIC_ACTIONS[screen] ?? []; + const seen = new Set(own.map((a) => a.id)); + return [...own, ...generic.filter((a) => !seen.has(a.id))]; +} + +export function toActionView(action: DriverAction): ActionView { + return { + id: action.id, + description: action.description, + ...(action.params ? { params: action.params } : {}), + }; +} diff --git a/src/store/control/client.ts b/src/store/control/client.ts new file mode 100644 index 000000000..1652f71c5 --- /dev/null +++ b/src/store/control/client.ts @@ -0,0 +1,157 @@ +import * as http from 'node:http'; +import type { + ControlState, + DetectRequest, + RunRecord, + RunRequest, + RunStatus, +} from './types.js'; + +export class ControlClientError extends Error { + constructor(readonly status: number, message: string) { + super(message); + this.name = 'ControlClientError'; + } +} + +interface HealthResponse { + ok: true; + version: string; + surface: string; + pid: number; + program: string; +} + +/** The parent's side of the control API: one unix socket, JSON in and out. */ +export class ControlClient { + constructor(private readonly socketPath: string) {} + + health(): Promise { + return this.request('GET', '/health'); + } + + async state(): Promise { + return (await this.request<{ state: ControlState }>('GET', '/state')).state; + } + + /** Long poll: the first commit with `version > since`, else after `waitMs`. */ + async waitForChange(since: number, waitMs: number): Promise { + const path = `/state?wait=${waitMs}&since=${since}`; + return ( + await this.request<{ state: ControlState }>( + 'GET', + path, + undefined, + waitMs + 5000, + ) + ).state; + } + + async performAction( + id: string, + params: Record = {}, + ): Promise { + return ( + await this.request<{ state: ControlState }>( + 'POST', + `/actions/${encodeURIComponent(id)}`, + { params }, + ) + ).state; + } + + async setCredentials(): Promise { + return ( + await this.request<{ state: ControlState }>('POST', '/credentials', {}) + ).state; + } + + async armRun(): Promise<{ status: RunStatus; error: string | null }> { + return ( + await this.request<{ run: { status: RunStatus; error: string | null } }>( + 'POST', + '/run', + {}, + ) + ).run; + } + + async detect(req: DetectRequest = {}): Promise { + return (await this.request<{ state: ControlState }>('POST', '/detect', req)) + .state; + } + + async startRun(req: RunRequest): Promise { + return (await this.request<{ run: RunRecord }>('POST', '/runs', req)).run; + } + + async runs(): Promise { + return (await this.request<{ runs: RunRecord[] }>('GET', '/runs')).runs; + } + + async shutdown(): Promise { + await this.request<{ ok: true }>('POST', '/shutdown', {}); + } + + private request( + method: 'GET' | 'POST', + path: string, + body?: unknown, + timeoutMs = 30_000, + ): Promise { + return new Promise((resolve, reject) => { + const payload = body === undefined ? undefined : JSON.stringify(body); + const req = http.request( + { + socketPath: this.socketPath, + path, + method, + headers: payload + ? { + 'content-type': 'application/json', + 'content-length': Buffer.byteLength(payload), + } + : {}, + timeout: timeoutMs, + }, + (res) => { + const chunks: Buffer[] = []; + res.on('data', (c: Buffer) => chunks.push(c)); + res.on('end', () => { + const text = Buffer.concat(chunks).toString('utf8'); + let parsed: { ok?: boolean; error?: string } & Record< + string, + unknown + >; + try { + parsed = JSON.parse(text) as typeof parsed; + } catch { + return reject( + new ControlClientError( + res.statusCode ?? 0, + `bad response: ${text}`, + ), + ); + } + const status = res.statusCode ?? 0; + if (status >= 400 || parsed.ok === false) { + return reject( + new ControlClientError( + status, + parsed.error ?? `HTTP ${status}`, + ), + ); + } + resolve(parsed as T); + }); + }, + ); + req.on('timeout', () => + req.destroy(new Error('control request timed out')), + ); + req.on('error', reject); + if (payload) req.write(payload); + req.end(); + }); + } +} diff --git a/src/store/control/driver.ts b/src/store/control/driver.ts new file mode 100644 index 000000000..7c5481563 --- /dev/null +++ b/src/store/control/driver.ts @@ -0,0 +1,64 @@ +import type { WizardStore } from '../state/store.js'; +import { actionsFor, toActionView, UnknownActionError } from './actions.js'; +import { projectState } from './state.js'; +import type { ActionView, ControlState, RunStatus } from './types.js'; + +/** + * Read and act on one store. Reads the committed state; acts through the exact + * setter the screen's keyboard handler would call. In-progress keystroke state + * is React-local and invisible here by design. + */ +export class ControlDriver { + constructor( + private readonly store: WizardStore, + private readonly run: () => { status: RunStatus; error: string | null }, + ) {} + + readState(): ControlState { + return projectState(this.store, this.run()); + } + + listActions(): ActionView[] { + return actionsFor(this.store.flow, this.store.currentScreen).map( + toActionView, + ); + } + + /** Apply a named action on the current screen; 400-class errors throw. */ + performAction( + actionId: string, + params: Record = {}, + ): ControlState { + const screen = this.store.currentScreen; + const action = actionsFor(this.store.flow, screen).find( + (a) => a.id === actionId, + ); + if (!action) throw new UnknownActionError(actionId, screen); + action.apply(this.store, params); + return this.readState(); + } + + /** + * Resolve on the first commit with `version > since`, or after `timeoutMs` + * with whatever is current. Every commit bumps the version, including the + * agent's `getUI()` calls, so a parent can block instead of poll. + */ + waitForVersion(since: number, timeoutMs: number): Promise { + if (this.store.getVersion() > since) + return Promise.resolve(this.readState()); + return new Promise((resolve) => { + let settled = false; + const finish = (): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + unsub(); + resolve(this.readState()); + }; + const timer = setTimeout(finish, timeoutMs); + const unsub = this.store.subscribe(() => { + if (this.store.getVersion() > since) finish(); + }); + }); + } +} diff --git a/src/store/control/index.ts b/src/store/control/index.ts new file mode 100644 index 000000000..9ce1a3ad1 --- /dev/null +++ b/src/store/control/index.ts @@ -0,0 +1,19 @@ +/** + * The control API: an HTTP/1.1 server over a unix socket that reads and acts + * on one store. Import only dynamically, from the cli runners, so a published + * TUI never loads it. + */ +export { attachControlServer, MAX_BODY_BYTES } from './server.js'; +export { ControlClient, ControlClientError } from './client.js'; +export { ControlDriver } from './driver.js'; +export { RunLedger, RunInFlightError } from './runs.js'; +export { + actionsFor, + GENERIC_ACTIONS, + NO_ACTION_SCREENS, + MissingParamError, + BadParamError, + UnknownActionError, +} from './actions.js'; +export { projectState, redactContext, runResult } from './state.js'; +export { CONTROL_SERVER_MARKER } from './marker.js'; diff --git a/src/store/control/marker.ts b/src/store/control/marker.ts new file mode 100644 index 000000000..2bca8cc5d --- /dev/null +++ b/src/store/control/marker.ts @@ -0,0 +1,5 @@ +/** + * Appears in exactly one built chunk. The smoke test proves published TUI + * entry chunks never import that chunk and headless builds still carry it. + */ +export const CONTROL_SERVER_MARKER = 'wizard-control-server'; diff --git a/src/store/control/params.ts b/src/store/control/params.ts new file mode 100644 index 000000000..a8c133c24 --- /dev/null +++ b/src/store/control/params.ts @@ -0,0 +1,27 @@ +/** Thrown when an action lacks a required param. Maps to 400. */ +export class MissingParamError extends Error { + constructor(action: string, param: string) { + super(`Action "${action}" requires param "${param}".`); + this.name = 'MissingParamError'; + } +} + +/** Thrown when a param is present but unusable. Maps to 400. */ +export class BadParamError extends Error { + constructor(action: string, param: string, detail: string) { + super(`Action "${action}" param "${param}": ${detail}`); + this.name = 'BadParamError'; + } +} + +export function requireString( + action: string, + params: Record, + key: string, +): string { + const v = params[key]; + if (typeof v !== 'string' || v.length === 0) { + throw new MissingParamError(action, key); + } + return v; +} diff --git a/src/store/control/runs.ts b/src/store/control/runs.ts new file mode 100644 index 000000000..dee411e13 --- /dev/null +++ b/src/store/control/runs.ts @@ -0,0 +1,61 @@ +import { randomUUID } from 'node:crypto'; +import type { RunRecord, RunResult } from './types.js'; + +/** Thrown when a run is requested while one is in flight. Maps to 409. */ +export class RunInFlightError extends Error { + constructor(runId: string) { + super(`A run is already in flight: ${runId}`); + this.name = 'RunInFlightError'; + } +} + +/** Every independent run this process served, in start order. */ +export class RunLedger { + private readonly records: RunRecord[] = []; + + get active(): RunRecord | null { + return this.records.find((r) => r.status === 'running') ?? null; + } + + start(programId: string, installDir: string): RunRecord { + const running = this.active; + if (running) throw new RunInFlightError(running.runId); + const record: RunRecord = { + runId: randomUUID(), + programId, + installDir, + status: 'running', + error: null, + startedAt: new Date().toISOString(), + finishedAt: null, + result: null, + }; + this.records.push(record); + return record; + } + + finish(runId: string, result: RunResult): void { + const record = this.find(runId); + record.status = 'done'; + record.result = result; + record.finishedAt = new Date().toISOString(); + } + + fail(runId: string, error: string, result: RunResult | null): void { + const record = this.find(runId); + record.status = 'failed'; + record.error = error; + record.result = result; + record.finishedAt = new Date().toISOString(); + } + + list(): RunRecord[] { + return this.records.map((r) => ({ ...r })); + } + + private find(runId: string): RunRecord { + const record = this.records.find((r) => r.runId === runId); + if (!record) throw new Error(`unknown run ${runId}`); + return record; + } +} diff --git a/src/store/control/server.ts b/src/store/control/server.ts new file mode 100644 index 000000000..6912d57f3 --- /dev/null +++ b/src/store/control/server.ts @@ -0,0 +1,359 @@ +import * as fs from 'node:fs'; +import * as http from 'node:http'; +import * as net from 'node:net'; +import { PROGRAM_REGISTRY } from '../programs/program-registry.js'; +import { RunPhase } from '../session/wizard-session.js'; +import { logToFile } from '../shared/debug.js'; +import type { WizardStore } from '../state/store.js'; +import { + BadParamError, + MissingParamError, + UnknownActionError, +} from './actions.js'; +import { ControlDriver } from './driver.js'; +import { CONTROL_SERVER_MARKER } from './marker.js'; +import { RunInFlightError, RunLedger } from './runs.js'; +import { runResult } from './state.js'; +import type { + ControlHooks, + ControlState, + ControlSurface, + DetectRequest, + RunRequest, + RunStatus, +} from './types.js'; + +export const MAX_BODY_BYTES = 64 * 1024; +const PROBE_TIMEOUT_MS = 200; +/** Long polls cap here so a stuck parent never pins a connection forever. */ +const MAX_WAIT_MS = 10 * 60 * 1000; + +export interface ControlServerOptions { + socketPath: string; + surface: ControlSurface; + hooks: ControlHooks; + version: string; + program: string; +} + +export interface ControlServerHandle { + readonly socketPath: string; + readonly ledger: RunLedger; + close(): Promise; +} + +/** A hook the other surface owns. Maps to 501. */ +class SurfaceUnavailableError extends Error { + constructor(route: string, surface: ControlSurface) { + super(`${route} is not available on the ${surface} surface`); + this.name = 'SurfaceUnavailableError'; + } +} + +class HttpError extends Error { + constructor(readonly status: number, message: string) { + super(message); + this.name = 'HttpError'; + } +} + +function statusFor(err: unknown): number { + if (err instanceof HttpError) return err.status; + if ( + err instanceof UnknownActionError || + err instanceof MissingParamError || + err instanceof BadParamError + ) + return 400; + if (err instanceof RunInFlightError) return 409; + if (err instanceof SurfaceUnavailableError) return 501; + return 500; +} + +function requireProgram(programId: string): void { + if (!PROGRAM_REGISTRY.some((c) => c.id === programId)) + throw new HttpError(400, `unknown program "${programId}"`); +} + +/** Refuse a live socket; unlink a stale file. */ +async function claimSocketPath(socketPath: string): Promise { + if (!fs.existsSync(socketPath)) return; + const live = await new Promise((resolve) => { + const probe = net.connect(socketPath); + const done = (value: boolean): void => { + probe.destroy(); + resolve(value); + }; + probe.setTimeout(PROBE_TIMEOUT_MS, () => done(true)); + probe.once('connect', () => done(true)); + probe.once('error', () => done(false)); + }); + if (live) throw new Error(`control socket already served: ${socketPath}`); + fs.unlinkSync(socketPath); +} + +function readBody(req: http.IncomingMessage): Promise> { + return new Promise((resolve, reject) => { + const type = req.headers['content-type']; + if (type && !type.toLowerCase().startsWith('application/json')) { + reject(new HttpError(415, 'body must be application/json')); + req.resume(); + return; + } + const chunks: Buffer[] = []; + let size = 0; + req.on('data', (chunk: Buffer) => { + size += chunk.length; + if (size > MAX_BODY_BYTES) { + reject(new HttpError(413, `body over ${MAX_BODY_BYTES} bytes`)); + req.destroy(); + } else chunks.push(chunk); + }); + req.on('end', () => { + const text = Buffer.concat(chunks).toString('utf8').trim(); + if (!text) return resolve({}); + try { + const parsed: unknown = JSON.parse(text); + if ( + parsed === null || + typeof parsed !== 'object' || + Array.isArray(parsed) + ) + throw new Error('not an object'); + resolve(parsed as Record); + } catch { + reject(new HttpError(400, 'body is not a JSON object')); + } + }); + req.on('error', reject); + }); +} + +function send(res: http.ServerResponse, status: number, body: unknown): void { + const text = JSON.stringify(body); + res.writeHead(status, { + 'content-type': 'application/json', + 'content-length': Buffer.byteLength(text), + }); + res.end(text); +} + +/** + * Serve the control API for one store over a unix socket. HTTP/1.1, JSON in + * and out. The store never learns who is listening; the hooks do the work the + * composition root owns. + */ +export async function attachControlServer( + store: WizardStore, + options: ControlServerOptions, +): Promise { + const { socketPath, surface, hooks } = options; + const ledger = new RunLedger(); + let shuttingDown = false; + + const runStatus = (): { status: RunStatus; error: string | null } => { + const active = ledger.active; + if (active) return { status: 'running', error: null }; + const last = ledger.list().at(-1); + if (last) return { status: last.status, error: last.error }; + switch (store.session.runPhase) { + case RunPhase.Running: + return { status: 'running', error: null }; + case RunPhase.Completed: + return { status: 'done', error: null }; + case RunPhase.Error: + return { + status: 'failed', + error: store.session.outroData?.message ?? null, + }; + default: + return { + status: store.session.runRequested ? 'running' : 'idle', + error: null, + }; + } + }; + const driver = new ControlDriver(store, runStatus); + + const requireSurface = (route: string, wanted: ControlSurface): void => { + if (surface !== wanted) throw new SurfaceUnavailableError(route, surface); + }; + + const startRun = (body: Record) => { + const programId = body.programId; + if (typeof programId !== 'string' || !programId) + throw new HttpError(400, 'programId is required'); + requireProgram(programId); + const req: RunRequest = { + programId, + ...(typeof body.installDir === 'string' + ? { installDir: body.installDir } + : {}), + ...(body.frameworkContext && + typeof body.frameworkContext === 'object' && + !Array.isArray(body.frameworkContext) + ? { frameworkContext: body.frameworkContext as Record } + : {}), + ...(typeof body.skillId === 'string' ? { skillId: body.skillId } : {}), + }; + const record = ledger.start( + programId, + req.installDir ?? store.session.installDir, + ); + // Record the outcome first, then reset tasks and phase for the next run. + void hooks.startRun(req).then( + () => { + ledger.finish(record.runId, runResult(store)); + store.completeRunStep(record.runId); + }, + (err: unknown) => { + ledger.fail( + record.runId, + err instanceof Error ? err.message : String(err), + runResult(store), + ); + store.completeRunStep(record.runId); + }, + ); + return { ...record }; + }; + + const handle = async ( + req: http.IncomingMessage, + res: http.ServerResponse, + ): Promise => { + const url = new URL(req.url ?? '/', 'http://control'); + const method = req.method ?? 'GET'; + const route = `${method} ${url.pathname}`; + + if (route === 'GET /health') { + return send(res, 200, { + ok: true, + version: options.version, + surface, + pid: process.pid, + program: options.program, + }); + } + if (route === 'GET /state') { + const wait = Number(url.searchParams.get('wait') ?? ''); + const since = Number(url.searchParams.get('since') ?? ''); + let state: ControlState; + if (Number.isFinite(wait) && wait > 0) { + const from = Number.isFinite(since) ? since : store.getVersion(); + state = await driver.waitForVersion(from, Math.min(wait, MAX_WAIT_MS)); + } else state = driver.readState(); + return send(res, 200, { ok: true, state }); + } + if (route === 'GET /runs') + return send(res, 200, { ok: true, runs: ledger.list() }); + if (method !== 'POST') throw new HttpError(404, `no route ${route}`); + + const body = await readBody(req); + const actionMatch = /^\/actions\/([^/]+)$/.exec(url.pathname); + if (actionMatch) { + const params = + body.params && typeof body.params === 'object' + ? (body.params as Record) + : {}; + const state = driver.performAction( + decodeURIComponent(actionMatch[1]), + params, + ); + return send(res, 200, { ok: true, state }); + } + switch (url.pathname) { + case '/credentials': + await hooks.setCredentials(); + return send(res, 200, { ok: true, state: driver.readState() }); + case '/run': + requireSurface('POST /run', 'tui'); + hooks.armRun(); + return send(res, 200, { ok: true, run: runStatus() }); + case '/detect': { + requireSurface('POST /detect', 'headless'); + if (typeof body.programId === 'string') requireProgram(body.programId); + const detect: DetectRequest = { + ...(typeof body.programId === 'string' + ? { programId: body.programId } + : {}), + ...(typeof body.installDir === 'string' + ? { installDir: body.installDir } + : {}), + }; + await hooks.detect(detect); + return send(res, 200, { ok: true, state: driver.readState() }); + } + case '/runs': + requireSurface('POST /runs', 'headless'); + return send(res, 200, { ok: true, run: startRun(body) }); + case '/shutdown': + send(res, 200, { ok: true }); + if (!shuttingDown) { + shuttingDown = true; + res.once('finish', () => void hooks.shutdown()); + } + return; + default: + throw new HttpError(404, `no route ${route}`); + } + }; + + const server = http.createServer((req, res) => { + handle(req, res).catch((err: unknown) => { + const status = statusFor(err); + const message = err instanceof Error ? err.message : String(err); + if (status === 500) + logToFile(`[${CONTROL_SERVER_MARKER}] ${route(req)} failed:`, err); + if (!res.headersSent) send(res, status, { ok: false, error: message }); + else res.end(); + }); + }); + server.keepAliveTimeout = 1000; + + await claimSocketPath(socketPath); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(socketPath, () => { + server.off('error', reject); + resolve(); + }); + }); + fs.chmodSync(socketPath, 0o600); + logToFile( + `[${CONTROL_SERVER_MARKER}] listening on ${socketPath} (${surface})`, + ); + + let closed = false; + const unlink = (): void => { + try { + fs.unlinkSync(socketPath); + } catch { + /* already gone */ + } + }; + const onExit = (): void => { + if (!closed) unlink(); + }; + process.once('exit', onExit); + + return { + socketPath, + ledger, + close: () => + new Promise((resolve) => { + if (closed) return resolve(); + closed = true; + process.off('exit', onExit); + server.closeAllConnections(); + server.close(() => { + unlink(); + resolve(); + }); + }), + }; +} + +function route(req: http.IncomingMessage): string { + return `${req.method ?? 'GET'} ${req.url ?? '/'}`; +} diff --git a/src/store/control/state.ts b/src/store/control/state.ts new file mode 100644 index 000000000..0169590c8 --- /dev/null +++ b/src/store/control/state.ts @@ -0,0 +1,139 @@ +import type { WizardStore } from '../state/store.js'; +import { actionsFor, toActionView } from './actions.js'; +import type { + ControlState, + OutroView, + RunResult, + RunStatus, + SetupQuestionView, +} from './types.js'; + +const SECRET_REF = /^secret:[0-9a-f-]{16,}$/i; +const SECRET_KEY = /(key|token|secret|password|credential)/i; + +/** Values a driver may read; secret refs and secret-looking keys never leave. */ +export function redactContext( + ctx: Record, +): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(ctx)) { + if (SECRET_KEY.test(key)) out[key] = '[redacted]'; + else if (typeof value === 'string' && SECRET_REF.test(value)) + out[key] = '[secret-ref]'; + else out[key] = value; + } + return out; +} + +/** FNV-1a over the serialized context; a change signal, not a secret hash. */ +export function contextDigest(ctx: Record): string { + const s = JSON.stringify(ctx); + let h = 0x811c9dc5; + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i); + h = Math.imul(h, 0x01000193) >>> 0; + } + return h.toString(16).padStart(8, '0'); +} + +export function outroView(store: WizardStore): OutroView | null { + const data = store.session.outroData; + if (!data) return null; + return { + kind: data.kind, + ...(data.errorCode ? { errorCode: data.errorCode } : {}), + ...(data.message ? { message: data.message } : {}), + ...(data.docsUrl ? { docsUrl: data.docsUrl } : {}), + }; +} + +/** What a finished run leaves behind, for the ledger. */ +export function runResult(store: WizardStore): RunResult { + const s = store.session; + return { + runPhase: s.runPhase, + outroData: outroView(store), + dashboardUrl: s.dashboardUrl, + notebookUrl: s.notebookUrl, + handoffText: store.handoffText, + }; +} + +function setupQuestions(store: WizardStore): SetupQuestionView[] { + const s = store.session; + const questions = s.frameworkConfig?.metadata.setup?.questions ?? []; + return questions + .filter((q) => !(q.key in s.frameworkContext)) + .map((q) => ({ + key: q.key, + message: q.message, + options: q.options.map((o) => ({ + label: o.label, + value: o.value, + ...(o.hint ? { hint: o.hint } : {}), + })), + })); +} + +/** Project the committed store state for a controlling parent. */ +export function projectState( + store: WizardStore, + run: { status: RunStatus; error: string | null }, +): ControlState { + const s = store.session; + const screen = store.currentScreen; + return { + version: store.getVersion(), + currentScreen: screen, + hasOverlay: store.hasInterrupt, + runPhase: s.runPhase, + run, + session: { + installDir: s.installDir, + integration: s.integration, + detectedFrameworkLabel: s.detectedFrameworkLabel, + detectionComplete: s.detectionComplete, + setupConfirmed: s.setupConfirmed, + integrate: s.integrate, + hasCredentials: s.credentials !== null, + projectId: s.credentials?.projectId ?? null, + mcpComplete: s.mcpComplete, + slackStepDismissed: s.slackStepDismissed, + skillsComplete: s.skillsComplete, + outroDismissed: s.outroDismissed, + llmOptIn: s.llmOptIn, + discoveredFeatures: [...s.discoveredFeatures], + runRequested: s.runRequested, + completedRuns: [...s.completedRuns], + }, + tasks: store.tasks.map((t) => ({ + label: t.label, + status: t.status, + ...(t.activeForm ? { activeForm: t.activeForm } : {}), + })), + statusMessages: [...store.statusMessages], + eventPlan: store.eventPlan.map((e) => ({ + name: e.name, + description: e.description, + })), + pendingQuestion: s.pendingQuestion ?? null, + taskNotice: s.taskNotice + ? { + title: s.taskNotice.title, + items: [...(s.taskNotice.items ?? [])], + prompt: s.taskNotice.prompt, + } + : null, + setupQuestions: setupQuestions(store), + actions: actionsFor(store.flow, screen).map(toActionView), + dashboardUrl: s.dashboardUrl, + notebookUrl: s.notebookUrl, + handoffText: store.handoffText, + outroData: outroView(store), + frameworkContext: { + keys: Object.keys(s.frameworkContext).sort(), + digest: contextDigest(s.frameworkContext), + values: redactContext(s.frameworkContext), + }, + }; +} diff --git a/src/store/control/types.ts b/src/store/control/types.ts new file mode 100644 index 000000000..ab5ddf2bb --- /dev/null +++ b/src/store/control/types.ts @@ -0,0 +1,141 @@ +import type { + OutroKind, + PendingQuestion, + RunPhase, +} from '../session/wizard-session.js'; +import type { ErrorCode } from '../shared/errors/codes.js'; +import type { WizardStore } from '../state/store.js'; + +/** One commit a controlling parent may make on a screen. */ +export interface DriverAction { + /** Stable id named in `POST /actions/`. */ + id: string; + description: string; + /** Parameter name to a human/type hint. Absent means no params. */ + params?: Record; + /** Apply the commit through exactly one store setter or resolver. */ + apply: (store: WizardStore, params: Record) => void; +} + +/** An action as a caller sees it: no closure. */ +export interface ActionView { + id: string; + description: string; + params?: Record; +} + +export interface SetupQuestionView { + key: string; + message: string; + options: Array<{ label: string; value: string; hint?: string }>; +} + +export interface TaskNoticeView { + title: string; + items: string[]; + prompt: string; +} + +export type RunStatus = 'idle' | 'running' | 'done' | 'failed'; + +/** The outro reduced to what a parent needs; never the rendered body. */ +export interface OutroView { + kind: OutroKind; + errorCode?: ErrorCode; + message?: string; + docsUrl?: string; +} + +export interface RunResult { + runPhase: RunPhase; + outroData: OutroView | null; + dashboardUrl: string | null; + notebookUrl: string | null; + handoffText: string | null; +} + +export interface RunRecord { + runId: string; + programId: string; + installDir: string; + status: Exclude; + error: string | null; + startedAt: string; + finishedAt: string | null; + result: RunResult | null; +} + +/** + * The observable state. A whitelist of the session: credentials reduce to a + * boolean and framework context passes a redaction, so no secret reaches a + * controlling parent. + */ +export interface ControlState { + version: number; + currentScreen: string; + hasOverlay: boolean; + runPhase: RunPhase; + run: { status: RunStatus; error: string | null }; + session: { + installDir: string; + integration: string | null; + detectedFrameworkLabel: string | null; + detectionComplete: boolean; + setupConfirmed: boolean; + integrate: boolean | null; + hasCredentials: boolean; + projectId: number | null; + mcpComplete: boolean; + slackStepDismissed: boolean; + skillsComplete: boolean; + outroDismissed: boolean; + llmOptIn: boolean; + discoveredFeatures: string[]; + runRequested: boolean; + completedRuns: string[]; + }; + tasks: Array<{ label: string; status: string; activeForm?: string }>; + statusMessages: string[]; + eventPlan: Array<{ name: string; description: string }>; + pendingQuestion: PendingQuestion | null; + taskNotice: TaskNoticeView | null; + setupQuestions: SetupQuestionView[]; + actions: ActionView[]; + dashboardUrl: string | null; + notebookUrl: string | null; + handoffText: string | null; + outroData: OutroView | null; + frameworkContext: { + keys: string[]; + digest: string; + values: Record; + }; +} + +export interface DetectRequest { + programId?: string; + installDir?: string; +} + +export interface RunRequest { + programId: string; + installDir?: string; + frameworkContext?: Record; + skillId?: string; +} + +export type ControlSurface = 'tui' | 'headless'; + +/** What the composition root does on the parent's behalf; the store never runs agents. */ +export interface ControlHooks { + /** Resolve credentials host side and commit them, advancing `auth`. */ + setCredentials(): Promise; + /** TUI surface: release the runner's agent start. */ + armRun(): void; + /** Headless surface: run detection for a program, writing through setters. */ + detect(req: DetectRequest): Promise; + /** Headless surface: one independent agent run. Resolves when it ends. */ + startRun(req: RunRequest): Promise; + /** Flush and exit. Idempotent. */ + shutdown(): Promise; +} diff --git a/src/store/programs/error-tracking-upload-source-maps/steps.ts b/src/store/programs/error-tracking-upload-source-maps/steps.ts index 9c106e495..395b69919 100644 --- a/src/store/programs/error-tracking-upload-source-maps/steps.ts +++ b/src/store/programs/error-tracking-upload-source-maps/steps.ts @@ -10,7 +10,8 @@ import type { ProgramStep } from '../program-step.js'; import type { WizardSession } from '../../session/wizard-session.js'; import { RunPhase } from '../../session/wizard-session.js'; -import { SOURCE_MAPS_CONTEXT_KEYS } from './detect.js'; +import { SOURCE_MAPS_CONTEXT_KEYS, VARIANT_DISPLAY_NAME } from './detect.js'; +import { requireString } from '../../control/params.js'; function projectSelected(session: WizardSession): boolean { return ( @@ -42,6 +43,44 @@ export const ERROR_TRACKING_UPLOAD_SOURCE_MAPS_PROGRAM: ProgramStep[] = [ screenId: 'source-maps-detect', isComplete: projectSelected, gate: projectSelected, + controlActions: [ + { + id: 'pick_source_maps_project', + description: + 'Commit the project to wire source-map upload for, as the detect ' + + "screen's picker would. The candidate list lives in the screen's " + + 'agentic report, so the caller supplies the pick.', + params: { + variant: 'skill variant (e.g. "node", "nextjs")', + path: 'project path relative to the repo root ("." = root)', + }, + apply: (store, params) => { + const variant = requireString( + 'pick_source_maps_project', + params, + 'variant', + ); + const path = requireString( + 'pick_source_maps_project', + params, + 'path', + ); + store.setFrameworkContext( + SOURCE_MAPS_CONTEXT_KEYS.selectedVariant, + variant, + ); + store.setFrameworkContext( + SOURCE_MAPS_CONTEXT_KEYS.selectedDisplayName, + (VARIANT_DISPLAY_NAME as Record)[variant] ?? + variant, + ); + store.setFrameworkContext( + SOURCE_MAPS_CONTEXT_KEYS.selectedPath, + path, + ); + }, + }, + ], }, { id: 'run', diff --git a/src/store/programs/error-tracking/index.ts b/src/store/programs/error-tracking/index.ts index 8aa47422a..25b5a5a8b 100644 --- a/src/store/programs/error-tracking/index.ts +++ b/src/store/programs/error-tracking/index.ts @@ -1,4 +1,5 @@ import { Integration } from '../../shared/constants.js'; +import { pickIntegrationTargetAction } from '../shared/control-actions.js'; import { detectFramework } from '../../detection/index.js'; import { scopeInstallDirToProject } from '../../detection/project-scope.js'; import { FRAMEWORK_REGISTRY } from '../../registry.js'; @@ -8,6 +9,7 @@ import { ERROR_TRACKING_UNSUPPORTED, errorTrackingProjectDir, gatherErrorTrackingContext, + ERROR_TRACKING_PROJECT_PATH_KEY, } from './detect-agentic.js'; import type { ProgramConfig, ProgramStep } from '../program-step.js'; import type { WizardSession } from '../../session/wizard-session.js'; @@ -74,6 +76,9 @@ const PICK_PROJECT_STEP: ProgramStep = { label: 'Detecting projects', screenId: 'error-tracking-detect', isComplete: (session) => session.integration != null, + controlActions: [ + pickIntegrationTargetAction(ERROR_TRACKING_PROJECT_PATH_KEY), + ], }; const ERROR_TRACKING_STEPS: ProgramStep[] = AGENT_SKILL_STEPS.flatMap( diff --git a/src/store/programs/self-driving/steps.ts b/src/store/programs/self-driving/steps.ts index c30cd89bb..3a7b6f9ee 100644 --- a/src/store/programs/self-driving/steps.ts +++ b/src/store/programs/self-driving/steps.ts @@ -23,7 +23,11 @@ import { detectSelfDrivingPrerequisites, POSTHOG_PRESENT_KEY, SELF_DRIVING_INTEGRATE_PATH_KEY, + GITHUB_REQUIRED_MESSAGE, + GITHUB_REQUIRED_BODY, } from './detect.js'; +import { pickIntegrationTargetAction } from '../shared/control-actions.js'; +import { OutroKind } from '../../session/wizard-session.js'; import { prepSelfDrivingIntegration } from './detect-agentic.js'; /** True once detection found PostHog already present in the project. */ @@ -65,6 +69,16 @@ export const SELF_DRIVING_PROGRAM: ProgramStep[] = [ isComplete: (session) => postHogPresent(session) || session.integrate !== null, gate: (session) => postHogPresent(session) || session.integrate !== null, + controlActions: [ + { + id: 'set_integrate', + description: + 'Answer the self-driving integration check. integrate=true sets up ' + + 'the PostHog SDK first; false goes straight to Self-driving.', + params: { integrate: 'boolean (default false)' }, + apply: (store, params) => store.setIntegrate(params.integrate === true), + }, + ], }, HEALTH_CHECK_STEP, { @@ -88,6 +102,9 @@ export const SELF_DRIVING_PROGRAM: ProgramStep[] = [ // (integrate=false); without the latter the orchestrator's waitUntil hangs. isComplete: (session) => session.integration != null || session.integrate === false, + controlActions: [ + pickIntegrationTargetAction(SELF_DRIVING_INTEGRATE_PATH_KEY), + ], }, { // The integration's own run step, imported and composed here: it runs the @@ -111,6 +128,14 @@ export const SELF_DRIVING_PROGRAM: ProgramStep[] = [ screenId: 'self-driving-handoff', show: (session) => session.integrate === true, isComplete: (session) => session.selfDrivingHandoffConfirmed, + controlActions: [ + { + id: 'confirm_self_driving_handoff', + description: + 'Acknowledge the post-integration handoff and start the Self-driving run.', + apply: (store) => store.confirmSelfDrivingHandoff(), + }, + ], }, { // Hard gate before the agent starts: Self-driving cannot research findings @@ -125,6 +150,25 @@ export const SELF_DRIVING_PROGRAM: ProgramStep[] = [ session.githubConnected === true || session.githubDeclined, gate: (session) => session.githubConnected === true || session.githubDeclined, + controlActions: [ + { + id: 'set_github_connected', + description: 'Resolve the GitHub App connection check', + params: { connected: 'boolean' }, + apply: (store, params) => + store.setGithubConnected(params.connected !== false), + }, + { + id: 'decline_github', + description: 'Answer "I can\'t connect right now" and end the run', + apply: (store) => + store.declineGithub({ + kind: OutroKind.Cancel, + message: GITHUB_REQUIRED_MESSAGE, + body: GITHUB_REQUIRED_BODY, + }), + }, + ], }, { id: 'run', diff --git a/src/store/programs/shared/control-actions.ts b/src/store/programs/shared/control-actions.ts new file mode 100644 index 000000000..3e7693051 --- /dev/null +++ b/src/store/programs/shared/control-actions.ts @@ -0,0 +1,41 @@ +import type { DriverAction } from '../../control/types.js'; +import { BadParamError, requireString } from '../../control/params.js'; +import { FRAMEWORK_REGISTRY } from '../../registry.js'; +import type { Integration } from '../../shared/constants.js'; + +/** Commit the project a detect screen's picker would: its path and framework. */ +export function pickIntegrationTargetAction(pathKey: string): DriverAction { + return { + id: 'pick_integration_target', + description: + "Commit the project to set up, as the detect screen's picker would: " + + 'its path relative to the repo root and its framework.', + params: { + path: 'project path relative to the repo root ("." = root)', + integration: 'framework id, e.g. "nextjs"', + }, + apply: (store, params) => { + const path = requireString('pick_integration_target', params, 'path'); + const integration = requireString( + 'pick_integration_target', + params, + 'integration', + ); + const config = (FRAMEWORK_REGISTRY as Partial>)[ + integration + ]; + if (!config) { + throw new BadParamError( + 'pick_integration_target', + 'integration', + `unknown framework "${integration}"`, + ); + } + store.setFrameworkContext(pathKey, path); + store.setFrameworkConfig( + integration as Integration, + FRAMEWORK_REGISTRY[integration as Integration], + ); + }, + }; +} diff --git a/src/store/session/wizard-session.ts b/src/store/session/wizard-session.ts index 87361a6df..ee0f73400 100644 --- a/src/store/session/wizard-session.ts +++ b/src/store/session/wizard-session.ts @@ -240,6 +240,10 @@ export interface WizardSession { * see `NEVER_FROM_ENV`, and keep that list in step with this comment. */ e2eAsk: boolean; + /** `--control-socket`: the unix socket path a parent drives this run over, else null. */ + controlSocket: string | null; + /** A controlled run starts its agent only after the parent posts `/run`. */ + runRequested: boolean; /** * `--local-posthog` folds into `baseUrl`, and `--local-context-mill` is read * from `getLocalDev()` — neither belongs here. This one stays because @@ -475,8 +479,9 @@ export function buildSession(args: { installDir?: string; ci?: boolean; signup?: boolean; - /** Harness-only. Set by the e2e TUI host from `E2E_ASK`, never by a flag. */ + /** Keep the `wizard_ask` bridge wired in a `ci` session; `--e2e-ask` or the e2e host. */ e2eAsk?: boolean; + controlSocket?: string; localDev?: boolean; localMcp?: boolean; localContextMill?: boolean; @@ -504,6 +509,8 @@ export function buildSession(args: { ci: args.ci ?? false, signup: args.signup ?? false, e2eAsk: args.e2eAsk ?? false, + controlSocket: args.controlSocket ?? null, + runRequested: false, localMcp: local.localMcp, mcpFeatures: args.mcpFeatures, apiKey: args.apiKey, diff --git a/src/store/shared/environment.ts b/src/store/shared/environment.ts index 7ac24e30c..8827e46fb 100644 --- a/src/store/shared/environment.ts +++ b/src/store/shared/environment.ts @@ -30,7 +30,7 @@ export function isNonInteractiveEnvironment(): boolean { * so every question would stall for the bridge timeout instead of failing fast * with an actionable error. See `shouldDisableAsk`. */ -const NEVER_FROM_ENV = ['e2eAsk']; +const NEVER_FROM_ENV = ['e2eAsk', 'controlSocket', 'runRequested']; /** * Session args from the `POSTHOG_WIZARD_*` environment variables. diff --git a/src/store/state/__tests__/invariants.test.ts b/src/store/state/__tests__/invariants.test.ts index 226e9a849..ec5405874 100644 --- a/src/store/state/__tests__/invariants.test.ts +++ b/src/store/state/__tests__/invariants.test.ts @@ -396,6 +396,7 @@ const MUTATIONS: MutationCase[] = [ invoke: (s) => s.completeRunStep('integrate-run'), emits: 1, }, + { name: 'requestRun', invoke: (s) => s.requestRun(), emits: 1 }, { name: 'setOutroDismissed', invoke: (s) => s.setOutroDismissed(), emits: 1 }, { name: 'setOutroData', diff --git a/src/store/state/flow.ts b/src/store/state/flow.ts index c68518fcb..1b440a65e 100644 --- a/src/store/state/flow.ts +++ b/src/store/state/flow.ts @@ -12,6 +12,7 @@ import type { WizardReadinessResult } from '../health-checks/readiness.js'; import type { Integration } from '../shared/constants.js'; import type { FrameworkConfig } from '../framework-config.js'; import type { ProgramId } from '../programs/program-registry.js'; +import type { DriverAction } from '../control/types.js'; /** Context passed to onInit callbacks, before the real session is assigned. */ export interface StoreInitContext { @@ -73,6 +74,11 @@ export interface FlowStep { * steps shared across programs. Attribution only. Matched by `screenId`. */ reportsAsProgramId?: ProgramId; + /** + * Commits a controlling parent may make on this step's screen, beyond the + * generic ones every flow shares. Each calls one store setter. + */ + controlActions?: readonly DriverAction[]; } export interface Flow { diff --git a/src/store/state/store-api.ts b/src/store/state/store-api.ts index bd18fae34..c0012df58 100644 --- a/src/store/state/store-api.ts +++ b/src/store/state/store-api.ts @@ -21,6 +21,7 @@ export const STORE_BOUNDARY_MEMBERS = [ 'declineSharing', 'dismissManualAuthCode', 'dismissOutage', + 'emitChange', 'enableFeature', 'eventPlan', 'getGate', @@ -29,6 +30,7 @@ export const STORE_BOUNDARY_MEMBERS = [ 'hasInterrupt', 'interruptDepth', 'pushStatus', + 'requestRun', 'resolvePendingQuestion', 'resolvePortConflict', 'resolveTaskNotice', diff --git a/src/store/state/store.ts b/src/store/state/store.ts index 116d328c6..b5bf8c619 100644 --- a/src/store/state/store.ts +++ b/src/store/state/store.ts @@ -846,6 +846,12 @@ export class WizardStore { this.setRunPhase(RunPhase.Idle); } + /** A controlled run's parent released the agent; the runner waits on this. */ + requestRun(): void { + this.$session.setKey('runRequested', true); + this.emitChange(); + } + setOutroDismissed(dismissed = true): void { this.$session.setKey('outroDismissed', dismissed); this.emitChange(); diff --git a/src/store/types.ts b/src/store/types.ts index d8fab130e..13ea03926 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -94,3 +94,22 @@ export type { } from './ui/wizard-ui.js'; export type { WizardSpellbook } from './wizard-spellbook.js'; export type { StoreBoundaryMember, WizardStoreApi } from './state/store-api.js'; +export type { + ActionView, + ControlHooks, + ControlState, + ControlSurface, + DetectRequest, + DriverAction, + OutroView, + RunRecord, + RunRequest, + RunResult, + RunStatus, + SetupQuestionView, + TaskNoticeView, +} from './control/types.js'; +export type { + ControlServerHandle, + ControlServerOptions, +} from './control/server.js'; diff --git a/src/tui/start-tui.ts b/src/tui/start-tui.ts index fa135fd3c..065eff20a 100644 --- a/src/tui/start-tui.ts +++ b/src/tui/start-tui.ts @@ -18,6 +18,9 @@ import { getExitLine } from './exit-line.js'; export { releaseTerminal }; +/** Appears in the TUI chunk; the smoke test proves it never imports the control server. */ +export const TUI_ENTRY_MARKER = 'wizard-tui-entry'; + export interface TuiHandle { unmount: () => void; store: WizardStore; @@ -29,6 +32,7 @@ export function startTUI( program: ProgramId = Program.PostHogIntegration, ): TuiHandle { enterDarkTerminal(); + logToFile(`[${TUI_ENTRY_MARKER}] ${program}`); const store = new WizardStore(flowFor(program).flow); store.version = version; From 70932fd552cdcea3022ca07ec7bc243f7d696da6 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Sat, 19 Sep 2026 12:05:17 -0400 Subject: [PATCH 2/7] feat(control): give every independent run its own stream and state Each POST /runs clears the previous run's outcome, publishes to a task stream session of its own, and records its task list in the ledger, so nothing from one run leaks into the next while credentials and framework context persist. The headless runner attaches no process-level stream when a socket is served. scripts/controlled-headless-smoke.no-jest.ts drives the surface end to end. Generated-By: PostHog Desktop Task-Id: d14e92bb-6ee1-49b5-8502-39cb80079589 --- scripts/controlled-headless-smoke.no-jest.ts | 151 +++++++++++++++++++ src/cli/__tests__/control-hooks.test.ts | 69 ++++++++- src/cli/control-hooks.ts | 34 ++++- src/cli/runners/run-non-interactive.ts | 43 ++++-- src/store/control/__tests__/state.test.ts | 1 + src/store/control/server.ts | 14 +- src/store/control/state.ts | 1 + src/store/control/types.ts | 1 + src/store/state/__tests__/invariants.test.ts | 1 + src/store/state/store-api.ts | 1 + src/store/state/store.ts | 18 +++ 11 files changed, 307 insertions(+), 27 deletions(-) create mode 100644 scripts/controlled-headless-smoke.no-jest.ts diff --git a/scripts/controlled-headless-smoke.no-jest.ts b/scripts/controlled-headless-smoke.no-jest.ts new file mode 100644 index 000000000..6227f4a94 --- /dev/null +++ b/scripts/controlled-headless-smoke.no-jest.ts @@ -0,0 +1,151 @@ +/** + * Drive a published-style headless run over its control socket: detect, then + * one independent run per program named on the command line, then shutdown. + * Prints every request and a redacted view of every response. + * + * POSTHOG_WIZARD_API_KEY=… npx tsx scripts/controlled-headless-smoke.no-jest.ts \ + * --app /tmp/app --project-id 228144 [--region us] [--bin dist/bin.js] posthog-integration [metrics …] + */ +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { HEADLESS_FLAG } from '@env'; +import { ControlClient } from '@store/control'; +import type { ControlState } from '@store/types'; + +async function waitForSocket(p: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (fs.existsSync(p)) return; + await new Promise((r) => setTimeout(r, 150)); + } + throw new Error(`the wizard did not open ${p} within ${timeoutMs}ms`); +} + +const args = process.argv.slice(2); +const opt = (name: string, fallback?: string): string | undefined => { + const i = args.indexOf(`--${name}`); + return i >= 0 ? args[i + 1] : fallback; +}; +const programs = args.filter( + (a, i) => !a.startsWith('--') && (i === 0 || !args[i - 1].startsWith('--')), +); +const app = opt('app'); +const projectId = opt('project-id'); +const region = opt('region', 'us')!; +const bin = opt('bin', 'bin.ts')!; +if (!app || !projectId || programs.length === 0) { + process.stderr.write( + 'usage: --app --project-id [--region us|eu] [--bin dist/bin.js] [program…]\n', + ); + process.exit(2); +} +if (!process.env.POSTHOG_WIZARD_API_KEY) { + process.stderr.write( + 'POSTHOG_WIZARD_API_KEY must be set in the environment\n', + ); + process.exit(2); +} + +const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wizard-ctl-')); +const socketPath = path.join(dir, 'w.sock'); +const cmd = bin.endsWith('.ts') + ? path.join(process.cwd(), 'node_modules/.bin/tsx') + : 'node'; +const argv = [ + bin, + `--${HEADLESS_FLAG}`, + '--control-socket', + socketPath, + '--project-id', + projectId, + '--region', + region, + '--install-dir', + app, +]; +const log = (line: string) => process.stdout.write(`${line}\n`); +log( + `$ ${cmd === 'node' ? 'node' : 'npx tsx'} ${argv.join( + ' ', + )} # POSTHOG_WIZARD_API_KEY in env`, +); +const env = { ...process.env }; +for (const k of Object.keys(env)) + if (/^(CLAUDE|ANTHROPIC|AI_AGENT)/.test(k)) delete env[k]; +const child = spawn(cmd, argv, { + cwd: process.cwd(), + env, + stdio: ['ignore', 'pipe', 'pipe'], +}); +const wizardOut: string[] = []; +child.stdout.on('data', (d: Buffer) => wizardOut.push(d.toString())); +child.stderr.on('data', (d: Buffer) => wizardOut.push(d.toString())); +const exit = new Promise((resolve) => + child.once('exit', (code) => resolve(code)), +); + +const brief = (s: ControlState) => ({ + version: s.version, + screen: s.currentScreen, + runPhase: s.runPhase, + run: s.run, + integration: s.session.integration, + detectionComplete: s.session.detectionComplete, + hasCredentials: s.session.hasCredentials, + tasks: s.tasks.map((t) => `${t.status}:${t.label}`), + dashboardUrl: s.dashboardUrl, + notebookUrl: s.notebookUrl, + outro: s.outroData, +}); + +async function main(): Promise { + await waitForSocket(socketPath, 120_000); + const client = new ControlClient(socketPath); + log(`GET /health -> ${JSON.stringify(await client.health())}`); + log(`GET /state -> ${JSON.stringify(brief(await client.state()))}`); + log(`POST /detect {} -> ${JSON.stringify(brief(await client.detect({})))}`); + for (const programId of programs) { + const record = await client.startRun({ programId }); + log(`POST /runs {"programId":"${programId}"} -> ${JSON.stringify(record)}`); + let state = await client.state(); + while (state.run.status === 'running') { + state = await client.waitForChange(state.version, 60_000); + const last = state.tasks + .filter((t) => t.status !== 'pending') + .slice(-1)[0]; + log( + ` GET /state?wait=60000&since=${state.version} -> screen=${ + state.currentScreen + } runPhase=${state.runPhase} run=${state.run.status} tasks=${ + state.tasks.filter((t) => t.status === 'completed').length + }/${state.tasks.length}${ + last ? ` last=${last.status}:${last.label}` : '' + }`, + ); + } + log(` final -> ${JSON.stringify(brief(state))}`); + } + log(`GET /runs -> ${JSON.stringify(await client.runs(), null, 2)}`); + log( + `POST /shutdown -> ${JSON.stringify( + await client.shutdown().then(() => ({ ok: true })), + )}`, + ); + const code = await exit; + log(`wizard exit code: ${code}`); + log(`socket removed: ${!fs.existsSync(socketPath)}`); + const console = wizardOut + .join('') + .replace(/phx_[A-Za-z0-9_]+/g, 'phx_') + .trim(); + if (console) log(`wizard console:\n${console}`); + process.exit(code === 0 ? 0 : 1); +} + +main().catch((e: unknown) => { + process.stderr.write(`smoke failed: ${(e as Error)?.stack ?? String(e)}\n`); + child.kill('SIGKILL'); + process.exit(1); +}); diff --git a/src/cli/__tests__/control-hooks.test.ts b/src/cli/__tests__/control-hooks.test.ts index 3072da6da..23a8f89e3 100644 --- a/src/cli/__tests__/control-hooks.test.ts +++ b/src/cli/__tests__/control-hooks.test.ts @@ -17,7 +17,7 @@ vi.mock('@store/shared/analytics', () => ({ sessionProperties: () => ({}), })); -import { buildSession, StoreUI, setUI } from '@store'; +import { buildSession, OutroKind, RunPhase, StoreUI, setUI } from '@store'; import { Program } from '@store/programs'; import { createControlHooks } from '../control-hooks.js'; import { fakeRunAgent, fakeStartTUI } from '../testing/fake-surfaces.js'; @@ -43,13 +43,28 @@ function setup(program = Program.PostHogIntegration) { store.setFrameworkContext('shared', 'live'); const agent = fakeRunAgent(); const shutdown = vi.fn(() => Promise.resolve()); + const streams: Array<{ + programId: string; + attach: ReturnType; + shutdown: ReturnType; + }> = []; + const runStream = vi.fn((config: { id: string }) => { + const stream = { + programId: config.id, + attach: vi.fn(), + shutdown: vi.fn(() => Promise.resolve()), + }; + streams.push(stream); + return stream; + }); const hooks = createControlHooks({ store, programId: program, runAgent: agent.runAgent, + runStream, shutdown, }); - return { store, hooks, agent, shutdown, dir }; + return { store, hooks, agent, shutdown, streams, dir }; } describe('control hooks', () => { @@ -137,3 +152,53 @@ describe('control hooks', () => { expect(shutdown).toHaveBeenCalledTimes(1); }); }); + +describe('independent runs', () => { + it('each run gets its own stream and a clean run state', async () => { + const { store, hooks, streams } = setup(); + store.setDashboardUrl('https://us.posthog.com/project/1/dashboard/9'); + store.setTasks([{ label: 'stale', status: 'completed' } as never]); + store.setRunPhase(RunPhase.Completed); + await hooks.startRun({ programId: Program.Metrics }); + await hooks.startRun({ programId: Program.Audit }); + expect(streams.map((s) => s.programId)).toEqual([ + Program.Metrics, + Program.Audit, + ]); + for (const stream of streams) { + expect(stream.attach).toHaveBeenCalledTimes(1); + expect(stream.shutdown).toHaveBeenCalledWith(2000); + } + expect(store.session.dashboardUrl).toBeNull(); + expect(store.tasks).toEqual([]); + expect(store.session.runPhase).toBe(RunPhase.Idle); + }); + + it('a run that throws leaves an error outro and still closes its stream', async () => { + const { store, streams } = setup(); + const failing = createControlHooks({ + store, + programId: Program.PostHogIntegration, + runAgent: () => Promise.reject(new Error('gateway refused')), + runStream: () => { + const stream = { + programId: 'x', + attach: vi.fn(), + shutdown: vi.fn(() => Promise.resolve()), + }; + streams.push(stream); + return stream; + }, + shutdown: () => Promise.resolve(), + }); + await expect( + failing.startRun({ programId: Program.PostHogIntegration }), + ).rejects.toThrow('gateway refused'); + expect(store.session.runPhase).toBe(RunPhase.Error); + expect(store.session.outroData).toEqual({ + kind: OutroKind.Error, + message: 'gateway refused', + }); + expect(streams[0].shutdown).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/cli/control-hooks.ts b/src/cli/control-hooks.ts index fde2347bf..6442b1ff5 100644 --- a/src/cli/control-hooks.ts +++ b/src/cli/control-hooks.ts @@ -1,20 +1,30 @@ import * as path from 'node:path'; import type { RunAgent } from '@agent/types'; -import { getOrAskForProjectData, logToFile } from '@store'; +import { getOrAskForProjectData, logToFile, OutroKind, RunPhase } from '@store'; import { flowFor, getProgramConfig, runConfigFor } from '@store/programs'; import type { ControlHooks, DetectRequest, + ProgramConfig, ProgramId, RunRequest, + WizardSession, WizardStore, } from '@store/types'; +/** The task stream one independent run publishes to; a new session per run. */ +export interface RunStream { + attach(): void; + shutdown(timeoutMs: number): Promise; +} + export interface ControlHookDeps { store: WizardStore; /** The program this process launched with. */ programId: ProgramId; runAgent: RunAgent; + /** Builds the stream a run publishes to. Absent means the run publishes nothing. */ + runStream?: (config: ProgramConfig, session: WizardSession) => RunStream; /** Flush and exit; the runner owns the exact steps. */ shutdown: () => Promise; } @@ -97,7 +107,27 @@ export function createControlHooks(deps: ControlHookDeps): ControlHooks { programLabel: config.id, }; logToFile(`[control] run ${config.id} in ${runSession.installDir}`); - await deps.runAgent(runConfigFor(config), runSession, { composed: true }); + // Each run is its own session: a clean run state and its own stream, as + // a fresh CLI invocation would have. Credentials and context persist. + store.resetRunState(); + const stream = deps.runStream?.(config, runSession); + stream?.attach(); + try { + await deps.runAgent(runConfigFor(config), runSession, { + composed: true, + }); + } catch (err) { + if (store.session.runPhase !== RunPhase.Error) { + store.setOutroData({ + kind: OutroKind.Error, + message: err instanceof Error ? err.message : String(err), + }); + store.setRunPhase(RunPhase.Error); + } + throw err; + } finally { + await stream?.shutdown(2000); + } }, shutdown: deps.shutdown, diff --git a/src/cli/runners/run-non-interactive.ts b/src/cli/runners/run-non-interactive.ts index c165a5e71..437d3c5ad 100644 --- a/src/cli/runners/run-non-interactive.ts +++ b/src/cli/runners/run-non-interactive.ts @@ -16,6 +16,7 @@ import type { Sequence, CloudRegion, ProgramConfig, + WizardSession, WizardStore, TaskStreamPush, OutroData, @@ -182,6 +183,9 @@ export function runNonInteractive( // dumps locally and pushes nothing. Telemetry consent gates the push only. let store: WizardStore | null = null; let taskStream: TaskStreamPush | null = null; + let runStream: + | ((config: ProgramConfig, runSession: WizardSession) => TaskStreamPush) + | null = null; { const { WizardStore } = await import('@store'); const { HeadlessUI } = await import('@tui/console'); @@ -216,20 +220,30 @@ export function runNonInteractive( } else { setUI(new HeadlessUI(headlessStore)); } - taskStream = new TaskStreamPush({ - store: headlessStore, - programId: config.streamWorkflowId ?? config.id, - destinations, - eventPlanPath: config.eventPlanFile - ? join(session.installDir, config.eventPlanFile) - : undefined, - auditChecks: config.auditLedgerFile - ? () => getAuditChecks(headlessStore.session) - : undefined, - enabled: destinations.length > 0, - }); - taskStream.attach(); - if (!options.controlSocket) headlessStore.setRunPhase(RunPhase.Running); + const streamFor = ( + runConfig: ProgramConfig, + runSession: WizardSession, + ): TaskStreamPush => + new TaskStreamPush({ + store: headlessStore, + programId: runConfig.streamWorkflowId ?? runConfig.id, + destinations, + eventPlanPath: runConfig.eventPlanFile + ? join(runSession.installDir, runConfig.eventPlanFile) + : undefined, + auditChecks: runConfig.auditLedgerFile + ? () => getAuditChecks(headlessStore.session) + : undefined, + enabled: destinations.length > 0, + }); + if (options.controlSocket) { + // Every POST /runs is one independent run with its own stream session. + runStream = streamFor; + } else { + taskStream = streamFor(config, session); + taskStream.attach(); + headlessStore.setRunPhase(RunPhase.Running); + } if (fileDestination) { logToFile(`[task-stream] ${mode} dump: ${fileDestination.path}`); } @@ -276,6 +290,7 @@ export function runNonInteractive( store: controlledStore, programId: config.id, runAgent, + runStream: runStream ?? undefined, shutdown: () => { release?.(); return Promise.resolve(); diff --git a/src/store/control/__tests__/state.test.ts b/src/store/control/__tests__/state.test.ts index 85d752ab6..eaea0f6ce 100644 --- a/src/store/control/__tests__/state.test.ts +++ b/src/store/control/__tests__/state.test.ts @@ -107,6 +107,7 @@ describe('projectState', () => { const result = runResult(store); expect(result).toEqual({ runPhase: RunPhase.Error, + tasks: [], outroData: { kind: OutroKind.Error, errorCode: 'PHW_INTERNAL_UNHANDLED', diff --git a/src/store/control/server.ts b/src/store/control/server.ts index 6912d57f3..91325de15 100644 --- a/src/store/control/server.ts +++ b/src/store/control/server.ts @@ -200,20 +200,16 @@ export async function attachControlServer( programId, req.installDir ?? store.session.installDir, ); - // Record the outcome first, then reset tasks and phase for the next run. + // The state keeps this run's outcome until the next run starts; the hook + // resets it then, so a poller reading after completion sees the result. void hooks.startRun(req).then( - () => { - ledger.finish(record.runId, runResult(store)); - store.completeRunStep(record.runId); - }, - (err: unknown) => { + () => ledger.finish(record.runId, runResult(store)), + (err: unknown) => ledger.fail( record.runId, err instanceof Error ? err.message : String(err), runResult(store), - ); - store.completeRunStep(record.runId); - }, + ), ); return { ...record }; }; diff --git a/src/store/control/state.ts b/src/store/control/state.ts index 0169590c8..f8ef02243 100644 --- a/src/store/control/state.ts +++ b/src/store/control/state.ts @@ -52,6 +52,7 @@ export function runResult(store: WizardStore): RunResult { const s = store.session; return { runPhase: s.runPhase, + tasks: store.tasks.map((t) => ({ label: t.label, status: t.status })), outroData: outroView(store), dashboardUrl: s.dashboardUrl, notebookUrl: s.notebookUrl, diff --git a/src/store/control/types.ts b/src/store/control/types.ts index ab5ddf2bb..13bc270de 100644 --- a/src/store/control/types.ts +++ b/src/store/control/types.ts @@ -48,6 +48,7 @@ export interface OutroView { export interface RunResult { runPhase: RunPhase; + tasks: Array<{ label: string; status: string }>; outroData: OutroView | null; dashboardUrl: string | null; notebookUrl: string | null; diff --git a/src/store/state/__tests__/invariants.test.ts b/src/store/state/__tests__/invariants.test.ts index ec5405874..a003f0cc5 100644 --- a/src/store/state/__tests__/invariants.test.ts +++ b/src/store/state/__tests__/invariants.test.ts @@ -397,6 +397,7 @@ const MUTATIONS: MutationCase[] = [ emits: 1, }, { name: 'requestRun', invoke: (s) => s.requestRun(), emits: 1 }, + { name: 'resetRunState', invoke: (s) => s.resetRunState(), emits: 1 }, { name: 'setOutroDismissed', invoke: (s) => s.setOutroDismissed(), emits: 1 }, { name: 'setOutroData', diff --git a/src/store/state/store-api.ts b/src/store/state/store-api.ts index c0012df58..d5f9fe63a 100644 --- a/src/store/state/store-api.ts +++ b/src/store/state/store-api.ts @@ -31,6 +31,7 @@ export const STORE_BOUNDARY_MEMBERS = [ 'interruptDepth', 'pushStatus', 'requestRun', + 'resetRunState', 'resolvePendingQuestion', 'resolvePortConflict', 'resolveTaskNotice', diff --git a/src/store/state/store.ts b/src/store/state/store.ts index b5bf8c619..a8f316d48 100644 --- a/src/store/state/store.ts +++ b/src/store/state/store.ts @@ -846,6 +846,24 @@ export class WizardStore { this.setRunPhase(RunPhase.Idle); } + /** Clear what one agent run leaves behind, so the next independent run starts clean. */ + resetRunState(): void { + this.cancelPendingQuestion(); + if (this.session.taskNotice) this.resolveTaskNotice(false); + this.$tasks.set([]); + this.$statusMessages.set([]); + this.$eventPlan.set([]); + this.$handoffText.set(null); + this.$tokenUsage.set(EMPTY_TOKEN_USAGE); + this.$currentStage.set(null); + this.$session.setKey('runPhase', RunPhase.Idle); + this.$session.setKey('outroData', null); + this.$session.setKey('outroDismissed', false); + this.$session.setKey('dashboardUrl', null); + this.$session.setKey('notebookUrl', null); + this.emitChange(); + } + /** A controlled run's parent released the agent; the runner waits on this. */ requestRun(): void { this.$session.setKey('runRequested', true); From 425106df162180fdf3df6c23c040e51a77ef952e Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Sat, 19 Sep 2026 12:09:01 -0400 Subject: [PATCH 3/7] feat(control): let a headless run use an issued gateway bearer A headless run with WIZARD_CI_GATEWAY_TOKEN_FILE in its environment configures the gateway from that file, as --ci does, instead of minting a token. Generated-By: PostHog Desktop Task-Id: d14e92bb-6ee1-49b5-8502-39cb80079589 --- src/cli/README.md | 6 ++++++ src/cli/runners/run-non-interactive.ts | 5 ++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/cli/README.md b/src/cli/README.md index 821d0444d..69a9c4028 100644 --- a/src/cli/README.md +++ b/src/cli/README.md @@ -16,6 +16,12 @@ surfaces' public entries. Holds no domain logic. `run-non-interactive.ts` (headless, every build) and in `run-wizard.ts` (TUI, dev builds only). Published TUI runs refuse the flag unless the headless flag is present. +- Every `POST /runs` is one independent run: the hook clears the previous run's + state and gives the run its own task stream session; credentials and framework + context persist. A headless run with `WIZARD_CI_GATEWAY_TOKEN_FILE` in its + environment uses that bearer and never mints. + `scripts/controlled-headless-smoke.no-jest.ts` drives the surface end to end + and prints every request and response. ## May import diff --git a/src/cli/runners/run-non-interactive.ts b/src/cli/runners/run-non-interactive.ts index 437d3c5ad..ca3fb574a 100644 --- a/src/cli/runners/run-non-interactive.ts +++ b/src/cli/runners/run-non-interactive.ts @@ -24,6 +24,7 @@ import type { } from '@store/types'; import { LoggingUI } from '@tui/console'; import { runConfigFor, getAuditChecks, flowFor } from '@store/programs'; +import { runtimeEnv } from '@env'; import { resolveNoTelemetry } from './resolve-no-telemetry.js'; import { createControlHooks } from '../control-hooks.js'; import { join } from 'node:path'; @@ -262,7 +263,9 @@ export function runNonInteractive( }; try { - if (mode === 'ci') { + // An issued gateway bearer in the environment replaces the mint, for + // `--ci` and for a headless run driven by a test harness alike. + if (mode === 'ci' || runtimeEnv('WIZARD_CI_GATEWAY_TOKEN_FILE')) { const { configureGatewayFromCIEnvironment } = await import('@agent'); configureGatewayFromCIEnvironment( Number(session.projectId), From b2394b3e6a6fcf633ad422cf6106d112276d2780 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Sat, 19 Sep 2026 12:16:48 -0400 Subject: [PATCH 4/7] chore(control): settle runs, key the chunk manifest by module group The control hooks mark a run that ended while running as completed and mark detection complete after a program's ciPreRun, so the ledger, the stream, and the state agree with the headless runner. The chunk manifest is keyed by source-module group instead of chunk file name, because rolldown's chunk names differ by platform; CI's fixture diff now compares content. The development skill documents the surfaces and how to drive the control API. Generated-By: PostHog Desktop Task-Id: d14e92bb-6ee1-49b5-8502-39cb80079589 --- .../references/ARCHITECTURE.md | 47 ++ scripts/README.md | 33 +- scripts/__fixtures__/chunk-manifest.ci.json | 521 ++++++++---------- scripts/__fixtures__/chunk-manifest.prod.json | 509 ++++++++--------- scripts/chunk-manifest.no-jest.ts | 43 +- src/cli/__tests__/control-hooks.test.ts | 17 + src/cli/control-hooks.ts | 7 +- 7 files changed, 592 insertions(+), 585 deletions(-) diff --git a/.claude/skills/wizard-development/references/ARCHITECTURE.md b/.claude/skills/wizard-development/references/ARCHITECTURE.md index 98c560a44..f13384acc 100644 --- a/.claude/skills/wizard-development/references/ARCHITECTURE.md +++ b/.claude/skills/wizard-development/references/ARCHITECTURE.md @@ -170,3 +170,50 @@ tool names or discovery. Context-mill supplies skills and flow/task prompts. instrumentation. The linear sequence creates the benchmark pipeline; there is no pipeline construction in the compatibility `agent-runner.ts`. Inspect the actual consumer before extending instrumentation to another sequence or harness. + +## Surfaces and the control API + +The tree is three surfaces plus a composition root, each with a `README.md` that +lists what it owns and may import: + +| Surface | Owns | Imports | +| ----------- | ------------------------------------------------------------------------------- | --------------------------------------------------- | +| `src/store` | state, session, `WizardUI`, programs as data, tool behavior, the control API | `@env` | +| `src/agent` | one independent agent run: switchboard, sequences, harnesses, gateway | `@env`, `@store`, `@store/types`, `@store/programs` | +| `src/tui` | Ink screens, `UiStore`, program presentation, Ink-free console renderers | `@env`, `@store`, `@store/types`, `@store/programs` | +| `src/cli` | argv, command tree, runners that sequence runs and pass context, `ControlHooks` | every surface, through its public entries only | + +Cross-surface imports go through `@store`, `@store/types`, `@store/programs`, +`@agent`, `@agent/types`, `@tui`, `@tui/types`, and `@tui/console`. +`src/__tests__/architecture` enforces the matrix and the public-entry rule; +`tsc -b tsconfig.solution.json` mirrors it with project references. + +`--control-socket ` serves an HTTP/1.1 API over a unix socket from +`src/store/control`: state with long polling, actions that call one store setter +each, credentials, run arming on the TUI surface, detection and independent runs +on the headless surface, shutdown. The store owns the server; +`src/cli/control-hooks.ts` does the work that needs the agent. Every +`POST /runs` is one independent run with a clean run state and its own task +stream session. Published builds keep the server for headless runs and refuse +the flag on the TUI, whose bundle never contains it. The full route table and +the run instructions live in +[`e2e-harness/ARCHITECTURE.md`](../../../../e2e-harness/ARCHITECTURE.md). + +Run it: + +```bash +# headless, every build; the key travels in the environment +POSTHOG_WIZARD_API_KEY=phx_... WIZARD_CI_GATEWAY_TOKEN_FILE=/path/to/token \ + npx tsx bin.ts --headless-DONOTUSE-EXPERIMENTAL --control-socket /tmp/w/w.sock \ + --project-id --region us --install-dir /tmp/app +curl -s --unix-socket /tmp/w/w.sock -X POST -H 'content-type: application/json' -d '{}' http://localhost/detect +curl -s --unix-socket /tmp/w/w.sock -X POST -H 'content-type: application/json' \ + -d '{"programId":"posthog-integration"}' http://localhost/runs +curl -s --unix-socket /tmp/w/w.sock 'http://localhost/state?wait=60000&since=0' | jq '.state.run, .state.tasks' +curl -s --unix-socket /tmp/w/w.sock http://localhost/runs +curl -s --unix-socket /tmp/w/w.sock -X POST http://localhost/shutdown + +# the same sequence, scripted +POSTHOG_WIZARD_API_KEY=phx_... WIZARD_CI_GATEWAY_TOKEN_FILE=/path/to/token \ + npx tsx scripts/controlled-headless-smoke.no-jest.ts --app /tmp/app --project-id posthog-integration +``` diff --git a/scripts/README.md b/scripts/README.md index 725eda866..8477856e2 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -12,18 +12,19 @@ real ink render) and is driven purely by store state manipulation; a PTY parent ([`e2e-harness/tui-capture.ts`](../e2e-harness/tui-capture.ts), node-pty + `@xterm/headless`) captures the real rendered screen. -| Script | What it does | Needs | -| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | -| **`tui-host.no-jest.ts`** | Real TUI host: `MODE=fixed` follows a profile; `MODE=serve` accepts socket commands. | `APP_DIR`, `PROJECT_ID`, key for a full run, `SNAP_CTRL`; run under a PTY, with `CONTROL_SOCK` in serve mode | -| **`tui-snapshots.no-jest.ts`** | Runs the fixed host and saves colored `SNAP_OUT/NN-.ans` frames, including within-screen progress. | `SNAP_OUT`, `APP_DIR`, `PROJECT_ID`, `POSTHOG_KEY_FILE` or `POSTHOG_PERSONAL_API_KEY` | -| **`wizard-ci-mcp.no-jest.ts`** | Stdio MCP server: `open_app`, `read_state`, `perform_action`, `render_screen`, `run_agent`. Screen output is plain text. | Spawns the host; `open_app` requires `appDir` and `projectId`, with optional `keyFile`, `apiKey`, `region` | -| **`chunk-manifest.no-jest.ts`** | Prints a structural manifest of `dist/`: per chunk, the source files it contains and the chunks it imports, hash suffixes stripped. Baselines live in `scripts/__fixtures__/chunk-manifest.{prod,ci}.json`. | A built `dist/` | -| **`wizard-ci-explore.no-jest.ts`** | `pnpm wizard-ci-explore`: opens an app, confirms setup, reads state, prints one frame, and exits. It does not run the agent. | `APP_DIR`, `PROJECT_ID`; optional `POSTHOG_KEY_FILE` | +| Script | What it does | Needs | +| ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| **`tui-host.no-jest.ts`** | Real TUI host: `MODE=fixed` follows a profile; `MODE=serve` accepts socket commands. | `APP_DIR`, `PROJECT_ID`, key for a full run, `SNAP_CTRL`; run under a PTY, with `CONTROL_SOCK` in serve mode | +| **`tui-snapshots.no-jest.ts`** | Runs the fixed host and saves colored `SNAP_OUT/NN-.ans` frames, including within-screen progress. | `SNAP_OUT`, `APP_DIR`, `PROJECT_ID`, `POSTHOG_KEY_FILE` or `POSTHOG_PERSONAL_API_KEY` | +| **`wizard-ci-mcp.no-jest.ts`** | Stdio MCP server: `open_app`, `read_state`, `perform_action`, `render_screen`, `run_agent`. Screen output is plain text. | Spawns the host; `open_app` requires `appDir` and `projectId`, with optional `keyFile`, `apiKey`, `region` | +| **`chunk-manifest.no-jest.ts`** | Prints a structural manifest of `dist/`, keyed by source-module group rather than chunk file name: which sources share a chunk and which groups each imports. Baselines live in `scripts/__fixtures__/chunk-manifest.{prod,ci}.json`; CI diffs a fresh build against them. | A built `dist/` | +| **`controlled-headless-smoke.no-jest.ts`** | Drives a headless run over its control socket: detect, one independent run per program named, the run ledger, shutdown. Prints every request and a redacted view of every response. | `POSTHOG_WIZARD_API_KEY`, `WIZARD_CI_GATEWAY_TOKEN_FILE`; `--app`, `--project-id`; optional `--region`, `--bin dist/bin.js` | +| **`wizard-ci-explore.no-jest.ts`** | `pnpm wizard-ci-explore`: opens an app, confirms setup, reads state, prints one frame, and exits. It does not run the agent. | `APP_DIR`, `PROJECT_ID`; optional `POSTHOG_KEY_FILE` | > You usually don't call these directly — `pnpm wizard-ci-snapshots` (in > [wizard-workbench](https://github.com/PostHog/wizard-workbench)) orchestrates -> the snapshot route; the MCP server is registered in this repo's `.mcp.json` and -> used via the `exploring-the-wizard` skill. +> the snapshot route; the MCP server is registered in this repo's `.mcp.json` +> and used via the `exploring-the-wizard` skill. `PROGRAM`, `SNAP_HARNESS`, `SNAP_SEQUENCE`, `SNAP_MODEL`, and `E2E_ASK` are host environment inputs, inherited from the launcher; they are not MCP tool @@ -35,11 +36,11 @@ before choosing credentials or an EU project. ## Credentials for full agent runs Full TUI host and snapshot runs require both the personal API key (or -`POSTHOG_KEY_FILE`) and `WIZARD_CI_GATEWAY_TOKEN_FILE`, plus `PROJECT_ID`. -For MCP runs, pass the personal key through `open_app` and set the gateway -token file path in the server environment before launch. Restart the server -after changing it; the gateway path is not an MCP tool argument. -Detection-only exploration does not need either secret. See +`POSTHOG_KEY_FILE`) and `WIZARD_CI_GATEWAY_TOKEN_FILE`, plus `PROJECT_ID`. For +MCP runs, pass the personal key through `open_app` and set the gateway token +file path in the server environment before launch. Restart the server after +changing it; the gateway path is not an MCP tool argument. Detection-only +exploration does not need either secret. See [local credential setup](../docs/local-dev.md#credentials-for-local-ci-and-headless-runs). ## Background @@ -47,5 +48,5 @@ Detection-only exploration does not need either secret. See The control plane lives in [`e2e-harness/`](../e2e-harness/) — out of `src/`, so none of it ships in prod. `WizardCiDriver` (read/act over the store), the screen→action registry, the e2e profiles, and `tui-capture` (real-TUI PTY -capture). See [`ARCHITECTURE.md`](../e2e-harness/ARCHITECTURE.md) for how the two -routes drive these (env strip, scoped project id, gotchas). +capture). See [`ARCHITECTURE.md`](../e2e-harness/ARCHITECTURE.md) for how the +two routes drive these (env strip, scoped project id, gotchas). diff --git a/scripts/__fixtures__/chunk-manifest.ci.json b/scripts/__fixtures__/chunk-manifest.ci.json index 457f72acf..ece09eac7 100644 --- a/scripts/__fixtures__/chunk-manifest.ci.json +++ b/scripts/__fixtures__/chunk-manifest.ci.json @@ -1,25 +1,43 @@ { - "add-mcp-server-to-clients.js": { + "bin.ts": { "sources": [ - "src/store/services/steps/add-mcp-server-to-clients/MCPClient.ts", - "src/store/services/steps/add-mcp-server-to-clients/clients/claude-code.ts", - "src/store/services/steps/add-mcp-server-to-clients/clients/claude-web.ts", - "src/store/services/steps/add-mcp-server-to-clients/clients/codex.ts", - "src/store/services/steps/add-mcp-server-to-clients/clients/cursor.ts", - "src/store/services/steps/add-mcp-server-to-clients/clients/opencode.ts", - "src/store/services/steps/add-mcp-server-to-clients/clients/visual-studio-code.ts", - "src/store/services/steps/add-mcp-server-to-clients/clients/zed.ts", - "src/store/services/steps/add-mcp-server-to-clients/defaults.ts", - "src/store/services/steps/add-mcp-server-to-clients/index.ts", - "src/store/services/steps/add-mcp-server-to-clients/plugin-client.ts", - "src/store/services/steps/add-mcp-server-to-clients/results.ts" + "bin.ts" + ], + "imports": [ + "src/cli/commands/ai-observability.ts" + ] + }, + "src/agent/agent-interface.ts": { + "sources": [ + "src/agent/agent-interface.ts", + "src/agent/bash-fence.ts", + "src/agent/commandments.ts", + "src/agent/gateway/gateway-session.ts", + "src/agent/output-signals.ts", + "src/agent/runner/harness/pi/gateway.ts", + "src/agent/runner/harness/pi/runtime-notes.ts", + "src/agent/runner/switchboard/commandments.ts", + "src/agent/stored-login.ts", + "src/agent/tools/mcp.ts", + "src/agent/triage-provider.ts" + ], + "imports": [ + "src/agent/runner/sequence/orchestrator/queue-tools.ts", + "src/store/agent-protocol/agent-env-isolation.ts", + "src/store/agent-protocol/agent-signals.ts", + "src/store/auth-session-state.ts" + ] + }, + "src/agent/agent-prompt-loader.ts": { + "sources": [ + "src/agent/agent-prompt-loader.ts" ], "imports": [ - "analytics.js", - "package-manager.js" + "src/agent/runner/sequence/orchestrator/queue-tools.ts", + "src/store/auth-session-state.ts" ] }, - "agent.js": { + "src/agent/agent-prompt.ts": { "sources": [ "src/agent/agent-prompt.ts", "src/agent/detection/agentic.ts", @@ -56,105 +74,107 @@ "src/agent/runner/switchboard/sequence.ts" ], "imports": [ - "agent-interface.js", - "agent-prompt-loader.js", - "analytics.js", - "package-manager.js", - "pi.js", - "program-registry.js", - "programs.js", - "queue-tools.js", - "store.js" + "src/agent/agent-interface.ts", + "src/agent/agent-prompt-loader.ts", + "src/agent/aio-capture.ts", + "src/agent/runner/sequence/orchestrator/queue-tools.ts", + "src/store/agent-protocol/agent-env-isolation.ts", + "src/store/agent-protocol/agent-signals.ts", + "src/store/auth-session-state.ts", + "src/store/programs/ai-opt-in-gate.ts" ] }, - "agent-interface.js": { + "src/agent/aio-capture.ts": { "sources": [ - "src/agent/agent-interface.ts", - "src/agent/bash-fence.ts", - "src/agent/commandments.ts", - "src/agent/gateway/gateway-session.ts", - "src/agent/output-signals.ts", - "src/agent/runner/harness/pi/gateway.ts", - "src/agent/runner/harness/pi/runtime-notes.ts", - "src/agent/runner/switchboard/commandments.ts", - "src/agent/stored-login.ts", - "src/agent/tools/mcp.ts", - "src/agent/triage-provider.ts" + "src/agent/aio-capture.ts", + "src/agent/runner/harness/pi/completion.ts", + "src/agent/runner/harness/pi/index.ts" ], "imports": [ - "analytics.js", - "package-manager.js", - "program-registry.js", - "queue-tools.js", - "store.js" + "src/agent/agent-interface.ts", + "src/agent/runner/harness/pi/mcp.ts", + "src/agent/runner/harness/pi/security.ts", + "src/agent/runner/harness/pi/subagent.ts", + "src/agent/runner/harness/pi/task.ts", + "src/agent/runner/harness/pi/tasks.ts", + "src/agent/runner/harness/pi/tools.ts", + "src/store/agent-protocol/agent-env-isolation.ts", + "src/store/agent-protocol/agent-signals.ts" ] }, - "agent-prompt-loader.js": { + "src/agent/runner/harness/pi/mcp.ts": { "sources": [ - "src/agent/agent-prompt-loader.ts" + "src/agent/runner/harness/pi/mcp.ts" ], - "imports": [ - "analytics.js", - "program-registry.js", - "queue-tools.js" - ] + "imports": [] }, - "analytics.js": { - "sources": [], + "src/agent/runner/harness/pi/orchestrator-tools.ts": { + "sources": [ + "src/agent/runner/harness/pi/orchestrator-tools.ts" + ], "imports": [ - "analytics.js" + "src/agent/runner/sequence/orchestrator/queue-tools.ts" ] }, - "bin.js": { + "src/agent/runner/harness/pi/security.ts": { "sources": [ - "bin.ts" + "src/agent/runner/harness/pi/security.ts" ], "imports": [ - "main.js" + "src/agent/agent-interface.ts", + "src/store/auth-session-state.ts" ] }, - "ci-install.js": { + "src/agent/runner/harness/pi/subagent.ts": { "sources": [ - "src/cli/commands/basic-integration/ci-install.ts" + "src/agent/runner/harness/pi/subagent.ts" ], - "imports": [ - "analytics.js", - "console.js", - "package-manager.js", - "program-registry.js", - "runners.js" - ] + "imports": [] }, - "console.js": { - "sources": [], + "src/agent/runner/harness/pi/task.ts": { + "sources": [ + "src/agent/runner/harness/pi/task.ts" + ], "imports": [ - "console.js" + "src/agent/agent-interface.ts", + "src/agent/agent-prompt-loader.ts", + "src/agent/aio-capture.ts", + "src/agent/runner/harness/pi/mcp.ts", + "src/agent/runner/harness/pi/orchestrator-tools.ts", + "src/agent/runner/harness/pi/security.ts", + "src/agent/runner/harness/pi/tools.ts", + "src/agent/runner/sequence/orchestrator/queue-tools.ts", + "src/store/agent-protocol/agent-env-isolation.ts", + "src/store/agent-protocol/agent-signals.ts" ] }, - "control.js": { + "src/agent/runner/harness/pi/tasks.ts": { "sources": [ - "src/store/control/actions.ts", - "src/store/control/driver.ts", - "src/store/control/marker.ts", - "src/store/control/runs.ts", - "src/store/control/server.ts", - "src/store/control/state.ts" + "src/agent/runner/harness/pi/tasks.ts" + ], + "imports": [] + }, + "src/agent/runner/harness/pi/tools.ts": { + "sources": [ + "src/agent/runner/harness/pi/tools.ts" ], "imports": [ - "analytics.js", - "program-registry.js" + "src/agent/aio-capture.ts", + "src/store/agent-protocol/agent-env-isolation.ts", + "src/store/auth-session-state.ts" ] }, - "interactive.js": { + "src/agent/runner/sequence/orchestrator/queue-tools.ts": { "sources": [ - "src/cli/commands/basic-integration/interactive.ts" + "src/agent/runner/sequence/orchestrator/queue-tools.ts", + "src/agent/runner/sequence/orchestrator/queue.ts", + "src/agent/runner/switchboard/models.ts" ], "imports": [ - "program-registry.js", - "runners.js" + "src/store/auth-session-state.ts" ] }, - "main.js": { + "src/cli/commands/ai-observability.ts": { "sources": [ "src/cli/commands/ai-observability.ts", "src/cli/commands/audit.ts", @@ -190,53 +210,115 @@ "src/cli/wizard.ts" ], "imports": [ - "agent.js", - "analytics.js", - "ci-install.js", - "console.js", - "interactive.js", - "non-interactive.js", - "package-manager.js", - "playground.js", - "program-registry.js", - "runners.js", - "store.js", - "tui.js" + "src/agent/agent-prompt.ts", + "src/cli/commands/basic-integration/ci-install.ts", + "src/cli/commands/basic-integration/interactive.ts", + "src/cli/commands/basic-integration/non-interactive.ts", + "src/cli/commands/basic-integration/playground.ts", + "src/cli/control-hooks.ts", + "src/store/agent-protocol/agent-env-isolation.ts", + "src/store/agent-protocol/agent-signals.ts", + "src/store/auth-session-state.ts", + "src/tui/App.tsx", + "src/tui/console/headless-ui.ts" ] }, - "mcp.js": { + "src/cli/commands/basic-integration/ci-install.ts": { "sources": [ - "src/agent/runner/harness/pi/mcp.ts" + "src/cli/commands/basic-integration/ci-install.ts" ], "imports": [ - "analytics.js" + "src/cli/control-hooks.ts", + "src/store/agent-protocol/agent-signals.ts", + "src/store/auth-session-state.ts", + "src/tui/console/headless-ui.ts" ] }, - "non-interactive.js": { + "src/cli/commands/basic-integration/interactive.ts": { + "sources": [ + "src/cli/commands/basic-integration/interactive.ts" + ], + "imports": [ + "src/cli/control-hooks.ts", + "src/store/auth-session-state.ts" + ] + }, + "src/cli/commands/basic-integration/non-interactive.ts": { "sources": [ "src/cli/commands/basic-integration/non-interactive.ts" ], "imports": [ - "analytics.js", - "package-manager.js" + "src/store/agent-protocol/agent-signals.ts" ] }, - "orchestrator-tools.js": { + "src/cli/commands/basic-integration/playground.ts": { "sources": [ - "src/agent/runner/harness/pi/orchestrator-tools.ts" + "src/cli/commands/basic-integration/playground.ts" ], "imports": [ - "analytics.js", - "queue-tools.js" + "src/tui/App.tsx" ] }, - "package-json.js": { - "sources": [], + "src/cli/control-hooks.ts": { + "sources": [ + "src/cli/control-hooks.ts", + "src/cli/runners/resolve-no-telemetry.ts", + "src/cli/runners/run-non-interactive.ts", + "src/cli/runners/run-wizard-ci.ts", + "src/cli/runners/run-wizard-headless.ts", + "src/cli/runners/run-wizard.ts" + ], + "imports": [ + "src/agent/agent-prompt.ts", + "src/store/agent-protocol/agent-env-isolation.ts", + "src/store/agent-protocol/agent-signals.ts", + "src/store/auth-session-state.ts", + "src/store/control/actions.ts", + "src/store/programs/ai-opt-in-gate.ts", + "src/tui/App.tsx", + "src/tui/console/headless-ui.ts" + ] + }, + "src/store/agent-protocol/agent-env-isolation.ts": { + "sources": [ + "src/store/agent-protocol/agent-env-isolation.ts", + "src/store/agent-protocol/agent-phase.ts", + "src/store/agent-protocol/mcp-prompt.ts", + "src/store/agent-protocol/token-pricing.ts", + "src/store/file-watcher.ts", + "src/store/mcp-project-profile.ts", + "src/store/mcp-role-prompts.copy.json", + "src/store/mcp-role-prompts.ts", + "src/store/mcp-seed-events.ts", + "src/store/safe-tools.ts", + "src/store/services/claude-settings.ts", + "src/store/services/steps/add-mcp-server-to-clients/browser-client.ts", + "src/store/services/steps/add-mcp-server-to-clients/login-client.ts", + "src/store/session/secret-vault.ts", + "src/store/shared/clipboard.ts", + "src/store/shared/custom-headers.ts", + "src/store/shared/env-api-key.ts", + "src/store/shared/environment.ts", + "src/store/shared/terminal-bell.ts", + "src/store/state/flow-resolution.ts", + "src/store/state/run-failure.ts", + "src/store/state/store.ts", + "src/store/task-stream/audit-areas.ts", + "src/store/task-stream/destinations/file.ts", + "src/store/task-stream/destinations/posthog.ts", + "src/store/task-stream/event-plan-watcher.ts", + "src/store/task-stream/task-stream-push.ts", + "src/store/tools/handoff.ts", + "src/store/ui/store-ui.ts", + "src/store/ui/wizard-ui.ts", + "src/store/wizard-spellbook.ts" + ], "imports": [ - "package-json.js" + "src/store/agent-protocol/agent-signals.ts", + "src/store/auth-session-state.ts" ] }, - "package-manager.js": { + "src/store/agent-protocol/agent-signals.ts": { "sources": [ "src/store/agent-protocol/agent-signals.ts", "src/store/api.ts", @@ -264,39 +346,9 @@ "src/store/shared/urls.ts", "src/store/shared/wizard-abort.ts" ], - "imports": [ - "analytics.js" - ] + "imports": [] }, - "pi.js": { - "sources": [ - "src/agent/aio-capture.ts", - "src/agent/runner/harness/pi/completion.ts", - "src/agent/runner/harness/pi/index.ts" - ], - "imports": [ - "agent-interface.js", - "analytics.js", - "mcp.js", - "package-manager.js", - "security.js", - "store.js", - "subagent.js", - "task.js", - "tasks.js", - "tools.js" - ] - }, - "playground.js": { - "sources": [ - "src/cli/commands/basic-integration/playground.ts" - ], - "imports": [ - "analytics.js", - "tui.js" - ] - }, - "program-registry.js": { + "src/store/auth-session-state.ts": { "sources": [ "src/store/auth-session-state.ts", "src/store/control/params.ts", @@ -425,166 +477,67 @@ "src/store/tools/tools.ts" ], "imports": [ - "analytics.js", - "package-json.js", - "package-manager.js", - "steps.js" - ] - }, - "programs.js": { - "sources": [ - "src/store/programs/ai-opt-in-gate.ts", - "src/store/programs/audit/ledger-watcher.ts", - "src/store/programs/error-tracking-upload-source-maps/detect-agentic.ts", - "src/store/programs/flow-for.ts", - "src/store/programs/run-config.ts" - ], - "imports": [ - "analytics.js", - "package-manager.js", - "program-registry.js", - "store.js" + "src/store/agent-protocol/agent-signals.ts", + "src/store/services/steps/upload-environment-variables/EnvironmentProvider.ts" ] }, - "queue-tools.js": { + "src/store/control/actions.ts": { "sources": [ - "src/agent/runner/sequence/orchestrator/queue-tools.ts", - "src/agent/runner/sequence/orchestrator/queue.ts", - "src/agent/runner/switchboard/models.ts" + "src/store/control/actions.ts", + "src/store/control/driver.ts", + "src/store/control/marker.ts", + "src/store/control/runs.ts", + "src/store/control/server.ts", + "src/store/control/state.ts" ], "imports": [ - "analytics.js", - "program-registry.js" + "src/store/auth-session-state.ts" ] }, - "runners.js": { + "src/store/programs/ai-opt-in-gate.ts": { "sources": [ - "src/cli/control-hooks.ts", - "src/cli/runners/resolve-no-telemetry.ts", - "src/cli/runners/run-non-interactive.ts", - "src/cli/runners/run-wizard-ci.ts", - "src/cli/runners/run-wizard-headless.ts", - "src/cli/runners/run-wizard.ts" + "src/store/programs/ai-opt-in-gate.ts", + "src/store/programs/audit/ledger-watcher.ts", + "src/store/programs/error-tracking-upload-source-maps/detect-agentic.ts", + "src/store/programs/flow-for.ts", + "src/store/programs/run-config.ts" ], "imports": [ - "agent.js", - "analytics.js", - "console.js", - "control.js", - "package-manager.js", - "program-registry.js", - "programs.js", - "store.js", - "tui.js" + "src/store/agent-protocol/agent-env-isolation.ts", + "src/store/agent-protocol/agent-signals.ts", + "src/store/auth-session-state.ts" ] }, - "security.js": { + "src/store/services/steps/add-mcp-server-to-clients/MCPClient.ts": { "sources": [ - "src/agent/runner/harness/pi/security.ts" + "src/store/services/steps/add-mcp-server-to-clients/MCPClient.ts", + "src/store/services/steps/add-mcp-server-to-clients/clients/claude-code.ts", + "src/store/services/steps/add-mcp-server-to-clients/clients/claude-web.ts", + "src/store/services/steps/add-mcp-server-to-clients/clients/codex.ts", + "src/store/services/steps/add-mcp-server-to-clients/clients/cursor.ts", + "src/store/services/steps/add-mcp-server-to-clients/clients/opencode.ts", + "src/store/services/steps/add-mcp-server-to-clients/clients/visual-studio-code.ts", + "src/store/services/steps/add-mcp-server-to-clients/clients/zed.ts", + "src/store/services/steps/add-mcp-server-to-clients/defaults.ts", + "src/store/services/steps/add-mcp-server-to-clients/index.ts", + "src/store/services/steps/add-mcp-server-to-clients/plugin-client.ts", + "src/store/services/steps/add-mcp-server-to-clients/results.ts" ], "imports": [ - "agent-interface.js", - "analytics.js", - "program-registry.js" + "src/store/agent-protocol/agent-signals.ts" ] }, - "steps.js": { + "src/store/services/steps/upload-environment-variables/EnvironmentProvider.ts": { "sources": [ "src/store/services/steps/upload-environment-variables/EnvironmentProvider.ts", "src/store/services/steps/upload-environment-variables/index.ts", "src/store/services/steps/upload-environment-variables/providers/vercel.ts" ], "imports": [ - "analytics.js", - "package-manager.js" + "src/store/agent-protocol/agent-signals.ts" ] }, - "store.js": { - "sources": [ - "src/store/agent-protocol/agent-env-isolation.ts", - "src/store/agent-protocol/agent-phase.ts", - "src/store/agent-protocol/mcp-prompt.ts", - "src/store/agent-protocol/token-pricing.ts", - "src/store/file-watcher.ts", - "src/store/mcp-project-profile.ts", - "src/store/mcp-role-prompts.copy.json", - "src/store/mcp-role-prompts.ts", - "src/store/mcp-seed-events.ts", - "src/store/safe-tools.ts", - "src/store/services/claude-settings.ts", - "src/store/services/steps/add-mcp-server-to-clients/browser-client.ts", - "src/store/services/steps/add-mcp-server-to-clients/login-client.ts", - "src/store/session/secret-vault.ts", - "src/store/shared/clipboard.ts", - "src/store/shared/custom-headers.ts", - "src/store/shared/env-api-key.ts", - "src/store/shared/environment.ts", - "src/store/shared/terminal-bell.ts", - "src/store/state/flow-resolution.ts", - "src/store/state/run-failure.ts", - "src/store/state/store.ts", - "src/store/task-stream/audit-areas.ts", - "src/store/task-stream/destinations/file.ts", - "src/store/task-stream/destinations/posthog.ts", - "src/store/task-stream/event-plan-watcher.ts", - "src/store/task-stream/task-stream-push.ts", - "src/store/tools/handoff.ts", - "src/store/ui/store-ui.ts", - "src/store/ui/wizard-ui.ts", - "src/store/wizard-spellbook.ts" - ], - "imports": [ - "analytics.js", - "package-manager.js", - "program-registry.js" - ] - }, - "subagent.js": { - "sources": [ - "src/agent/runner/harness/pi/subagent.ts" - ], - "imports": [ - "analytics.js" - ] - }, - "task.js": { - "sources": [ - "src/agent/runner/harness/pi/task.ts" - ], - "imports": [ - "agent-interface.js", - "agent-prompt-loader.js", - "analytics.js", - "mcp.js", - "orchestrator-tools.js", - "package-manager.js", - "pi.js", - "queue-tools.js", - "security.js", - "store.js", - "tools.js" - ] - }, - "tasks.js": { - "sources": [ - "src/agent/runner/harness/pi/tasks.ts" - ], - "imports": [ - "analytics.js" - ] - }, - "tools.js": { - "sources": [ - "src/agent/runner/harness/pi/tools.ts" - ], - "imports": [ - "analytics.js", - "pi.js", - "program-registry.js", - "store.js" - ] - }, - "tui.js": { + "src/tui/App.tsx": { "sources": [ "src/tui/App.tsx", "src/tui/components/LearnCard.tsx", @@ -767,12 +720,20 @@ "src/tui/ui-store.ts" ], "imports": [ - "add-mcp-server-to-clients.js", - "analytics.js", - "package-manager.js", - "program-registry.js", - "programs.js", - "store.js" + "src/store/agent-protocol/agent-env-isolation.ts", + "src/store/agent-protocol/agent-signals.ts", + "src/store/auth-session-state.ts", + "src/store/programs/ai-opt-in-gate.ts", + "src/store/services/steps/add-mcp-server-to-clients/MCPClient.ts" + ] + }, + "src/tui/console/headless-ui.ts": { + "sources": [ + "src/tui/console/headless-ui.ts", + "src/tui/console/logging-ui.ts" + ], + "imports": [ + "src/store/auth-session-state.ts" ] } } diff --git a/scripts/__fixtures__/chunk-manifest.prod.json b/scripts/__fixtures__/chunk-manifest.prod.json index aaa066d54..09163190e 100644 --- a/scripts/__fixtures__/chunk-manifest.prod.json +++ b/scripts/__fixtures__/chunk-manifest.prod.json @@ -1,25 +1,43 @@ { - "add-mcp-server-to-clients.js": { + "bin.ts": { "sources": [ - "src/store/services/steps/add-mcp-server-to-clients/MCPClient.ts", - "src/store/services/steps/add-mcp-server-to-clients/clients/claude-code.ts", - "src/store/services/steps/add-mcp-server-to-clients/clients/claude-web.ts", - "src/store/services/steps/add-mcp-server-to-clients/clients/codex.ts", - "src/store/services/steps/add-mcp-server-to-clients/clients/cursor.ts", - "src/store/services/steps/add-mcp-server-to-clients/clients/opencode.ts", - "src/store/services/steps/add-mcp-server-to-clients/clients/visual-studio-code.ts", - "src/store/services/steps/add-mcp-server-to-clients/clients/zed.ts", - "src/store/services/steps/add-mcp-server-to-clients/defaults.ts", - "src/store/services/steps/add-mcp-server-to-clients/index.ts", - "src/store/services/steps/add-mcp-server-to-clients/plugin-client.ts", - "src/store/services/steps/add-mcp-server-to-clients/results.ts" + "bin.ts" ], "imports": [ - "analytics.js", - "package-manager.js" + "src/cli/commands/ai-observability.ts" ] }, - "agent.js": { + "src/agent/agent-interface.ts": { + "sources": [ + "src/agent/agent-interface.ts", + "src/agent/bash-fence.ts", + "src/agent/commandments.ts", + "src/agent/gateway/gateway-session.ts", + "src/agent/output-signals.ts", + "src/agent/runner/harness/pi/gateway.ts", + "src/agent/runner/harness/pi/runtime-notes.ts", + "src/agent/runner/switchboard/commandments.ts", + "src/agent/stored-login.ts", + "src/agent/tools/mcp.ts", + "src/agent/triage-provider.ts" + ], + "imports": [ + "src/agent/runner/sequence/orchestrator/queue-tools.ts", + "src/store/agent-protocol/agent-env-isolation.ts", + "src/store/agent-protocol/agent-signals.ts", + "src/store/auth-session-state.ts" + ] + }, + "src/agent/agent-prompt-loader.ts": { + "sources": [ + "src/agent/agent-prompt-loader.ts" + ], + "imports": [ + "src/agent/runner/sequence/orchestrator/queue-tools.ts", + "src/store/auth-session-state.ts" + ] + }, + "src/agent/agent-prompt.ts": { "sources": [ "src/agent/agent-prompt.ts", "src/agent/detection/agentic.ts", @@ -56,109 +74,107 @@ "src/agent/runner/switchboard/sequence.ts" ], "imports": [ - "agent-interface.js", - "agent-prompt-loader.js", - "analytics.js", - "package-manager.js", - "pi.js", - "program-registry.js", - "programs.js", - "queue-tools.js", - "store.js" + "src/agent/agent-interface.ts", + "src/agent/agent-prompt-loader.ts", + "src/agent/aio-capture.ts", + "src/agent/runner/sequence/orchestrator/queue-tools.ts", + "src/store/agent-protocol/agent-env-isolation.ts", + "src/store/agent-protocol/agent-signals.ts", + "src/store/auth-session-state.ts", + "src/store/programs/ai-opt-in-gate.ts" ] }, - "agent-interface.js": { + "src/agent/aio-capture.ts": { "sources": [ - "src/agent/agent-interface.ts", - "src/agent/bash-fence.ts", - "src/agent/commandments.ts", - "src/agent/gateway/gateway-session.ts", - "src/agent/output-signals.ts", - "src/agent/runner/harness/pi/gateway.ts", - "src/agent/runner/harness/pi/runtime-notes.ts", - "src/agent/runner/switchboard/commandments.ts", - "src/agent/stored-login.ts", - "src/agent/tools/mcp.ts", - "src/agent/triage-provider.ts" + "src/agent/aio-capture.ts", + "src/agent/runner/harness/pi/completion.ts", + "src/agent/runner/harness/pi/index.ts" ], "imports": [ - "analytics.js", - "package-manager.js", - "program-registry.js", - "queue-tools.js", - "store.js" + "src/agent/agent-interface.ts", + "src/agent/runner/harness/pi/mcp.ts", + "src/agent/runner/harness/pi/security.ts", + "src/agent/runner/harness/pi/subagent.ts", + "src/agent/runner/harness/pi/task.ts", + "src/agent/runner/harness/pi/tasks.ts", + "src/agent/runner/harness/pi/tools.ts", + "src/store/agent-protocol/agent-env-isolation.ts", + "src/store/agent-protocol/agent-signals.ts" ] }, - "agent-prompt-loader.js": { + "src/agent/runner/harness/pi/mcp.ts": { "sources": [ - "src/agent/agent-prompt-loader.ts" + "src/agent/runner/harness/pi/mcp.ts" ], - "imports": [ - "analytics.js", - "program-registry.js", - "queue-tools.js" - ] + "imports": [] }, - "analytics.js": { - "sources": [], + "src/agent/runner/harness/pi/orchestrator-tools.ts": { + "sources": [ + "src/agent/runner/harness/pi/orchestrator-tools.ts" + ], "imports": [ - "analytics.js" + "src/agent/runner/sequence/orchestrator/queue-tools.ts" ] }, - "bin.js": { + "src/agent/runner/harness/pi/security.ts": { "sources": [ - "bin.ts" + "src/agent/runner/harness/pi/security.ts" ], "imports": [ - "main.js" + "src/agent/agent-interface.ts", + "src/store/auth-session-state.ts" ] }, - "ci-install.js": { + "src/agent/runner/harness/pi/subagent.ts": { "sources": [ - "src/cli/commands/basic-integration/ci-install.ts" + "src/agent/runner/harness/pi/subagent.ts" ], - "imports": [ - "analytics.js", - "console.js", - "package-manager.js", - "program-registry.js", - "runners.js" - ] + "imports": [] }, - "console.js": { + "src/agent/runner/harness/pi/task.ts": { "sources": [ - "src/tui/console/headless-ui.ts", - "src/tui/console/logging-ui.ts" + "src/agent/runner/harness/pi/task.ts" ], "imports": [ - "analytics.js", - "program-registry.js" + "src/agent/agent-interface.ts", + "src/agent/agent-prompt-loader.ts", + "src/agent/aio-capture.ts", + "src/agent/runner/harness/pi/mcp.ts", + "src/agent/runner/harness/pi/orchestrator-tools.ts", + "src/agent/runner/harness/pi/security.ts", + "src/agent/runner/harness/pi/tools.ts", + "src/agent/runner/sequence/orchestrator/queue-tools.ts", + "src/store/agent-protocol/agent-env-isolation.ts", + "src/store/agent-protocol/agent-signals.ts" ] }, - "control.js": { + "src/agent/runner/harness/pi/tasks.ts": { "sources": [ - "src/store/control/actions.ts", - "src/store/control/driver.ts", - "src/store/control/marker.ts", - "src/store/control/runs.ts", - "src/store/control/server.ts", - "src/store/control/state.ts" + "src/agent/runner/harness/pi/tasks.ts" + ], + "imports": [] + }, + "src/agent/runner/harness/pi/tools.ts": { + "sources": [ + "src/agent/runner/harness/pi/tools.ts" ], "imports": [ - "analytics.js", - "program-registry.js" + "src/agent/aio-capture.ts", + "src/store/agent-protocol/agent-env-isolation.ts", + "src/store/auth-session-state.ts" ] }, - "interactive.js": { + "src/agent/runner/sequence/orchestrator/queue-tools.ts": { "sources": [ - "src/cli/commands/basic-integration/interactive.ts" + "src/agent/runner/sequence/orchestrator/queue-tools.ts", + "src/agent/runner/sequence/orchestrator/queue.ts", + "src/agent/runner/switchboard/models.ts" ], "imports": [ - "program-registry.js", - "runners.js" + "src/store/auth-session-state.ts" ] }, - "main.js": { + "src/cli/commands/ai-observability.ts": { "sources": [ "src/cli/commands/ai-observability.ts", "src/cli/commands/audit.ts", @@ -194,53 +210,112 @@ "src/cli/wizard.ts" ], "imports": [ - "agent.js", - "analytics.js", - "ci-install.js", - "console.js", - "interactive.js", - "non-interactive.js", - "package-manager.js", - "playground.js", - "program-registry.js", - "runners.js", - "store.js", - "tui.js" + "src/agent/agent-prompt.ts", + "src/cli/commands/basic-integration/ci-install.ts", + "src/cli/commands/basic-integration/interactive.ts", + "src/cli/commands/basic-integration/non-interactive.ts", + "src/cli/commands/basic-integration/playground.ts", + "src/cli/control-hooks.ts", + "src/store/agent-protocol/agent-env-isolation.ts", + "src/store/agent-protocol/agent-signals.ts", + "src/store/auth-session-state.ts", + "src/tui/App.tsx" ] }, - "mcp.js": { + "src/cli/commands/basic-integration/ci-install.ts": { "sources": [ - "src/agent/runner/harness/pi/mcp.ts" + "src/cli/commands/basic-integration/ci-install.ts" ], "imports": [ - "analytics.js" + "src/cli/control-hooks.ts", + "src/store/agent-protocol/agent-signals.ts", + "src/store/auth-session-state.ts" ] }, - "non-interactive.js": { + "src/cli/commands/basic-integration/interactive.ts": { + "sources": [ + "src/cli/commands/basic-integration/interactive.ts" + ], + "imports": [ + "src/cli/control-hooks.ts", + "src/store/auth-session-state.ts" + ] + }, + "src/cli/commands/basic-integration/non-interactive.ts": { "sources": [ "src/cli/commands/basic-integration/non-interactive.ts" ], "imports": [ - "analytics.js", - "package-manager.js" + "src/store/agent-protocol/agent-signals.ts" ] }, - "orchestrator-tools.js": { + "src/cli/commands/basic-integration/playground.ts": { "sources": [ - "src/agent/runner/harness/pi/orchestrator-tools.ts" + "src/cli/commands/basic-integration/playground.ts" + ], + "imports": [ + "src/tui/App.tsx" + ] + }, + "src/cli/control-hooks.ts": { + "sources": [ + "src/cli/control-hooks.ts", + "src/cli/runners/resolve-no-telemetry.ts", + "src/cli/runners/run-non-interactive.ts", + "src/cli/runners/run-wizard-ci.ts", + "src/cli/runners/run-wizard-headless.ts", + "src/cli/runners/run-wizard.ts" ], "imports": [ - "analytics.js", - "queue-tools.js" + "src/agent/agent-prompt.ts", + "src/store/agent-protocol/agent-env-isolation.ts", + "src/store/agent-protocol/agent-signals.ts", + "src/store/auth-session-state.ts", + "src/store/control/actions.ts", + "src/store/programs/ai-opt-in-gate.ts", + "src/tui/App.tsx" ] }, - "package-json.js": { - "sources": [], + "src/store/agent-protocol/agent-env-isolation.ts": { + "sources": [ + "src/store/agent-protocol/agent-env-isolation.ts", + "src/store/agent-protocol/agent-phase.ts", + "src/store/agent-protocol/mcp-prompt.ts", + "src/store/agent-protocol/token-pricing.ts", + "src/store/file-watcher.ts", + "src/store/mcp-project-profile.ts", + "src/store/mcp-role-prompts.copy.json", + "src/store/mcp-role-prompts.ts", + "src/store/mcp-seed-events.ts", + "src/store/safe-tools.ts", + "src/store/services/claude-settings.ts", + "src/store/services/steps/add-mcp-server-to-clients/browser-client.ts", + "src/store/services/steps/add-mcp-server-to-clients/login-client.ts", + "src/store/session/secret-vault.ts", + "src/store/shared/clipboard.ts", + "src/store/shared/custom-headers.ts", + "src/store/shared/env-api-key.ts", + "src/store/shared/environment.ts", + "src/store/shared/terminal-bell.ts", + "src/store/state/flow-resolution.ts", + "src/store/state/run-failure.ts", + "src/store/state/store.ts", + "src/store/task-stream/audit-areas.ts", + "src/store/task-stream/destinations/file.ts", + "src/store/task-stream/destinations/posthog.ts", + "src/store/task-stream/event-plan-watcher.ts", + "src/store/task-stream/task-stream-push.ts", + "src/store/tools/handoff.ts", + "src/store/ui/store-ui.ts", + "src/store/ui/wizard-ui.ts", + "src/store/wizard-spellbook.ts" + ], "imports": [ - "package-json.js" + "src/store/agent-protocol/agent-signals.ts", + "src/store/auth-session-state.ts" ] }, - "package-manager.js": { + "src/store/agent-protocol/agent-signals.ts": { "sources": [ "src/store/agent-protocol/agent-signals.ts", "src/store/api.ts", @@ -268,39 +343,9 @@ "src/store/shared/urls.ts", "src/store/shared/wizard-abort.ts" ], - "imports": [ - "analytics.js" - ] - }, - "pi.js": { - "sources": [ - "src/agent/aio-capture.ts", - "src/agent/runner/harness/pi/completion.ts", - "src/agent/runner/harness/pi/index.ts" - ], - "imports": [ - "agent-interface.js", - "analytics.js", - "mcp.js", - "package-manager.js", - "security.js", - "store.js", - "subagent.js", - "task.js", - "tasks.js", - "tools.js" - ] + "imports": [] }, - "playground.js": { - "sources": [ - "src/cli/commands/basic-integration/playground.ts" - ], - "imports": [ - "analytics.js", - "tui.js" - ] - }, - "program-registry.js": { + "src/store/auth-session-state.ts": { "sources": [ "src/store/auth-session-state.ts", "src/store/control/params.ts", @@ -429,166 +474,67 @@ "src/store/tools/tools.ts" ], "imports": [ - "analytics.js", - "package-json.js", - "package-manager.js", - "steps.js" - ] - }, - "programs.js": { - "sources": [ - "src/store/programs/ai-opt-in-gate.ts", - "src/store/programs/audit/ledger-watcher.ts", - "src/store/programs/error-tracking-upload-source-maps/detect-agentic.ts", - "src/store/programs/flow-for.ts", - "src/store/programs/run-config.ts" - ], - "imports": [ - "analytics.js", - "package-manager.js", - "program-registry.js", - "store.js" + "src/store/agent-protocol/agent-signals.ts", + "src/store/services/steps/upload-environment-variables/EnvironmentProvider.ts" ] }, - "queue-tools.js": { + "src/store/control/actions.ts": { "sources": [ - "src/agent/runner/sequence/orchestrator/queue-tools.ts", - "src/agent/runner/sequence/orchestrator/queue.ts", - "src/agent/runner/switchboard/models.ts" + "src/store/control/actions.ts", + "src/store/control/driver.ts", + "src/store/control/marker.ts", + "src/store/control/runs.ts", + "src/store/control/server.ts", + "src/store/control/state.ts" ], "imports": [ - "analytics.js", - "program-registry.js" + "src/store/auth-session-state.ts" ] }, - "runners.js": { + "src/store/programs/ai-opt-in-gate.ts": { "sources": [ - "src/cli/control-hooks.ts", - "src/cli/runners/resolve-no-telemetry.ts", - "src/cli/runners/run-non-interactive.ts", - "src/cli/runners/run-wizard-ci.ts", - "src/cli/runners/run-wizard-headless.ts", - "src/cli/runners/run-wizard.ts" + "src/store/programs/ai-opt-in-gate.ts", + "src/store/programs/audit/ledger-watcher.ts", + "src/store/programs/error-tracking-upload-source-maps/detect-agentic.ts", + "src/store/programs/flow-for.ts", + "src/store/programs/run-config.ts" ], "imports": [ - "agent.js", - "analytics.js", - "console.js", - "control.js", - "package-manager.js", - "program-registry.js", - "programs.js", - "store.js", - "tui.js" + "src/store/agent-protocol/agent-env-isolation.ts", + "src/store/agent-protocol/agent-signals.ts", + "src/store/auth-session-state.ts" ] }, - "security.js": { + "src/store/services/steps/add-mcp-server-to-clients/MCPClient.ts": { "sources": [ - "src/agent/runner/harness/pi/security.ts" + "src/store/services/steps/add-mcp-server-to-clients/MCPClient.ts", + "src/store/services/steps/add-mcp-server-to-clients/clients/claude-code.ts", + "src/store/services/steps/add-mcp-server-to-clients/clients/claude-web.ts", + "src/store/services/steps/add-mcp-server-to-clients/clients/codex.ts", + "src/store/services/steps/add-mcp-server-to-clients/clients/cursor.ts", + "src/store/services/steps/add-mcp-server-to-clients/clients/opencode.ts", + "src/store/services/steps/add-mcp-server-to-clients/clients/visual-studio-code.ts", + "src/store/services/steps/add-mcp-server-to-clients/clients/zed.ts", + "src/store/services/steps/add-mcp-server-to-clients/defaults.ts", + "src/store/services/steps/add-mcp-server-to-clients/index.ts", + "src/store/services/steps/add-mcp-server-to-clients/plugin-client.ts", + "src/store/services/steps/add-mcp-server-to-clients/results.ts" ], "imports": [ - "agent-interface.js", - "analytics.js", - "program-registry.js" + "src/store/agent-protocol/agent-signals.ts" ] }, - "steps.js": { + "src/store/services/steps/upload-environment-variables/EnvironmentProvider.ts": { "sources": [ "src/store/services/steps/upload-environment-variables/EnvironmentProvider.ts", "src/store/services/steps/upload-environment-variables/index.ts", "src/store/services/steps/upload-environment-variables/providers/vercel.ts" ], "imports": [ - "analytics.js", - "package-manager.js" + "src/store/agent-protocol/agent-signals.ts" ] }, - "store.js": { - "sources": [ - "src/store/agent-protocol/agent-env-isolation.ts", - "src/store/agent-protocol/agent-phase.ts", - "src/store/agent-protocol/mcp-prompt.ts", - "src/store/agent-protocol/token-pricing.ts", - "src/store/file-watcher.ts", - "src/store/mcp-project-profile.ts", - "src/store/mcp-role-prompts.copy.json", - "src/store/mcp-role-prompts.ts", - "src/store/mcp-seed-events.ts", - "src/store/safe-tools.ts", - "src/store/services/claude-settings.ts", - "src/store/services/steps/add-mcp-server-to-clients/browser-client.ts", - "src/store/services/steps/add-mcp-server-to-clients/login-client.ts", - "src/store/session/secret-vault.ts", - "src/store/shared/clipboard.ts", - "src/store/shared/custom-headers.ts", - "src/store/shared/env-api-key.ts", - "src/store/shared/environment.ts", - "src/store/shared/terminal-bell.ts", - "src/store/state/flow-resolution.ts", - "src/store/state/run-failure.ts", - "src/store/state/store.ts", - "src/store/task-stream/audit-areas.ts", - "src/store/task-stream/destinations/file.ts", - "src/store/task-stream/destinations/posthog.ts", - "src/store/task-stream/event-plan-watcher.ts", - "src/store/task-stream/task-stream-push.ts", - "src/store/tools/handoff.ts", - "src/store/ui/store-ui.ts", - "src/store/ui/wizard-ui.ts", - "src/store/wizard-spellbook.ts" - ], - "imports": [ - "analytics.js", - "package-manager.js", - "program-registry.js" - ] - }, - "subagent.js": { - "sources": [ - "src/agent/runner/harness/pi/subagent.ts" - ], - "imports": [ - "analytics.js" - ] - }, - "task.js": { - "sources": [ - "src/agent/runner/harness/pi/task.ts" - ], - "imports": [ - "agent-interface.js", - "agent-prompt-loader.js", - "analytics.js", - "mcp.js", - "orchestrator-tools.js", - "package-manager.js", - "pi.js", - "queue-tools.js", - "security.js", - "store.js", - "tools.js" - ] - }, - "tasks.js": { - "sources": [ - "src/agent/runner/harness/pi/tasks.ts" - ], - "imports": [ - "analytics.js" - ] - }, - "tools.js": { - "sources": [ - "src/agent/runner/harness/pi/tools.ts" - ], - "imports": [ - "analytics.js", - "pi.js", - "program-registry.js", - "store.js" - ] - }, - "tui.js": { + "src/tui/App.tsx": { "sources": [ "src/tui/App.tsx", "src/tui/components/LearnCard.tsx", @@ -771,12 +717,11 @@ "src/tui/ui-store.ts" ], "imports": [ - "add-mcp-server-to-clients.js", - "analytics.js", - "package-manager.js", - "program-registry.js", - "programs.js", - "store.js" + "src/store/agent-protocol/agent-env-isolation.ts", + "src/store/agent-protocol/agent-signals.ts", + "src/store/auth-session-state.ts", + "src/store/programs/ai-opt-in-gate.ts", + "src/store/services/steps/add-mcp-server-to-clients/MCPClient.ts" ] } } diff --git a/scripts/chunk-manifest.no-jest.ts b/scripts/chunk-manifest.no-jest.ts index 623c2e064..37caf9c74 100644 --- a/scripts/chunk-manifest.no-jest.ts +++ b/scripts/chunk-manifest.no-jest.ts @@ -1,7 +1,9 @@ /** - * Structural manifest of the built bundle: for every chunk in dist/, the - * source files it contains (from its sourcemap) and the chunks it imports. - * Hash suffixes are stripped so the output is stable across builds. + * Structural manifest of the built bundle, keyed by content rather than by + * chunk file name: every chunk that carries source files becomes a group named + * after its first source; imports point at the groups they reach, with empty + * facade chunks resolved through. Chunk names and hashes vary by platform; + * which modules share a chunk and who imports whom does not. * * tsx scripts/chunk-manifest.no-jest.ts [distDir] > manifest.json */ @@ -16,8 +18,12 @@ const stripHash = (file: string): string => const importRe = /(?:from\s*|import\s*\(\s*)["']\.\/([^"']+\.js)["']/g; -const manifest: Record = {}; +interface Chunk { + sources: string[]; + imports: string[]; +} +const chunks: Record = {}; for (const file of fs .readdirSync(dist) .filter((f) => f.endsWith('.js')) @@ -34,7 +40,32 @@ for (const file of fs : []; const imports = new Set(); for (const m of code.matchAll(importRe)) imports.add(stripHash(m[1])); - manifest[stripHash(file)] = { sources, imports: [...imports].sort() }; + chunks[stripHash(file)] = { sources, imports: [...imports].sort() }; +} + +/** The groups a chunk reaches: itself when it carries sources, else what it re-exports. */ +function groupsOf(name: string, seen = new Set()): string[] { + const chunk = chunks[name]; + if (!chunk || seen.has(name)) return []; + seen.add(name); + if (chunk.sources.length) return [chunk.sources[0]]; + return chunk.imports.flatMap((i) => groupsOf(i, seen)); +} + +const manifest: Record = {}; +for (const [name, chunk] of Object.entries(chunks)) { + if (!chunk.sources.length) continue; + const imports = new Set(); + for (const i of chunk.imports) for (const g of groupsOf(i)) imports.add(g); + imports.delete(chunk.sources[0]); + manifest[chunk.sources[0]] = { + sources: chunk.sources, + imports: [...imports].sort(), + }; + void name; } -process.stdout.write(JSON.stringify(manifest, null, 2) + '\n'); +const sorted = Object.fromEntries( + Object.entries(manifest).sort(([a], [b]) => a.localeCompare(b)), +); +process.stdout.write(JSON.stringify(sorted, null, 2) + '\n'); diff --git a/src/cli/__tests__/control-hooks.test.ts b/src/cli/__tests__/control-hooks.test.ts index 23a8f89e3..70eeb444b 100644 --- a/src/cli/__tests__/control-hooks.test.ts +++ b/src/cli/__tests__/control-hooks.test.ts @@ -202,3 +202,20 @@ describe('independent runs', () => { expect(streams[0].shutdown).toHaveBeenCalledTimes(1); }); }); + +describe('run settlement', () => { + it('marks a run that ended while running as completed', async () => { + const { store } = setup(); + const hooks = createControlHooks({ + store, + programId: Program.PostHogIntegration, + runAgent: () => { + store.setRunPhase(RunPhase.Running); + return Promise.resolve(); + }, + shutdown: () => Promise.resolve(), + }); + await hooks.startRun({ programId: Program.PostHogIntegration }); + expect(store.session.runPhase).toBe(RunPhase.Completed); + }); +}); diff --git a/src/cli/control-hooks.ts b/src/cli/control-hooks.ts index 6442b1ff5..4654556e5 100644 --- a/src/cli/control-hooks.ts +++ b/src/cli/control-hooks.ts @@ -87,7 +87,7 @@ export function createControlHooks(deps: ControlHookDeps): ControlHooks { // ciPreRun writes to the session object directly, as the headless runner // lets it; publish the result so pollers and gates see it. await config.ciPreRun(store.session); - store.emitChange(); + store.setDetectionComplete(); } else { await store.runReadyHooks(); } @@ -116,6 +116,11 @@ export function createControlHooks(deps: ControlHookDeps): ControlHooks { await deps.runAgent(runConfigFor(config), runSession, { composed: true, }); + // Headless renderers never flip the phase; settle it as the headless + // runner does, so the ledger and the stream record a completed run. + if (store.session.runPhase === RunPhase.Running) { + store.setRunPhase(RunPhase.Completed); + } } catch (err) { if (store.session.runPhase !== RunPhase.Error) { store.setOutroData({ From 9747c6577a247ff19f8cdf1192234ff4c33807b5 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Sat, 19 Sep 2026 12:51:27 -0400 Subject: [PATCH 5/7] feat(control): mirror the store in the control state GET /state is the committed session whitelist (CONTROL_SESSION_KEYS, credentials as a flag), the run atoms, and the actions legal on the current screen. The run status, the overlay flag, the context digest, and the reduced views go; a ledger record's result is the state at the end of the run. POST /run is requestRun on the store, so ControlHooks shrinks to what the store cannot do itself: credentials, detection, one run, shutdown. Generated-By: PostHog Desktop Task-Id: d14e92bb-6ee1-49b5-8502-39cb80079589 --- scripts/controlled-headless-smoke.no-jest.ts | 14 +- src/cli/__tests__/control-hooks.test.ts | 25 ++- src/cli/control-hooks.ts | 6 +- src/store/README.md | 14 +- src/store/control/__tests__/actions.test.ts | 4 +- src/store/control/__tests__/server.test.ts | 24 +-- src/store/control/__tests__/state.test.ts | 116 ++++++-------- src/store/control/actions.ts | 10 +- src/store/control/client.ts | 13 +- src/store/control/driver.ts | 17 +-- src/store/control/index.ts | 5 +- src/store/control/runs.ts | 9 +- src/store/control/server.ts | 59 +++---- src/store/control/state.ts | 152 ++++++------------- src/store/control/types.ts | 145 +++++------------- src/store/state/store-api.ts | 3 +- src/store/types.ts | 7 +- 17 files changed, 219 insertions(+), 404 deletions(-) diff --git a/scripts/controlled-headless-smoke.no-jest.ts b/scripts/controlled-headless-smoke.no-jest.ts index 6227f4a94..318050691 100644 --- a/scripts/controlled-headless-smoke.no-jest.ts +++ b/scripts/controlled-headless-smoke.no-jest.ts @@ -11,6 +11,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { HEADLESS_FLAG } from '@env'; +import { RunPhase } from '@store'; import { ControlClient } from '@store/control'; import type { ControlState } from '@store/types'; @@ -89,15 +90,14 @@ const exit = new Promise((resolve) => const brief = (s: ControlState) => ({ version: s.version, screen: s.currentScreen, - runPhase: s.runPhase, - run: s.run, + runPhase: s.session.runPhase, integration: s.session.integration, detectionComplete: s.session.detectionComplete, hasCredentials: s.session.hasCredentials, tasks: s.tasks.map((t) => `${t.status}:${t.label}`), - dashboardUrl: s.dashboardUrl, - notebookUrl: s.notebookUrl, - outro: s.outroData, + dashboardUrl: s.session.dashboardUrl, + notebookUrl: s.session.notebookUrl, + outro: s.session.outroData, }); async function main(): Promise { @@ -110,7 +110,7 @@ async function main(): Promise { const record = await client.startRun({ programId }); log(`POST /runs {"programId":"${programId}"} -> ${JSON.stringify(record)}`); let state = await client.state(); - while (state.run.status === 'running') { + while (state.session.runPhase === RunPhase.Running) { state = await client.waitForChange(state.version, 60_000); const last = state.tasks .filter((t) => t.status !== 'pending') @@ -118,7 +118,7 @@ async function main(): Promise { log( ` GET /state?wait=60000&since=${state.version} -> screen=${ state.currentScreen - } runPhase=${state.runPhase} run=${state.run.status} tasks=${ + } runPhase=${state.session.runPhase} tasks=${ state.tasks.filter((t) => t.status === 'completed').length }/${state.tasks.length}${ last ? ` last=${last.status}:${last.label}` : '' diff --git a/src/cli/__tests__/control-hooks.test.ts b/src/cli/__tests__/control-hooks.test.ts index 70eeb444b..e96cb0883 100644 --- a/src/cli/__tests__/control-hooks.test.ts +++ b/src/cli/__tests__/control-hooks.test.ts @@ -68,12 +68,6 @@ function setup(program = Program.PostHogIntegration) { } describe('control hooks', () => { - it('armRun releases the runner through the store', () => { - const { store, hooks } = setup(); - hooks.armRun(); - expect(store.session.runRequested).toBe(true); - }); - it('setCredentials resolves the API key and commits it', async () => { const { store, hooks } = setup(); projectData.mockResolvedValueOnce({ @@ -171,7 +165,7 @@ describe('independent runs', () => { } expect(store.session.dashboardUrl).toBeNull(); expect(store.tasks).toEqual([]); - expect(store.session.runPhase).toBe(RunPhase.Idle); + expect(store.session.runPhase).toBe(RunPhase.Completed); }); it('a run that throws leaves an error outro and still closes its stream', async () => { @@ -204,6 +198,23 @@ describe('independent runs', () => { }); describe('run settlement', () => { + it('is in flight before the agent starts and completed after it returns', async () => { + const { store } = setup(); + let seen: RunPhase | null = null; + const hooks = createControlHooks({ + store, + programId: Program.PostHogIntegration, + runAgent: () => { + seen = store.session.runPhase; + return Promise.resolve(); + }, + shutdown: () => Promise.resolve(), + }); + await hooks.startRun({ programId: Program.PostHogIntegration }); + expect(seen).toBe(RunPhase.Running); + expect(store.session.runPhase).toBe(RunPhase.Completed); + }); + it('marks a run that ended while running as completed', async () => { const { store } = setup(); const hooks = createControlHooks({ diff --git a/src/cli/control-hooks.ts b/src/cli/control-hooks.ts index 4654556e5..9a29abd08 100644 --- a/src/cli/control-hooks.ts +++ b/src/cli/control-hooks.ts @@ -64,10 +64,6 @@ export function createControlHooks(deps: ControlHookDeps): ControlHooks { }); }, - armRun() { - store.requestRun(); - }, - async detect(req: DetectRequest) { const programId = req.programId ?? deps.programId; if (programId !== store.activeProgram) { @@ -110,6 +106,8 @@ export function createControlHooks(deps: ControlHookDeps): ControlHooks { // Each run is its own session: a clean run state and its own stream, as // a fresh CLI invocation would have. Credentials and context persist. store.resetRunState(); + // In flight from here on: pollers read the phase, not the ledger. + store.setRunPhase(RunPhase.Running); const stream = deps.runStream?.(config, runSession); stream?.attach(); try { diff --git a/src/store/README.md b/src/store/README.md index 891541abd..92a66a7e9 100644 --- a/src/store/README.md +++ b/src/store/README.md @@ -12,12 +12,14 @@ Render-agnostic state and the contract between the agent and whatever renders. - `tools/`: wizard tool behavior shared by every harness facade. - `detection/`, `frameworks`, `services/`, `security/`, `task-stream/`, `shared/`. -- `control/`: the control API. An HTTP/1.1 server over a unix socket that reads - one store and commits through its setters: `GET /health`, `GET /state` (long - poll with `?wait=&since=`), `GET /runs`, `POST /actions/`, - `POST /credentials`, `POST /run` (TUI surface), `POST /detect` and - `POST /runs` (headless surface), `POST /shutdown`. The store never runs - agents; `ControlHooks` from the cli do. Generic actions live in +- `control/`: the control API. An HTTP/1.1 server over a unix socket that + mirrors one store. `GET /state` is the committed session whitelist + (`CONTROL_SESSION_KEYS`, credentials as a flag), the run atoms, and the + actions legal on the current screen; `?wait=&since=` blocks on the store + version. `POST /actions/` is one store setter; `POST /run` is + `requestRun`. `POST /credentials`, `POST /detect`, `POST /runs` (headless + surface), and `POST /shutdown` call `ControlHooks` the cli implements, because + the store never authenticates or runs agents. Generic actions live in `control/actions.ts`; a program adds its own through `FlowStep.controlActions`. diff --git a/src/store/control/__tests__/actions.test.ts b/src/store/control/__tests__/actions.test.ts index 76fc7f904..4e8d40f3e 100644 --- a/src/store/control/__tests__/actions.test.ts +++ b/src/store/control/__tests__/actions.test.ts @@ -21,8 +21,6 @@ import { } from '../actions.js'; import { ControlDriver } from '../driver.js'; -const IDLE = () => ({ status: 'idle' as const, error: null }); - function storeFor(program = Program.PostHogIntegration): WizardStore { const store = createTestStore(program); setUI(new StoreUI(store)); @@ -359,7 +357,7 @@ describe('coverage', () => { it('the driver rejects an action the current screen does not offer', () => { const store = storeFor(); - const driver = new ControlDriver(store, IDLE); + const driver = new ControlDriver(store); expect(() => driver.performAction('keep_skills')).toThrow( UnknownActionError, ); diff --git a/src/store/control/__tests__/server.test.ts b/src/store/control/__tests__/server.test.ts index e0f5aa6b0..537e2069c 100644 --- a/src/store/control/__tests__/server.test.ts +++ b/src/store/control/__tests__/server.test.ts @@ -53,7 +53,6 @@ async function serve( }); return Promise.resolve(); }), - armRun: vi.fn(() => store.requestRun()), detect: vi.fn(() => { store.setDetectionComplete(); return Promise.resolve(); @@ -239,7 +238,6 @@ describe('control server', () => { setUI(new StoreUI(store)); const hooks = { setCredentials: vi.fn(), - armRun: vi.fn(), detect: vi.fn(), startRun: vi.fn(), shutdown: vi.fn(), @@ -290,7 +288,7 @@ describe('control server', () => { await expect( client.startRun({ programId: Program.PostHogIntegration }), ).rejects.toMatchObject({ status: 409 }); - expect((await client.state()).run.status).toBe('running'); + expect((await client.state()).session.runPhase).toBe(RunPhase.Running); await new Promise((r) => setTimeout(r, 80)); const runs = await client.runs(); @@ -300,12 +298,13 @@ describe('control server', () => { status: 'done', error: null, }); - expect(runs[0].result).toMatchObject({ + // The result is the state as it read when the run ended. + expect(runs[0].result?.session).toMatchObject({ runPhase: RunPhase.Completed, outroData: { kind: OutroKind.Success, message: 'done' }, }); expect(runs[0].finishedAt).not.toBeNull(); - expect((await client.state()).run.status).toBe('done'); + expect((await client.state()).session.runPhase).toBe(RunPhase.Completed); }); it('records a failed run with its error', async () => { @@ -320,10 +319,7 @@ describe('control server', () => { status: 'failed', error: 'gateway refused', }); - expect((await client.state()).run).toEqual({ - status: 'failed', - error: 'gateway refused', - }); + expect(record.result?.currentScreen).toBe('intro'); }); it('serves each surface its own hooks and answers 501 for the other', async () => { @@ -344,15 +340,9 @@ describe('control server', () => { tui.client.startRun({ programId: Program.Audit }), ).rejects.toMatchObject({ status: 501 }); await expect(tui.client.detect()).rejects.toMatchObject({ status: 501 }); - expect(await tui.client.armRun()).toEqual({ - status: 'running', - error: null, - }); + expect((await tui.client.armRun()).session.runRequested).toBe(true); expect(tui.store.session.runRequested).toBe(true); - expect(await tui.client.armRun()).toEqual({ - status: 'running', - error: null, - }); + expect((await tui.client.armRun()).session.runRequested).toBe(true); }); it('commits credentials through the hook and shuts down once', async () => { diff --git a/src/store/control/__tests__/state.test.ts b/src/store/control/__tests__/state.test.ts index eaea0f6ce..bc7a220fc 100644 --- a/src/store/control/__tests__/state.test.ts +++ b/src/store/control/__tests__/state.test.ts @@ -7,24 +7,23 @@ import { import { createTestStore } from '../../testing/index.js'; import { setUI } from '../../ui/index.js'; import { StoreUI } from '../../ui/store-ui.js'; -import { - contextDigest, - projectState, - redactContext, - runResult, -} from '../state.js'; +import { actionsFor, toActionView } from '../actions.js'; +import { CONTROL_SESSION_KEYS, projectState, redactContext } from '../state.js'; -const RUN = { status: 'idle' as const, error: null }; +function storeFor(over: { apiKey?: string } = {}) { + const store = createTestStore(); + setUI(new StoreUI(store)); + store.session = buildSession({ + installDir: '/tmp/control-state', + ci: true, + ...over, + }); + return store; +} describe('projectState', () => { it('never carries a credential, an api key, a user, or a vaulted answer', () => { - const store = createTestStore(); - setUI(new StoreUI(store)); - store.session = buildSession({ - installDir: '/tmp/control-state', - ci: true, - apiKey: 'phx_PERSONAL_SECRET', - }); + const store = storeFor({ apiKey: 'phx_PERSONAL_SECRET' }); store.setCredentials({ accessToken: 'phx_ACCESS_SECRET', projectApiKey: 'phc_PROJECT_TOKEN', @@ -42,7 +41,7 @@ describe('projectState', () => { 'secret:0b7c6d2e-1a2b-4c3d-8e9f-0a1b2c3d4e5f', ); - const state = projectState(store, RUN); + const state = projectState(store); const text = JSON.stringify(state); for (const leak of [ 'phx_', @@ -56,70 +55,55 @@ describe('projectState', () => { } expect(state.session.hasCredentials).toBe(true); expect(state.session.projectId).toBe(42); - expect(state.frameworkContext.values).toEqual({ + expect(state.session.frameworkContext).toEqual({ router: 'app', 'upload-api-key': '[redacted]', answer: '[secret-ref]', }); - expect(state.frameworkContext.keys).toEqual([ - 'answer', - 'router', - 'upload-api-key', + }); + + it('mirrors the store: the listed session fields, the run atoms, the screen', () => { + const store = storeFor(); + store.completeSetup(); + store.setTasks([ + { label: 'Install SDK', status: 'in_progress', done: false } as never, ]); - expect(state.frameworkContext.digest).toBe( - contextDigest(store.session.frameworkContext), + store.setEventPlan([{ name: 'signup', description: 'a user signed up' }]); + store.setHandoffText('run this prompt'); + store.setDashboardUrl('https://us.posthog.com/project/1/dashboard/2'); + store.setOutroData({ + kind: OutroKind.Success, + message: 'ok', + body: 'long body copy', + }); + store.setRunPhase(RunPhase.Completed); + + const state = projectState(store); + for (const key of CONTROL_SESSION_KEYS) { + if (key === 'frameworkContext') continue; + expect(state.session[key], key).toEqual(store.session[key]); + } + expect(state.currentScreen).toBe(store.currentScreen); + expect(state.version).toBe(store.getVersion()); + expect(state.tasks).toEqual(store.tasks); + expect(state.statusMessages).toEqual(store.statusMessages); + expect(state.eventPlan).toEqual(store.eventPlan); + expect(state.handoffText).toBe('run this prompt'); + expect(state.actions).toEqual( + actionsFor(store.flow, store.currentScreen).map(toActionView), ); + expect(JSON.stringify(state)).not.toContain('"apply"'); }); - it('projects the screen, its actions, and the version', () => { - const store = createTestStore(); - setUI(new StoreUI(store)); - store.session = buildSession({ - installDir: '/tmp/control-state', - ci: true, - }); - const before = projectState(store, RUN); + it('bumps the version on a commit and offers the screen actions', () => { + const store = storeFor(); + const before = projectState(store); expect(before.currentScreen).toBe('intro'); expect(before.actions.map((a) => a.id)).toEqual(['confirm_setup']); - expect(before.hasOverlay).toBe(false); store.completeSetup(); - const after = projectState(store, { status: 'running', error: null }); + const after = projectState(store); expect(after.version).toBeGreaterThan(before.version); expect(after.session.setupConfirmed).toBe(true); - expect(after.run).toEqual({ status: 'running', error: null }); - }); - - it('reduces the outro and reports the run result', () => { - const store = createTestStore(); - setUI(new StoreUI(store)); - store.session = buildSession({ - installDir: '/tmp/control-state', - ci: true, - }); - store.setOutroData({ - kind: OutroKind.Error, - message: 'boom', - body: 'long body copy', - errorCode: 'PHW_INTERNAL_UNHANDLED' as never, - }); - store.setDashboardUrl('https://us.posthog.com/project/1/dashboard/2'); - store.setRunPhase(RunPhase.Error); - const result = runResult(store); - expect(result).toEqual({ - runPhase: RunPhase.Error, - tasks: [], - outroData: { - kind: OutroKind.Error, - errorCode: 'PHW_INTERNAL_UNHANDLED', - message: 'boom', - }, - dashboardUrl: 'https://us.posthog.com/project/1/dashboard/2', - notebookUrl: null, - handoffText: null, - }); - expect(JSON.stringify(projectState(store, RUN))).not.toContain( - 'long body copy', - ); }); it('redacts by key and by secret ref only', () => { diff --git a/src/store/control/actions.ts b/src/store/control/actions.ts index 6b1d88d12..9c10303dd 100644 --- a/src/store/control/actions.ts +++ b/src/store/control/actions.ts @@ -7,7 +7,7 @@ import { McpOutcome, type AskAnswers } from '../session/wizard-session.js'; import type { Flow } from '../state/flow.js'; import { Interrupt } from '../state/interrupts.js'; import { requireString } from './params.js'; -import type { ActionView, DriverAction } from './types.js'; +import type { DriverAction } from './types.js'; export { BadParamError, MissingParamError } from './params.js'; @@ -149,7 +149,7 @@ export const GENERIC_ACTIONS: Readonly< id: 'answer_question', description: 'Resolve the pending wizard_ask request with a complete answers ' + - 'map: { [questionId]: string | string[] }. See state.pendingQuestion.', + 'map: { [questionId]: string | string[] }. See state.session.pendingQuestion.', params: { answers: 'Record' }, apply: (store, params) => store.resolvePendingQuestion((params.answers ?? {}) as AskAnswers), @@ -165,7 +165,7 @@ export const GENERIC_ACTIONS: Readonly< id: 'resolve_notice', description: 'Resolve the task-notice overlay a program shows before an optional ' + - 'step. keep=true runs the step, keep=false skips it. See state.taskNotice.', + 'step. keep=true runs the step, keep=false skips it. See state.session.taskNotice.', params: { keep: 'boolean (default true)' }, apply: (store, params) => store.resolveTaskNotice(params.keep !== false), }, @@ -222,7 +222,9 @@ export function actionsFor(flow: Flow, screen: string): DriverAction[] { return [...own, ...generic.filter((a) => !seen.has(a.id))]; } -export function toActionView(action: DriverAction): ActionView { +export function toActionView( + action: DriverAction, +): Omit { return { id: action.id, description: action.description, diff --git a/src/store/control/client.ts b/src/store/control/client.ts index 1652f71c5..4f191c988 100644 --- a/src/store/control/client.ts +++ b/src/store/control/client.ts @@ -4,7 +4,6 @@ import type { DetectRequest, RunRecord, RunRequest, - RunStatus, } from './types.js'; export class ControlClientError extends Error { @@ -66,14 +65,10 @@ export class ControlClient { ).state; } - async armRun(): Promise<{ status: RunStatus; error: string | null }> { - return ( - await this.request<{ run: { status: RunStatus; error: string | null } }>( - 'POST', - '/run', - {}, - ) - ).run; + /** TUI surface: release the runner's agent start. Idempotent. */ + async armRun(): Promise { + return (await this.request<{ state: ControlState }>('POST', '/run', {})) + .state; } async detect(req: DetectRequest = {}): Promise { diff --git a/src/store/control/driver.ts b/src/store/control/driver.ts index 7c5481563..89cc69ddc 100644 --- a/src/store/control/driver.ts +++ b/src/store/control/driver.ts @@ -1,7 +1,7 @@ import type { WizardStore } from '../state/store.js'; -import { actionsFor, toActionView, UnknownActionError } from './actions.js'; +import { actionsFor, UnknownActionError } from './actions.js'; import { projectState } from './state.js'; -import type { ActionView, ControlState, RunStatus } from './types.js'; +import type { ControlState } from './types.js'; /** * Read and act on one store. Reads the committed state; acts through the exact @@ -9,19 +9,10 @@ import type { ActionView, ControlState, RunStatus } from './types.js'; * is React-local and invisible here by design. */ export class ControlDriver { - constructor( - private readonly store: WizardStore, - private readonly run: () => { status: RunStatus; error: string | null }, - ) {} + constructor(private readonly store: WizardStore) {} readState(): ControlState { - return projectState(this.store, this.run()); - } - - listActions(): ActionView[] { - return actionsFor(this.store.flow, this.store.currentScreen).map( - toActionView, - ); + return projectState(this.store); } /** Apply a named action on the current screen; 400-class errors throw. */ diff --git a/src/store/control/index.ts b/src/store/control/index.ts index 9ce1a3ad1..772b033cd 100644 --- a/src/store/control/index.ts +++ b/src/store/control/index.ts @@ -3,10 +3,9 @@ * on one store. Import only dynamically, from the cli runners, so a published * TUI never loads it. */ -export { attachControlServer, MAX_BODY_BYTES } from './server.js'; +export { attachControlServer } from './server.js'; export { ControlClient, ControlClientError } from './client.js'; export { ControlDriver } from './driver.js'; -export { RunLedger, RunInFlightError } from './runs.js'; export { actionsFor, GENERIC_ACTIONS, @@ -15,5 +14,5 @@ export { BadParamError, UnknownActionError, } from './actions.js'; -export { projectState, redactContext, runResult } from './state.js'; +export { CONTROL_SESSION_KEYS } from './state.js'; export { CONTROL_SERVER_MARKER } from './marker.js'; diff --git a/src/store/control/runs.ts b/src/store/control/runs.ts index dee411e13..aa51232a2 100644 --- a/src/store/control/runs.ts +++ b/src/store/control/runs.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto'; -import type { RunRecord, RunResult } from './types.js'; +import type { ProgramId } from '../programs/program-registry.js'; +import type { ControlState, RunRecord } from './types.js'; /** Thrown when a run is requested while one is in flight. Maps to 409. */ export class RunInFlightError extends Error { @@ -17,7 +18,7 @@ export class RunLedger { return this.records.find((r) => r.status === 'running') ?? null; } - start(programId: string, installDir: string): RunRecord { + start(programId: ProgramId, installDir: string): RunRecord { const running = this.active; if (running) throw new RunInFlightError(running.runId); const record: RunRecord = { @@ -34,14 +35,14 @@ export class RunLedger { return record; } - finish(runId: string, result: RunResult): void { + finish(runId: string, result: ControlState): void { const record = this.find(runId); record.status = 'done'; record.result = result; record.finishedAt = new Date().toISOString(); } - fail(runId: string, error: string, result: RunResult | null): void { + fail(runId: string, error: string, result: ControlState): void { const record = this.find(runId); record.status = 'failed'; record.error = error; diff --git a/src/store/control/server.ts b/src/store/control/server.ts index 91325de15..09167515f 100644 --- a/src/store/control/server.ts +++ b/src/store/control/server.ts @@ -1,8 +1,10 @@ import * as fs from 'node:fs'; import * as http from 'node:http'; import * as net from 'node:net'; -import { PROGRAM_REGISTRY } from '../programs/program-registry.js'; -import { RunPhase } from '../session/wizard-session.js'; +import { + PROGRAM_REGISTRY, + type ProgramId, +} from '../programs/program-registry.js'; import { logToFile } from '../shared/debug.js'; import type { WizardStore } from '../state/store.js'; import { @@ -13,14 +15,12 @@ import { import { ControlDriver } from './driver.js'; import { CONTROL_SERVER_MARKER } from './marker.js'; import { RunInFlightError, RunLedger } from './runs.js'; -import { runResult } from './state.js'; import type { ControlHooks, ControlState, ControlSurface, DetectRequest, RunRequest, - RunStatus, } from './types.js'; export const MAX_BODY_BYTES = 64 * 1024; @@ -70,9 +70,12 @@ function statusFor(err: unknown): number { return 500; } -function requireProgram(programId: string): void { +function requireProgram(programId: unknown): ProgramId { + if (typeof programId !== 'string' || !programId) + throw new HttpError(400, 'programId is required'); if (!PROGRAM_REGISTRY.some((c) => c.id === programId)) throw new HttpError(400, `unknown program "${programId}"`); + return programId; } /** Refuse a live socket; unlink a stale file. */ @@ -149,43 +152,16 @@ export async function attachControlServer( ): Promise { const { socketPath, surface, hooks } = options; const ledger = new RunLedger(); + const driver = new ControlDriver(store); let shuttingDown = false; - const runStatus = (): { status: RunStatus; error: string | null } => { - const active = ledger.active; - if (active) return { status: 'running', error: null }; - const last = ledger.list().at(-1); - if (last) return { status: last.status, error: last.error }; - switch (store.session.runPhase) { - case RunPhase.Running: - return { status: 'running', error: null }; - case RunPhase.Completed: - return { status: 'done', error: null }; - case RunPhase.Error: - return { - status: 'failed', - error: store.session.outroData?.message ?? null, - }; - default: - return { - status: store.session.runRequested ? 'running' : 'idle', - error: null, - }; - } - }; - const driver = new ControlDriver(store, runStatus); - const requireSurface = (route: string, wanted: ControlSurface): void => { if (surface !== wanted) throw new SurfaceUnavailableError(route, surface); }; const startRun = (body: Record) => { - const programId = body.programId; - if (typeof programId !== 'string' || !programId) - throw new HttpError(400, 'programId is required'); - requireProgram(programId); const req: RunRequest = { - programId, + programId: requireProgram(body.programId), ...(typeof body.installDir === 'string' ? { installDir: body.installDir } : {}), @@ -197,18 +173,18 @@ export async function attachControlServer( ...(typeof body.skillId === 'string' ? { skillId: body.skillId } : {}), }; const record = ledger.start( - programId, + req.programId, req.installDir ?? store.session.installDir, ); // The state keeps this run's outcome until the next run starts; the hook // resets it then, so a poller reading after completion sees the result. void hooks.startRun(req).then( - () => ledger.finish(record.runId, runResult(store)), + () => ledger.finish(record.runId, driver.readState()), (err: unknown) => ledger.fail( record.runId, err instanceof Error ? err.message : String(err), - runResult(store), + driver.readState(), ), ); return { ...record }; @@ -264,14 +240,13 @@ export async function attachControlServer( return send(res, 200, { ok: true, state: driver.readState() }); case '/run': requireSurface('POST /run', 'tui'); - hooks.armRun(); - return send(res, 200, { ok: true, run: runStatus() }); + store.requestRun(); + return send(res, 200, { ok: true, state: driver.readState() }); case '/detect': { requireSurface('POST /detect', 'headless'); - if (typeof body.programId === 'string') requireProgram(body.programId); const detect: DetectRequest = { - ...(typeof body.programId === 'string' - ? { programId: body.programId } + ...(body.programId !== undefined + ? { programId: requireProgram(body.programId) } : {}), ...(typeof body.installDir === 'string' ? { installDir: body.installDir } diff --git a/src/store/control/state.ts b/src/store/control/state.ts index f8ef02243..876adc08a 100644 --- a/src/store/control/state.ts +++ b/src/store/control/state.ts @@ -1,12 +1,32 @@ +import type { WizardSession } from '../session/wizard-session.js'; import type { WizardStore } from '../state/store.js'; import { actionsFor, toActionView } from './actions.js'; -import type { - ControlState, - OutroView, - RunResult, - RunStatus, - SetupQuestionView, -} from './types.js'; +import type { ControlSession, ControlState } from './types.js'; + +/** The session fields a parent may read; everything else stays in the process. */ +export const CONTROL_SESSION_KEYS = [ + 'installDir', + 'integration', + 'detectedFrameworkLabel', + 'detectionComplete', + 'frameworkContext', + 'setupConfirmed', + 'integrate', + 'llmOptIn', + 'discoveredFeatures', + 'runRequested', + 'runPhase', + 'completedRuns', + 'pendingQuestion', + 'taskNotice', + 'outroData', + 'outroDismissed', + 'dashboardUrl', + 'notebookUrl', + 'mcpComplete', + 'slackStepDismissed', + 'skillsComplete', +] as const satisfies readonly (keyof WizardSession)[]; const SECRET_REF = /^secret:[0-9a-f-]{16,}$/i; const SECRET_KEY = /(key|token|secret|password|credential)/i; @@ -25,116 +45,34 @@ export function redactContext( return out; } -/** FNV-1a over the serialized context; a change signal, not a secret hash. */ -export function contextDigest(ctx: Record): string { - const s = JSON.stringify(ctx); - let h = 0x811c9dc5; - for (let i = 0; i < s.length; i++) { - h ^= s.charCodeAt(i); - h = Math.imul(h, 0x01000193) >>> 0; - } - return h.toString(16).padStart(8, '0'); -} - -export function outroView(store: WizardStore): OutroView | null { - const data = store.session.outroData; - if (!data) return null; +function projectSession(s: WizardSession): ControlSession { + const picked = Object.fromEntries( + CONTROL_SESSION_KEYS.map((key) => [key, s[key]]), + ) as Pick; return { - kind: data.kind, - ...(data.errorCode ? { errorCode: data.errorCode } : {}), - ...(data.message ? { message: data.message } : {}), - ...(data.docsUrl ? { docsUrl: data.docsUrl } : {}), + ...picked, + frameworkContext: redactContext(s.frameworkContext), + hasCredentials: s.credentials !== null, + projectId: s.credentials?.projectId ?? null, }; } -/** What a finished run leaves behind, for the ledger. */ -export function runResult(store: WizardStore): RunResult { - const s = store.session; - return { - runPhase: s.runPhase, - tasks: store.tasks.map((t) => ({ label: t.label, status: t.status })), - outroData: outroView(store), - dashboardUrl: s.dashboardUrl, - notebookUrl: s.notebookUrl, - handoffText: store.handoffText, - }; -} - -function setupQuestions(store: WizardStore): SetupQuestionView[] { - const s = store.session; - const questions = s.frameworkConfig?.metadata.setup?.questions ?? []; - return questions - .filter((q) => !(q.key in s.frameworkContext)) - .map((q) => ({ - key: q.key, - message: q.message, - options: q.options.map((o) => ({ - label: o.label, - value: o.value, - ...(o.hint ? { hint: o.hint } : {}), - })), - })); -} - -/** Project the committed store state for a controlling parent. */ -export function projectState( - store: WizardStore, - run: { status: RunStatus; error: string | null }, -): ControlState { +/** Project the committed store for a controlling parent. */ +export function projectState(store: WizardStore): ControlState { const s = store.session; const screen = store.currentScreen; + const questions = s.frameworkConfig?.metadata.setup?.questions ?? []; return { version: store.getVersion(), currentScreen: screen, - hasOverlay: store.hasInterrupt, - runPhase: s.runPhase, - run, - session: { - installDir: s.installDir, - integration: s.integration, - detectedFrameworkLabel: s.detectedFrameworkLabel, - detectionComplete: s.detectionComplete, - setupConfirmed: s.setupConfirmed, - integrate: s.integrate, - hasCredentials: s.credentials !== null, - projectId: s.credentials?.projectId ?? null, - mcpComplete: s.mcpComplete, - slackStepDismissed: s.slackStepDismissed, - skillsComplete: s.skillsComplete, - outroDismissed: s.outroDismissed, - llmOptIn: s.llmOptIn, - discoveredFeatures: [...s.discoveredFeatures], - runRequested: s.runRequested, - completedRuns: [...s.completedRuns], - }, - tasks: store.tasks.map((t) => ({ - label: t.label, - status: t.status, - ...(t.activeForm ? { activeForm: t.activeForm } : {}), - })), + session: projectSession(s), + tasks: [...store.tasks], statusMessages: [...store.statusMessages], - eventPlan: store.eventPlan.map((e) => ({ - name: e.name, - description: e.description, - })), - pendingQuestion: s.pendingQuestion ?? null, - taskNotice: s.taskNotice - ? { - title: s.taskNotice.title, - items: [...(s.taskNotice.items ?? [])], - prompt: s.taskNotice.prompt, - } - : null, - setupQuestions: setupQuestions(store), - actions: actionsFor(store.flow, screen).map(toActionView), - dashboardUrl: s.dashboardUrl, - notebookUrl: s.notebookUrl, + eventPlan: [...store.eventPlan], handoffText: store.handoffText, - outroData: outroView(store), - frameworkContext: { - keys: Object.keys(s.frameworkContext).sort(), - digest: contextDigest(s.frameworkContext), - values: redactContext(s.frameworkContext), - }, + setupQuestions: questions + .filter((q) => !(q.key in s.frameworkContext)) + .map((q) => ({ key: q.key, message: q.message, options: q.options })), + actions: actionsFor(store.flow, screen).map(toActionView), }; } diff --git a/src/store/control/types.ts b/src/store/control/types.ts index 13bc270de..56cf071e8 100644 --- a/src/store/control/types.ts +++ b/src/store/control/types.ts @@ -1,10 +1,8 @@ -import type { - OutroKind, - PendingQuestion, - RunPhase, -} from '../session/wizard-session.js'; -import type { ErrorCode } from '../shared/errors/codes.js'; -import type { WizardStore } from '../state/store.js'; +import type { SetupQuestion } from '../framework-config.js'; +import type { ProgramId } from '../programs/program-registry.js'; +import type { WizardSession } from '../session/wizard-session.js'; +import type { PlannedEvent, TaskItem, WizardStore } from '../state/store.js'; +import type { CONTROL_SESSION_KEYS } from './state.js'; /** One commit a controlling parent may make on a screen. */ export interface DriverAction { @@ -17,112 +15,53 @@ export interface DriverAction { apply: (store: WizardStore, params: Record) => void; } -/** An action as a caller sees it: no closure. */ -export interface ActionView { - id: string; - description: string; - params?: Record; -} - -export interface SetupQuestionView { - key: string; - message: string; - options: Array<{ label: string; value: string; hint?: string }>; -} - -export interface TaskNoticeView { - title: string; - items: string[]; - prompt: string; -} - -export type RunStatus = 'idle' | 'running' | 'done' | 'failed'; - -/** The outro reduced to what a parent needs; never the rendered body. */ -export interface OutroView { - kind: OutroKind; - errorCode?: ErrorCode; - message?: string; - docsUrl?: string; -} - -export interface RunResult { - runPhase: RunPhase; - tasks: Array<{ label: string; status: string }>; - outroData: OutroView | null; - dashboardUrl: string | null; - notebookUrl: string | null; - handoffText: string | null; -} - -export interface RunRecord { - runId: string; - programId: string; - installDir: string; - status: Exclude; - error: string | null; - startedAt: string; - finishedAt: string | null; - result: RunResult | null; -} +/** The session as a parent reads it: the listed fields, credentials as a flag. */ +export type ControlSession = Pick< + WizardSession, + (typeof CONTROL_SESSION_KEYS)[number] +> & { + hasCredentials: boolean; + projectId: number | null; +}; /** - * The observable state. A whitelist of the session: credentials reduce to a - * boolean and framework context passes a redaction, so no secret reaches a - * controlling parent. + * The store as a parent reads it: the committed session whitelist, the run + * atoms, and what the flow derives for the current screen. No access token, + * API key, user record, or answer value is ever projected. */ export interface ControlState { version: number; currentScreen: string; - hasOverlay: boolean; - runPhase: RunPhase; - run: { status: RunStatus; error: string | null }; - session: { - installDir: string; - integration: string | null; - detectedFrameworkLabel: string | null; - detectionComplete: boolean; - setupConfirmed: boolean; - integrate: boolean | null; - hasCredentials: boolean; - projectId: number | null; - mcpComplete: boolean; - slackStepDismissed: boolean; - skillsComplete: boolean; - outroDismissed: boolean; - llmOptIn: boolean; - discoveredFeatures: string[]; - runRequested: boolean; - completedRuns: string[]; - }; - tasks: Array<{ label: string; status: string; activeForm?: string }>; + session: ControlSession; + tasks: TaskItem[]; statusMessages: string[]; - eventPlan: Array<{ name: string; description: string }>; - pendingQuestion: PendingQuestion | null; - taskNotice: TaskNoticeView | null; - setupQuestions: SetupQuestionView[]; - actions: ActionView[]; - dashboardUrl: string | null; - notebookUrl: string | null; + eventPlan: PlannedEvent[]; handoffText: string | null; - outroData: OutroView | null; - frameworkContext: { - keys: string[]; - digest: string; - values: Record; - }; + /** Setup questions the session has not answered yet. */ + setupQuestions: Array>; + /** The commits legal on `currentScreen`. */ + actions: Array>; } -export interface DetectRequest { - programId?: string; - installDir?: string; -} +export type DetectRequest = { programId?: ProgramId } & Partial< + Pick +>; -export interface RunRequest { - programId: string; - installDir?: string; - frameworkContext?: Record; - skillId?: string; +export type RunRequest = { programId: ProgramId } & Partial< + Pick +>; + +/** One independent run the headless surface served. */ +export interface RunRecord { + runId: string; + programId: ProgramId; + installDir: string; + status: 'running' | 'done' | 'failed'; + error: string | null; + startedAt: string; + finishedAt: string | null; + /** The state as `GET /state` read it when the run ended. */ + result: ControlState | null; } export type ControlSurface = 'tui' | 'headless'; @@ -131,8 +70,6 @@ export type ControlSurface = 'tui' | 'headless'; export interface ControlHooks { /** Resolve credentials host side and commit them, advancing `auth`. */ setCredentials(): Promise; - /** TUI surface: release the runner's agent start. */ - armRun(): void; /** Headless surface: run detection for a program, writing through setters. */ detect(req: DetectRequest): Promise; /** Headless surface: one independent agent run. Resolves when it ends. */ diff --git a/src/store/state/store-api.ts b/src/store/state/store-api.ts index d5f9fe63a..23d4b7d93 100644 --- a/src/store/state/store-api.ts +++ b/src/store/state/store-api.ts @@ -21,7 +21,6 @@ export const STORE_BOUNDARY_MEMBERS = [ 'declineSharing', 'dismissManualAuthCode', 'dismissOutage', - 'emitChange', 'enableFeature', 'eventPlan', 'getGate', @@ -30,7 +29,6 @@ export const STORE_BOUNDARY_MEMBERS = [ 'hasInterrupt', 'interruptDepth', 'pushStatus', - 'requestRun', 'resetRunState', 'resolvePendingQuestion', 'resolvePortConflict', @@ -42,6 +40,7 @@ export const STORE_BOUNDARY_MEMBERS = [ 'setCredentials', 'setCurrentStage', 'setDetectedFramework', + 'setDetectionComplete', 'setEventPlan', 'setFrameworkConfig', 'setFrameworkContext', diff --git a/src/store/types.ts b/src/store/types.ts index 13ea03926..7be544def 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -95,19 +95,14 @@ export type { export type { WizardSpellbook } from './wizard-spellbook.js'; export type { StoreBoundaryMember, WizardStoreApi } from './state/store-api.js'; export type { - ActionView, ControlHooks, + ControlSession, ControlState, ControlSurface, DetectRequest, DriverAction, - OutroView, RunRecord, RunRequest, - RunResult, - RunStatus, - SetupQuestionView, - TaskNoticeView, } from './control/types.js'; export type { ControlServerHandle, From 2d28ba240ad711bba49e8b7b5302bcb1f4141913 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Sat, 19 Sep 2026 13:55:30 -0400 Subject: [PATCH 6/7] fix(control): fence the run, close cleanly, validate every param Routes that rewrite the session or end the process (POST /credentials, /detect, /shutdown) answer 409 while a run is in flight; a stale-socket probe only unlinks a socket; close() answers pending long polls before it drops connections and unlinks synchronously; the 413 reaches the client; a bad action id is a 400; the ledger records the resolved install dir and survives a hook that throws synchronously. Detection publishes what ciPreRun wrote to its copy as well as what it committed; every run calls runCleanups and keys its task stream by its own skill; the headless destination reads credentials from the live store; the CI gateway bearer is dev-build only and skipped for programs without an agent run. The default intro's confirm_setup follows the scan-sharing toggle like Enter, and action params are validated instead of coerced. Generated-By: PostHog Desktop Task-Id: d14e92bb-6ee1-49b5-8502-39cb80079589 --- .../boundary-contracts.test.ts.snap | 2 + .../architecture/boundary-contracts.test.ts | 12 + src/cli/__tests__/control-hooks.test.ts | 182 +++++++---- src/cli/commands/basic-integration/index.ts | 3 +- src/cli/commands/factories/shared.ts | 10 +- src/cli/control-hooks.ts | 54 ++-- src/cli/runners/run-non-interactive.ts | 31 +- src/cli/runners/run-wizard.ts | 8 +- src/cli/testing/fake-surfaces.ts | 2 + src/store/control/__tests__/actions.test.ts | 82 ++++- src/store/control/__tests__/server.test.ts | 306 ++++++++++++++---- src/store/control/__tests__/state.test.ts | 168 +++++++--- src/store/control/actions.ts | 125 ++++--- src/store/control/client.ts | 9 +- src/store/control/driver.ts | 23 +- src/store/control/index.ts | 11 +- src/store/control/params.ts | 100 +++++- src/store/control/runs.ts | 8 +- src/store/control/server.ts | 255 ++++++++++----- src/store/control/state.ts | 42 ++- src/store/control/types.ts | 19 +- src/store/index.ts | 2 + src/store/shared/environment.ts | 13 +- src/store/shared/headless-mode.ts | 5 + src/store/shared/paths.ts | 11 +- .../state/__tests__/reset-run-state.test.ts | 65 ++++ src/store/task-stream/task-stream-push.ts | 10 +- src/store/testing/index.ts | 31 ++ src/store/types.ts | 2 + 29 files changed, 1189 insertions(+), 402 deletions(-) create mode 100644 src/store/state/__tests__/reset-run-state.test.ts diff --git a/src/__tests__/architecture/__snapshots__/boundary-contracts.test.ts.snap b/src/__tests__/architecture/__snapshots__/boundary-contracts.test.ts.snap index af37db8de..21fb952fb 100644 --- a/src/__tests__/architecture/__snapshots__/boundary-contracts.test.ts.snap +++ b/src/__tests__/architecture/__snapshots__/boundary-contracts.test.ts.snap @@ -234,6 +234,7 @@ exports[`boundary contracts > cli consumes exactly the recorded names from other "initLocalDev", "installOrUpdatePostHogCli", "installSteeringSnippet", + "isControlledTui", "isHeadless", "isNonInteractiveEnvironment", "isRunFailure", @@ -245,6 +246,7 @@ exports[`boundary contracts > cli consumes exactly the recorded names from other "recoverOrphanedSettingsBackups", "regionOption", "removeMCPServerFromClientsStep", + "resolveInstallDir", "runCleanups", "setDetectionAgent", "setEntryCommand", diff --git a/src/__tests__/architecture/boundary-contracts.test.ts b/src/__tests__/architecture/boundary-contracts.test.ts index ba06863e5..b232217a4 100644 --- a/src/__tests__/architecture/boundary-contracts.test.ts +++ b/src/__tests__/architecture/boundary-contracts.test.ts @@ -67,4 +67,16 @@ describe('boundary contracts', () => { expect(consumed(roots)).toMatchSnapshot(); }, ); + + it.each(CONSUMERS.filter(([name]) => name !== 'cli'))( + '%s never names the control API', + (_name, roots) => { + const list: string[] = []; + for (const r of roots) files(r, list); + const offenders = list.filter((f) => + fs.readFileSync(f, 'utf8').includes('@store/control'), + ); + expect(offenders).toEqual([]); + }, + ); }); diff --git a/src/cli/__tests__/control-hooks.test.ts b/src/cli/__tests__/control-hooks.test.ts index e96cb0883..640017e69 100644 --- a/src/cli/__tests__/control-hooks.test.ts +++ b/src/cli/__tests__/control-hooks.test.ts @@ -1,13 +1,28 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { afterEach, describe, expect, it, vi } from 'vitest'; const projectData = vi.hoisted(() => vi.fn()); +const cleanups = vi.hoisted(() => vi.fn()); +const preRun = vi.hoisted(() => vi.fn()); vi.mock('@store', async (importOriginal) => ({ ...(await importOriginal()), getOrAskForProjectData: projectData, + runCleanups: cleanups, })); +vi.mock('@store/programs', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + getProgramConfig: (id: string) => { + const config = original.getProgramConfig(id as never); + // Metrics stands in for a program with a ciPreRun and a skill of its own. + return id === original.Program.Metrics + ? { ...config, ciPreRun: preRun, skillId: 'metrics-skill' } + : config; + }, + }; +}); vi.mock('@store/shared/analytics', () => ({ analytics: { setTag: vi.fn(), @@ -17,15 +32,25 @@ vi.mock('@store/shared/analytics', () => ({ sessionProperties: () => ({}), })); -import { buildSession, OutroKind, RunPhase, StoreUI, setUI } from '@store'; +import { + buildSession, + getUI, + HostResolution, + OutroKind, + RunPhase, + StoreUI, + setUI, +} from '@store'; import { Program } from '@store/programs'; +import type { WizardSession } from '@store/types'; import { createControlHooks } from '../control-hooks.js'; import { fakeRunAgent, fakeStartTUI } from '../testing/fake-surfaces.js'; const dirs: string[] = []; afterEach(() => { - for (const d of dirs.splice(0)) + for (const d of dirs.splice(0)) { fs.rmSync(d, { recursive: true, force: true }); + } vi.clearAllMocks(); }); @@ -67,13 +92,13 @@ function setup(program = Program.PostHogIntegration) { return { store, hooks, agent, shutdown, streams, dir }; } -describe('control hooks', () => { - it('setCredentials resolves the API key and commits it', async () => { +describe('credentials', () => { + it('resolves the API key host side and commits the project credentials', async () => { const { store, hooks } = setup(); projectData.mockResolvedValueOnce({ accessToken: 'phx_key', projectApiKey: 'phc_project', - host: { region: 'us' }, + host: HostResolution.fromApiHost('https://us.posthog.com'), projectId: 42, }); await hooks.setCredentials(); @@ -91,8 +116,10 @@ describe('control hooks', () => { projectId: 42, }); }); +}); - it('startRun scopes one independent run and passes context explicitly', async () => { +describe('one independent run', () => { + it('scopes the run session and passes the request context explicitly', async () => { const { store, hooks, agent, dir } = setup(); await hooks.startRun({ programId: Program.Audit, @@ -105,6 +132,7 @@ describe('control hooks', () => { programId: Program.Audit, installDir: path.join(dir, 'apps/web'), frameworkContextKeys: ['shared', 'picked'], + skillId: 'audit-events', composed: true, }, ]); @@ -113,7 +141,7 @@ describe('control hooks', () => { expect(store.session.frameworkContext).toEqual({ shared: 'live' }); }); - it('startRun keeps an absolute install dir and falls back to the live one', async () => { + it('keeps an absolute install dir and falls back to the live one', async () => { const { hooks, agent, dir } = setup(); await hooks.startRun({ programId: Program.PostHogIntegration, @@ -123,32 +151,20 @@ describe('control hooks', () => { expect(agent.calls.map((c) => c.installDir)).toEqual(['/abs/app', dir]); }); - it("detect runs the launched program's ready hooks against the store", async () => { - // Audit declares no ciPreRun, so detection is the store's onReady walk. - const { store, hooks } = setup(Program.Audit); - const before = store.getVersion(); - await hooks.detect({}); - expect(store.activeProgram).toBe(Program.Audit); - expect(store.getVersion()).toBeGreaterThanOrEqual(before); - }); - - it('detect switches program and install dir on request', async () => { - const { store, hooks, dir } = setup(); - fs.mkdirSync(path.join(dir, 'sub')); - await hooks.detect({ programId: Program.Audit, installDir: 'sub' }); - expect(store.activeProgram).toBe(Program.Audit); - expect(store.session.installDir).toBe(path.join(dir, 'sub')); - }); - - it('shutdown defers to the runner', async () => { - const { hooks, shutdown } = setup(); - await hooks.shutdown(); - expect(shutdown).toHaveBeenCalledTimes(1); + it('picks the skill from the request, then the program, then the live session', async () => { + const { store, hooks, agent } = setup(); + store.session = { ...store.session, skillId: 'live-skill' }; + await hooks.startRun({ programId: Program.Audit, skillId: 'requested' }); + await hooks.startRun({ programId: Program.Metrics }); + await hooks.startRun({ programId: Program.PostHogIntegration }); + expect(agent.calls.map((c) => c.skillId)).toEqual([ + 'requested', + 'metrics-skill', + 'live-skill', + ]); }); -}); -describe('independent runs', () => { - it('each run gets its own stream and a clean run state', async () => { + it('gives each run its own stream and a clean run state, and restores the user files', async () => { const { store, hooks, streams } = setup(); store.setDashboardUrl('https://us.posthog.com/project/1/dashboard/9'); store.setTasks([{ label: 'stale', status: 'completed' } as never]); @@ -163,12 +179,30 @@ describe('independent runs', () => { expect(stream.attach).toHaveBeenCalledTimes(1); expect(stream.shutdown).toHaveBeenCalledWith(2000); } + expect(cleanups).toHaveBeenCalledTimes(2); expect(store.session.dashboardUrl).toBeNull(); expect(store.tasks).toEqual([]); expect(store.session.runPhase).toBe(RunPhase.Completed); }); - it('a run that throws leaves an error outro and still closes its stream', async () => { + it('is in flight before the agent starts and completed after it returns', async () => { + const { store } = setup(); + let seen: RunPhase | null = null; + const hooks = createControlHooks({ + store, + programId: Program.PostHogIntegration, + runAgent: () => { + seen = store.session.runPhase; + return Promise.resolve(); + }, + shutdown: () => Promise.resolve(), + }); + await hooks.startRun({ programId: Program.PostHogIntegration }); + expect(seen).toBe(RunPhase.Running); + expect(store.session.runPhase).toBe(RunPhase.Completed); + }); + + it('records an error outro when the agent throws without one, and still closes the stream', async () => { const { store, streams } = setup(); const failing = createControlHooks({ store, @@ -194,39 +228,79 @@ describe('independent runs', () => { message: 'gateway refused', }); expect(streams[0].shutdown).toHaveBeenCalledTimes(1); + expect(cleanups).toHaveBeenCalledTimes(1); }); -}); -describe('run settlement', () => { - it('is in flight before the agent starts and completed after it returns', async () => { + it("keeps the agent's own error outro when it already set one", async () => { const { store } = setup(); - let seen: RunPhase | null = null; const hooks = createControlHooks({ store, programId: Program.PostHogIntegration, runAgent: () => { - seen = store.session.runPhase; - return Promise.resolve(); + store.setOutroData({ + kind: OutroKind.Error, + message: 'agent said so', + errorCode: 'PHW_AGENT_YARA_VIOLATION' as never, + }); + store.setRunPhase(RunPhase.Error); + return Promise.reject(new Error('aborted')); }, shutdown: () => Promise.resolve(), }); - await hooks.startRun({ programId: Program.PostHogIntegration }); - expect(seen).toBe(RunPhase.Running); - expect(store.session.runPhase).toBe(RunPhase.Completed); + await expect( + hooks.startRun({ programId: Program.PostHogIntegration }), + ).rejects.toThrow('aborted'); + expect(store.session.outroData).toMatchObject({ + message: 'agent said so', + errorCode: 'PHW_AGENT_YARA_VIOLATION', + }); }); +}); - it('marks a run that ended while running as completed', async () => { - const { store } = setup(); - const hooks = createControlHooks({ - store, - programId: Program.PostHogIntegration, - runAgent: () => { - store.setRunPhase(RunPhase.Running); - return Promise.resolve(); - }, - shutdown: () => Promise.resolve(), +describe('detection', () => { + it("runs the launched program's ready hooks when it declares no ciPreRun", async () => { + // Audit declares no ciPreRun, so detection is the store's onReady walk. + const { store, hooks } = setup(Program.Audit); + const ready = vi.spyOn(store, 'runReadyHooks'); + await hooks.detect({}); + expect(ready).toHaveBeenCalledTimes(1); + expect(preRun).not.toHaveBeenCalled(); + }); + + it('publishes both what ciPreRun wrote directly and what it committed through setters', async () => { + const { store, hooks } = setup(Program.Metrics); + preRun.mockImplementationOnce(async (session: WizardSession) => { + getUI().setCredentials({ + accessToken: 'phx_x', + projectApiKey: 'phc_x', + host: HostResolution.fromApiHost('https://us.posthog.com'), + projectId: 9, + }); + session.typescript = true; + session.integration = 'javascript_node' as never; + await Promise.resolve(); }); - await hooks.startRun({ programId: Program.PostHogIntegration }); - expect(store.session.runPhase).toBe(RunPhase.Completed); + await hooks.detect({}); + expect(preRun).toHaveBeenCalledTimes(1); + expect(store.session.credentials?.projectId).toBe(9); + expect(store.session.typescript).toBe(true); + expect(store.session.integration).toBe('javascript_node'); + expect(store.session.detectionComplete).toBe(true); + }); + + it('switches program and install dir on request', async () => { + const { store, hooks, dir } = setup(); + fs.mkdirSync(path.join(dir, 'sub')); + await hooks.detect({ programId: Program.Audit, installDir: 'sub' }); + expect(store.activeProgram).toBe(Program.Audit); + expect(store.session.installDir).toBe(path.join(dir, 'sub')); + }); +}); + +describe('shutdown', () => { + it('defers to the runner', async () => { + const { hooks, shutdown } = setup(); + await hooks.shutdown(); + expect(shutdown).toHaveBeenCalledTimes(1); }); }); diff --git a/src/cli/commands/basic-integration/index.ts b/src/cli/commands/basic-integration/index.ts index 44e2c1241..d22278d9c 100644 --- a/src/cli/commands/basic-integration/index.ts +++ b/src/cli/commands/basic-integration/index.ts @@ -2,6 +2,7 @@ import { isNonInteractiveEnvironment, setEntryCommand, headlessOption, + isControlledTui, isHeadless, regionOption, } from '@store'; @@ -64,7 +65,7 @@ export const basicIntegrationCommand: Command = { const { runHeadlessInstall } = await import('./ci-install.js'); return runHeadlessInstall(argv); } - if (argv.ci && argv.controlSocket) { + if (isControlledTui(argv)) { const { runInteractive } = await import('./interactive.js'); return runInteractive(argv); } diff --git a/src/cli/commands/factories/shared.ts b/src/cli/commands/factories/shared.ts index 888ab693e..ead5b4149 100644 --- a/src/cli/commands/factories/shared.ts +++ b/src/cli/commands/factories/shared.ts @@ -5,7 +5,12 @@ import { runWizardCI, runWizardHeadless, } from '../../runners/index.js'; -import { isHeadless, ErrorCodes, emitWizardError } from '@store'; +import { + isControlledTui, + isHeadless, + ErrorCodes, + emitWizardError, +} from '@store'; import type { ProgramConfig } from '@store/types'; import { skillProgramOptions } from '../skill-program-options.js'; @@ -45,8 +50,7 @@ export function dispatchProgram(config: ProgramConfig, argv: Arguments): void { // Same non-interactive pipeline `--ci` uses; validation (api-key, // install-dir, region) is owned by runNonInteractive. runWizardHeadless(config, options); - } else if (options.ci && options.controlSocket) { - // A controlled TUI: the real screens, API-key auth, a parent on the socket. + } else if (isControlledTui(options)) { runWizard(config, options); } else if (options.ci) { runWizardCI(config, options); diff --git a/src/cli/control-hooks.ts b/src/cli/control-hooks.ts index 9a29abd08..dd9e0cbc8 100644 --- a/src/cli/control-hooks.ts +++ b/src/cli/control-hooks.ts @@ -1,6 +1,12 @@ -import * as path from 'node:path'; import type { RunAgent } from '@agent/types'; -import { getOrAskForProjectData, logToFile, OutroKind, RunPhase } from '@store'; +import { + getOrAskForProjectData, + logToFile, + OutroKind, + resolveInstallDir, + runCleanups, + RunPhase, +} from '@store'; import { flowFor, getProgramConfig, runConfigFor } from '@store/programs'; import type { ControlHooks, @@ -23,26 +29,26 @@ export interface ControlHookDeps { /** The program this process launched with. */ programId: ProgramId; runAgent: RunAgent; - /** Builds the stream a run publishes to. Absent means the run publishes nothing. */ + /** Builds the stream a run publishes to; absent means the run publishes nothing. */ runStream?: (config: ProgramConfig, session: WizardSession) => RunStream; /** Flush and exit; the runner owns the exact steps. */ shutdown: () => Promise; } -/** A sub-app path stays relative to the live install dir; absolute wins. */ -function resolveInstallDir( - live: string, - requested: string | undefined, -): string { - if (!requested) return live; - return path.isAbsolute(requested) ? requested : path.join(live, requested); +/** The keys `draft` changed against `before`. */ +function changedKeys( + before: WizardSession, + draft: WizardSession, +): Partial { + const was = before as unknown as Record; + const out: Record = {}; + for (const [key, value] of Object.entries(draft)) { + if (value !== was[key]) out[key] = value; + } + return out as Partial; } -/** - * What the composition root does when a parent drives the run. Every agent run - * is independent: the context a run receives is exactly what the request and - * the live session hold, merged here and nowhere else. - */ +/** The composition root's side of the control API: every run independent, context merged here only. */ export function createControlHooks(deps: ControlHookDeps): ControlHooks { const { store } = deps; return { @@ -80,9 +86,11 @@ export function createControlHooks(deps: ControlHookDeps): ControlHooks { } const config = getProgramConfig(programId); if (config.ciPreRun) { - // ciPreRun writes to the session object directly, as the headless runner - // lets it; publish the result so pollers and gates see it. - await config.ciPreRun(store.session); + // ciPreRun writes to the object it is handed while its setters commit to the store. + const before = store.session; + const draft: WizardSession = { ...before }; + await config.ciPreRun(draft); + store.session = { ...store.session, ...changedKeys(before, draft) }; store.setDetectionComplete(); } else { await store.runReadyHooks(); @@ -92,7 +100,7 @@ export function createControlHooks(deps: ControlHookDeps): ControlHooks { async startRun(req: RunRequest) { const config = getProgramConfig(req.programId); const live = store.session; - const runSession = { + const runSession: WizardSession = { ...live, installDir: resolveInstallDir(live.installDir, req.installDir), frameworkContext: { @@ -103,10 +111,8 @@ export function createControlHooks(deps: ControlHookDeps): ControlHooks { programLabel: config.id, }; logToFile(`[control] run ${config.id} in ${runSession.installDir}`); - // Each run is its own session: a clean run state and its own stream, as - // a fresh CLI invocation would have. Credentials and context persist. + // One session per run: a clean run state and its own stream; credentials and context persist. store.resetRunState(); - // In flight from here on: pollers read the phase, not the ledger. store.setRunPhase(RunPhase.Running); const stream = deps.runStream?.(config, runSession); stream?.attach(); @@ -114,8 +120,7 @@ export function createControlHooks(deps: ControlHookDeps): ControlHooks { await deps.runAgent(runConfigFor(config), runSession, { composed: true, }); - // Headless renderers never flip the phase; settle it as the headless - // runner does, so the ledger and the stream record a completed run. + // Headless renderers never flip the phase; settle it so the ledger records a completed run. if (store.session.runPhase === RunPhase.Running) { store.setRunPhase(RunPhase.Completed); } @@ -129,6 +134,7 @@ export function createControlHooks(deps: ControlHookDeps): ControlHooks { } throw err; } finally { + runCleanups(); await stream?.shutdown(2000); } }, diff --git a/src/cli/runners/run-non-interactive.ts b/src/cli/runners/run-non-interactive.ts index ca3fb574a..80ea2c19b 100644 --- a/src/cli/runners/run-non-interactive.ts +++ b/src/cli/runners/run-non-interactive.ts @@ -24,7 +24,7 @@ import type { } from '@store/types'; import { LoggingUI } from '@tui/console'; import { runConfigFor, getAuditChecks, flowFor } from '@store/programs'; -import { runtimeEnv } from '@env'; +import { IS_PRODUCTION_BUILD, runtimeEnv } from '@env'; import { resolveNoTelemetry } from './resolve-no-telemetry.js'; import { createControlHooks } from '../control-hooks.js'; import { join } from 'node:path'; @@ -200,7 +200,9 @@ export function runNonInteractive( const posthogDestination = mode === 'headless' && !session.noTelemetry ? new PostHogDestination({ - getCredentials: () => session.credentials, + // The store forks the session on its first commit; read the live one. + getCredentials: () => + store?.session.credentials ?? session.credentials, onError: (e) => logToFile('[headless task-stream]', e.message), }) : null; @@ -228,6 +230,7 @@ export function runNonInteractive( new TaskStreamPush({ store: headlessStore, programId: runConfig.streamWorkflowId ?? runConfig.id, + skillId: runSession.skillId ?? undefined, destinations, eventPlanPath: runConfig.eventPlanFile ? join(runSession.installDir, runConfig.eventPlanFile) @@ -263,9 +266,11 @@ export function runNonInteractive( }; try { - // An issued gateway bearer in the environment replaces the mint, for - // `--ci` and for a headless run driven by a test harness alike. - if (mode === 'ci' || runtimeEnv('WIZARD_CI_GATEWAY_TOKEN_FILE')) { + // An issued gateway bearer replaces the mint for `--ci` and, in dev builds, for a harness-driven headless run. + if ( + mode === 'ci' || + (!IS_PRODUCTION_BUILD && runtimeEnv('WIZARD_CI_GATEWAY_TOKEN_FILE')) + ) { const { configureGatewayFromCIEnvironment } = await import('@agent'); configureGatewayFromCIEnvironment( Number(session.projectId), @@ -280,7 +285,7 @@ export function runNonInteractive( const { attachControlServer } = await import('@store/control'); const { runAgent } = await import('@agent'); const { VERSION } = await import('@store'); - let release: (() => void) | null = null; + let release: () => void = () => undefined; const served = new Promise((resolve) => { release = resolve; }); @@ -295,21 +300,19 @@ export function runNonInteractive( runAgent, runStream: runStream ?? undefined, shutdown: () => { - release?.(); + release(); return Promise.resolve(); }, }), }); - const onSignal = (): void => release?.(); - process.once('SIGINT', onSignal); - process.once('SIGTERM', onSignal); + process.once('SIGINT', release); + process.once('SIGTERM', release); logToFile(`[control] serving ${config.id} on ${handle.socketPath}`); await served; - process.off('SIGINT', onSignal); - process.off('SIGTERM', onSignal); + process.off('SIGINT', release); + process.off('SIGTERM', release); await handle.close(); - if (taskStream) await taskStream.shutdown(2000); - // Nothing else holds the loop: the process ends here with status 0. + // Each run shut its own stream; nothing else holds the loop, so the process ends with status 0. return; } diff --git a/src/cli/runners/run-wizard.ts b/src/cli/runners/run-wizard.ts index 53d90adc3..032e22616 100644 --- a/src/cli/runners/run-wizard.ts +++ b/src/cli/runners/run-wizard.ts @@ -288,17 +288,15 @@ export function runWizard( await activeTui.store.getGate('integration-check'); await activeTui.store.getGate('health-check'); - if (session.ci) { - // API-key sessions carry no OAuth token, so the gateway bearer comes - // from the CI environment, as it does for `--ci` and the e2e host. + const skipAgent = config.run == null; + if (session.ci && !skipAgent) { + // API-key sessions carry no OAuth token; the gateway bearer comes from the CI environment. const { configureGatewayFromCIEnvironment } = await import('@agent'); configureGatewayFromCIEnvironment( Number(session.projectId), session.region ?? 'us', ); } - - const skipAgent = config.run == null; const shown = (s: ProgramConfig['steps'][number]) => !s.show || s.show(activeTui.store.session); diff --git a/src/cli/testing/fake-surfaces.ts b/src/cli/testing/fake-surfaces.ts index 9633e9cc1..062e26b56 100644 --- a/src/cli/testing/fake-surfaces.ts +++ b/src/cli/testing/fake-surfaces.ts @@ -8,6 +8,7 @@ export interface RunAgentCall { programId: string; installDir: string; frameworkContextKeys: string[]; + skillId: string | null; composed: boolean; } @@ -19,6 +20,7 @@ export function fakeRunAgent(): { runAgent: RunAgent; calls: RunAgentCall[] } { programId: config.id, installDir: session.installDir, frameworkContextKeys: Object.keys(session.frameworkContext), + skillId: session.skillId, composed: options.composed ?? false, }); return Promise.resolve(); diff --git a/src/store/control/__tests__/actions.test.ts b/src/store/control/__tests__/actions.test.ts index 4e8d40f3e..3c2027978 100644 --- a/src/store/control/__tests__/actions.test.ts +++ b/src/store/control/__tests__/actions.test.ts @@ -4,21 +4,24 @@ import { SOURCE_MAPS_CONTEXT_KEYS } from '../../programs/error-tracking-upload-s import { flowFor } from '../../programs/flow-for.js'; import { Program, PROGRAM_REGISTRY } from '../../programs/program-registry.js'; import { SELF_DRIVING_INTEGRATE_PATH_KEY } from '../../programs/self-driving/detect.js'; -import { buildSession, McpOutcome } from '../../session/wizard-session.js'; +import { + buildSession, + McpOutcome, + ScanConsent, +} from '../../session/wizard-session.js'; import type { Flow } from '../../state/flow.js'; import { Interrupt } from '../../state/interrupts.js'; import { WizardStore } from '../../state/store.js'; -import { createTestStore } from '../../testing/index.js'; +import { createControlledStore, createTestStore } from '../../testing/index.js'; import { setUI } from '../../ui/index.js'; import { StoreUI } from '../../ui/store-ui.js'; import { actionsFor, - BadParamError, GENERIC_ACTIONS, - MissingParamError, NO_ACTION_SCREENS, UnknownActionError, } from '../actions.js'; +import { BadParamError, MissingParamError } from '../params.js'; import { ControlDriver } from '../driver.js'; function storeFor(program = Program.PostHogIntegration): WizardStore { @@ -328,6 +331,77 @@ describe('program actions', () => { }); }); +describe('param validation', () => { + it('confirm_setup on the default intro follows the sharing toggle like Enter does', () => { + // An interactive session starts undecided; a ci session is granted up front. + const undecided = createControlledStore(undefined, { ci: false }); + expect(undecided.session.scanConsent).toBe(ScanConsent.Undecided); + apply(undecided, 'intro', 'confirm_setup'); + expect(undecided.session.scanConsent).toBe(ScanConsent.Granted); + expect(undecided.session.setupConfirmed).toBe(true); + + const declined = createControlledStore(undefined, { ci: false }); + declined.declineSharing(); + apply(declined, 'intro', 'confirm_setup'); + expect(declined.session.scanConsent).toBe(ScanConsent.Declined); + + const explicit = storeFor(); + apply(explicit, 'intro', 'confirm_setup', { share: false }); + expect(explicit.session.scanConsent).toBe(ScanConsent.Declined); + expect(() => + apply(storeFor(), 'intro', 'confirm_setup', { share: 'yes' }), + ).toThrow(BadParamError); + + const other = createControlledStore(Program.Audit, { ci: false }); + apply(other, 'audit-intro', 'confirm_setup'); + expect(other.session.scanConsent).toBe(ScanConsent.Undecided); + expect(other.session.setupConfirmed).toBe(true); + }); + + it('passes booleans through and rejects anything else', () => { + const store = storeFor(); + const skills = vi.spyOn(store, 'setSkillsComplete'); + apply(store, 'keep-skills', 'keep_skills', { kept: false }); + expect(skills).toHaveBeenCalledWith(false); + apply(store, 'slack-connect', 'set_slack_connected', { connected: false }); + expect(store.session.slackConnected).toBe(false); + expect(() => + apply(store, 'slack-connect', 'set_slack_connected', { + connected: 'false', + }), + ).toThrow(BadParamError); + expect(() => + apply(store, 'keep-skills', 'keep_skills', { kept: 1 }), + ).toThrow(BadParamError); + }); + + it('rejects an unknown MCP outcome and a non-string client list', () => { + const store = storeFor(); + expect(() => + apply(store, 'mcp', 'set_mcp_outcome', { outcome: 'maybe' }), + ).toThrow(BadParamError); + expect(() => + apply(store, 'mcp', 'set_mcp_outcome', { clients: [1] }), + ).toThrow(BadParamError); + expect(store.session.mcpComplete).toBe(false); + }); + + it('requires an answers object and a non-empty string value', () => { + const store = storeFor(); + void store.requestQuestion({ + id: 'q', + subject: 'test', + questions: [{ id: 'color', question: 'Which?', kind: 'text' }], + } as never); + expect(() => + apply(store, Interrupt.WizardAsk, 'answer_question', { answers: 'red' }), + ).toThrow(MissingParamError); + expect(() => + apply(store, 'setup', 'choose', { key: 'router', value: '' }), + ).toThrow(MissingParamError); + }); +}); + describe('coverage', () => { it('every flow key and interrupt is actionable or explicitly no-action', () => { const missing: string[] = []; diff --git a/src/store/control/__tests__/server.test.ts b/src/store/control/__tests__/server.test.ts index 537e2069c..7db41e416 100644 --- a/src/store/control/__tests__/server.test.ts +++ b/src/store/control/__tests__/server.test.ts @@ -1,18 +1,13 @@ +import { spawn } from 'node:child_process'; import * as fs from 'node:fs'; import * as http from 'node:http'; import * as net from 'node:net'; import * as os from 'node:os'; import * as path from 'node:path'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { HostResolution } from '../../host-resolution.js'; import { Program } from '../../programs/program-registry.js'; -import { - buildSession, - OutroKind, - RunPhase, -} from '../../session/wizard-session.js'; -import { createTestStore } from '../../testing/index.js'; -import { setUI } from '../../ui/index.js'; -import { StoreUI } from '../../ui/store-ui.js'; +import { OutroKind, RunPhase } from '../../session/wizard-session.js'; +import { createControlledStore, expectNoSecrets } from '../../testing/index.js'; import { ControlClient, ControlClientError } from '../client.js'; import { attachControlServer, @@ -26,8 +21,9 @@ const dirs: string[] = []; afterEach(async () => { await Promise.all(handles.splice(0).map((h) => h.close())); - for (const dir of dirs.splice(0)) + for (const dir of dirs.splice(0)) { fs.rmSync(dir, { recursive: true, force: true }); + } }); function socketDir(): string { @@ -36,19 +32,46 @@ function socketDir(): string { return dir; } +/** A socket file whose server died without unlinking: what a crashed wizard leaves. */ +async function staleSocket(socketPath: string): Promise { + const child = spawn( + process.execPath, + [ + '-e', + "require('net').createServer().listen(process.argv[1], () => process.stdout.write('ok'))", + socketPath, + ], + { stdio: ['ignore', 'pipe', 'ignore'] }, + ); + await new Promise((resolve) => + child.stdout.once('data', () => resolve()), + ); + const exited = new Promise((resolve) => + child.once('exit', () => resolve()), + ); + child.kill('SIGKILL'); + await exited; +} + +const US = HostResolution.fromApiHost('https://us.posthog.com'); + async function serve( surface: ControlSurface = 'headless', program = Program.PostHogIntegration, + options: { apiKey?: string | null; runMs?: number } = {}, ) { - const store = createTestStore(program); - setUI(new StoreUI(store)); - store.session = buildSession({ installDir: '/tmp/control-server', ci: true }); + const store = createControlledStore(program, { + installDir: '/tmp/control-server', + ...(options.apiKey === null + ? {} + : { apiKey: options.apiKey ?? 'phx_test_key' }), + }); const hooks: ControlHooks = { setCredentials: vi.fn(() => { store.setCredentials({ accessToken: 'phx_SECRET', projectApiKey: 'phc_TOKEN', - host: {} as never, + host: US, projectId: 7, }); return Promise.resolve(); @@ -59,7 +82,7 @@ async function serve( }), startRun: vi.fn(async () => { store.setRunPhase(RunPhase.Running); - await new Promise((r) => setTimeout(r, 30)); + await new Promise((r) => setTimeout(r, options.runMs ?? 30)); store.setOutroData({ kind: OutroKind.Success, message: 'done' }); store.setRunPhase(RunPhase.Completed); }), @@ -113,10 +136,13 @@ function raw( ); } +const JSON_HEADERS = { 'content-type': 'application/json' }; +const settle = (ms: number) => new Promise((r) => setTimeout(r, ms)); + describe('control server', () => { it('answers health with the surface and program', async () => { const { client } = await serve('tui', Program.Audit); - expect(await client.health()).toMatchObject({ + expect(await client.health()).toEqual({ ok: true, surface: 'tui', program: Program.Audit, @@ -136,33 +162,44 @@ describe('control server', () => { expect(store.session.setupConfirmed).toBe(true); }); - it('maps bad input to 400, unknown routes to 404', async () => { + it('maps bad input to 400 and unknown routes to 404', async () => { const { client, socketPath } = await serve(); await expect(client.performAction('keep_skills')).rejects.toMatchObject({ status: 400, }); expect((await raw(socketPath, 'GET', '/nope')).status).toBe(404); expect( - ( - await raw(socketPath, 'POST', '/nope', '{}', { - 'content-type': 'application/json', - }) - ).status, + (await raw(socketPath, 'POST', '/nope', '{}', JSON_HEADERS)).status, ).toBe(404); expect( ( - await raw(socketPath, 'POST', '/actions/confirm_setup', '{', { - 'content-type': 'application/json', - }) + await raw( + socketPath, + 'POST', + '/actions/confirm_setup', + '{', + JSON_HEADERS, + ) ).status, ).toBe(400); + expect( + (await raw(socketPath, 'POST', '/actions/%zz', '{}', JSON_HEADERS)) + .status, + ).toBe(400); expect( ( - await raw(socketPath, 'POST', '/runs', '{}', { - 'content-type': 'application/json', - }) + await raw( + socketPath, + 'POST', + '/actions/confirm_setup', + '{"params":[]}', + JSON_HEADERS, + ) ).status, ).toBe(400); + expect( + (await raw(socketPath, 'POST', '/runs', '{}', JSON_HEADERS)).status, + ).toBe(400); }); it('rejects a missing param with 400 and the param name', async () => { @@ -174,7 +211,7 @@ describe('control server', () => { store.setCredentials({ accessToken: 't', projectApiKey: 'k', - host: {} as never, + host: US, projectId: 1, }); expect((await client.state()).currentScreen).toBe('source-maps-detect'); @@ -186,15 +223,32 @@ describe('control server', () => { }); }); - it('caps the body and requires JSON', async () => { + it('answers 413 past the body cap, 200 at it, and 415 for non-JSON', async () => { const { socketPath } = await serve(); - const big = JSON.stringify({ - params: { pad: 'x'.repeat(MAX_BODY_BYTES + 1) }, - }); - const r = await raw(socketPath, 'POST', '/actions/confirm_setup', big, { - 'content-type': 'application/json', - }).catch(() => ({ status: 413, json: {} })); - expect(r.status).toBe(413); + const wrap = (pad: string) => JSON.stringify({ params: { pad } }); + const overhead = wrap('').length; + const atLimit = wrap('x'.repeat(MAX_BODY_BYTES - overhead)); + expect(Buffer.byteLength(atLimit)).toBe(MAX_BODY_BYTES); + expect( + ( + await raw( + socketPath, + 'POST', + '/actions/confirm_setup', + atLimit, + JSON_HEADERS, + ) + ).status, + ).toBe(200); + const over = await raw( + socketPath, + 'POST', + '/actions/confirm_setup', + wrap('x'.repeat(MAX_BODY_BYTES - overhead + 1)), + JSON_HEADERS, + ); + expect(over.status).toBe(413); + expect(over.json.error).toContain(String(MAX_BODY_BYTES)); expect( ( await raw(socketPath, 'POST', '/actions/confirm_setup', 'x', { @@ -219,6 +273,17 @@ describe('control server', () => { expect(Date.now() - started).toBeGreaterThanOrEqual(50); }); + it('reads immediately for a non-numeric wait and times out for a version ahead of the store', async () => { + const { client, socketPath } = await serve(); + const current = (await client.state()).version; + const started = Date.now(); + const immediate = await raw(socketPath, 'GET', '/state?wait=abc&since=0'); + expect(immediate.status).toBe(200); + expect(Date.now() - started).toBeLessThan(500); + const ahead = await client.waitForChange(current + 100, 60); + expect(ahead.version).toBe(current); + }); + it('wakes every concurrent poller on one commit', async () => { const { client, store } = await serve(); const since = (await client.state()).version; @@ -230,67 +295,83 @@ describe('control server', () => { expect(sa.version).toBeGreaterThan(since); }); - it('owns its socket: 0600, stale files replaced, live sockets refused, unlinked on close', async () => { + it('answers pending long polls when it closes instead of holding the process', async () => { + const { client, handle } = await serve(); + const since = (await client.state()).version; + const started = Date.now(); + const pending = client.waitForChange(since, 5000); + await settle(20); + await handle.close(); + const state = await pending; + expect(state.version).toBe(since); + expect(Date.now() - started).toBeLessThan(1500); + }); + + it('owns its socket: 0600, a dead socket replaced, a live one refused, a plain file refused, unlinked on close', async () => { const dir = socketDir(); const stale = path.join(dir, 'stale.sock'); - fs.writeFileSync(stale, ''); - const store = createTestStore(); - setUI(new StoreUI(store)); + await staleSocket(stale); + expect(fs.existsSync(stale)).toBe(true); + const store = createControlledStore(); const hooks = { setCredentials: vi.fn(), detect: vi.fn(), startRun: vi.fn(), shutdown: vi.fn(), } as unknown as ControlHooks; - const handle = await attachControlServer(store, { - socketPath: stale, - surface: 'tui', - hooks, - version: 't', - program: 'p', - }); + const attach = (socketPath: string) => + attachControlServer(store, { + socketPath, + surface: 'tui', + hooks, + version: 't', + program: 'p', + }); + const handle = await attach(stale); expect(fs.statSync(stale).mode & 0o777).toBe(0o600); expect((await new ControlClient(stale).health()).ok).toBe(true); const live = path.join(dir, 'live.sock'); const other = net.createServer().listen(live); await new Promise((r) => other.once('listening', r)); - await expect( - attachControlServer(store, { - socketPath: live, - surface: 'tui', - hooks, - version: 't', - program: 'p', - }), - ).rejects.toThrow(/already served/); + await expect(attach(live)).rejects.toThrow(/already served/); other.close(); + const file = path.join(dir, 'not-a-socket'); + fs.writeFileSync(file, 'keep me'); + await expect(attach(file)).rejects.toThrow(/not a socket/); + expect(fs.readFileSync(file, 'utf8')).toBe('keep me'); + await handle.close(); expect(fs.existsSync(stale)).toBe(false); }); - it('runs one independent run at a time and records it in the ledger', async () => { - const { client, hooks } = await serve('headless'); + it('runs one independent run at a time and records the resolved request in the ledger', async () => { + const { client, hooks, store } = await serve('headless'); const run = await client.startRun({ programId: Program.PostHogIntegration, - installDir: '/tmp/app', + installDir: 'apps/web', + frameworkContext: { picked: 'yes' }, + skillId: 'nextjs', }); + const absolute = path.join(store.session.installDir, 'apps/web'); expect(run).toMatchObject({ status: 'running', programId: Program.PostHogIntegration, - installDir: '/tmp/app', + installDir: absolute, }); expect(hooks.startRun).toHaveBeenCalledWith({ programId: Program.PostHogIntegration, - installDir: '/tmp/app', + installDir: absolute, + frameworkContext: { picked: 'yes' }, + skillId: 'nextjs', }); await expect( client.startRun({ programId: Program.PostHogIntegration }), ).rejects.toMatchObject({ status: 409 }); expect((await client.state()).session.runPhase).toBe(RunPhase.Running); - await new Promise((r) => setTimeout(r, 80)); + await settle(80); const runs = await client.runs(); expect(runs).toHaveLength(1); expect(runs[0]).toMatchObject({ @@ -298,7 +379,6 @@ describe('control server', () => { status: 'done', error: null, }); - // The result is the state as it read when the run ended. expect(runs[0].result?.session).toMatchObject({ runPhase: RunPhase.Completed, outroData: { kind: OutroKind.Success, message: 'done' }, @@ -307,13 +387,53 @@ describe('control server', () => { expect((await client.state()).session.runPhase).toBe(RunPhase.Completed); }); - it('records a failed run with its error', async () => { + it('drops nothing silently: a non-object frameworkContext is a 400', async () => { + const { socketPath, hooks } = await serve('headless'); + const r = await raw( + socketPath, + 'POST', + '/runs', + JSON.stringify({ + programId: Program.PostHogIntegration, + frameworkContext: [], + }), + JSON_HEADERS, + ); + expect(r.status).toBe(400); + expect(hooks.startRun).not.toHaveBeenCalled(); + }); + + it('keeps runs in start order and hands out copies', async () => { + const { client, store } = await serve( + 'headless', + Program.PostHogIntegration, + { + runMs: 5, + }, + ); + await client.startRun({ programId: Program.Metrics }); + await settle(40); + await client.startRun({ programId: Program.Audit }); + await settle(40); + const runs = await client.runs(); + expect(runs.map((r) => [r.programId, r.status])).toEqual([ + [Program.Metrics, 'done'], + [Program.Audit, 'done'], + ]); + expect(runs.every((r) => r.installDir === store.session.installDir)).toBe( + true, + ); + runs[0].status = 'failed'; + expect((await client.runs())[0].status).toBe('done'); + }); + + it('records a failed run with its error and the state at failure', async () => { const { client, hooks } = await serve('headless'); vi.mocked(hooks.startRun).mockRejectedValueOnce( new Error('gateway refused'), ); await client.startRun({ programId: Program.PostHogIntegration }); - await new Promise((r) => setTimeout(r, 20)); + await settle(20); const [record] = await client.runs(); expect(record).toMatchObject({ status: 'failed', @@ -322,6 +442,36 @@ describe('control server', () => { expect(record.result?.currentScreen).toBe('intro'); }); + it('refuses detection, credentials, and shutdown while a run is in flight', async () => { + const { client, hooks } = await serve( + 'headless', + Program.PostHogIntegration, + { + runMs: 150, + }, + ); + await client.startRun({ programId: Program.PostHogIntegration }); + await expect(client.detect({})).rejects.toMatchObject({ status: 409 }); + await expect(client.setCredentials()).rejects.toMatchObject({ + status: 409, + }); + await expect(client.shutdown()).rejects.toMatchObject({ status: 409 }); + expect(hooks.detect).not.toHaveBeenCalled(); + expect(hooks.setCredentials).not.toHaveBeenCalled(); + expect(hooks.shutdown).not.toHaveBeenCalled(); + await settle(200); + await client.shutdown(); + await settle(10); + expect(hooks.shutdown).toHaveBeenCalledTimes(1); + }); + + it('treats a running TUI phase as in flight too', async () => { + const { client, store, hooks } = await serve('tui'); + store.setRunPhase(RunPhase.Running); + await expect(client.shutdown()).rejects.toMatchObject({ status: 409 }); + expect(hooks.shutdown).not.toHaveBeenCalled(); + }); + it('serves each surface its own hooks and answers 501 for the other', async () => { const headless = await serve('headless'); await expect(headless.client.armRun()).rejects.toMatchObject({ @@ -345,19 +495,37 @@ describe('control server', () => { expect((await tui.client.armRun()).session.runRequested).toBe(true); }); - it('commits credentials through the hook and shuts down once', async () => { + it('resolves a relative detect install dir against the live one', async () => { + const { client, hooks, store } = await serve('headless'); + await client.detect({ installDir: 'packages/api' }); + expect(hooks.detect).toHaveBeenCalledWith({ + installDir: path.join(store.session.installDir, 'packages/api'), + }); + }); + + it('commits credentials through the hook, leaks nothing, and shuts down once', async () => { const { client, hooks } = await serve('tui'); const state = await client.setCredentials(); expect(hooks.setCredentials).toHaveBeenCalledTimes(1); expect(state.session).toMatchObject({ hasCredentials: true, projectId: 7 }); - expect(JSON.stringify(state)).not.toContain('phx_SECRET'); + expectNoSecrets(JSON.stringify(state)); await client.shutdown(); await client.shutdown(); - await new Promise((r) => setTimeout(r, 10)); + await settle(10); expect(hooks.shutdown).toHaveBeenCalledTimes(1); }); + it('refuses to resolve credentials for a session without an API key', async () => { + const { client, hooks } = await serve('tui', Program.PostHogIntegration, { + apiKey: null, + }); + await expect(client.setCredentials()).rejects.toMatchObject({ + status: 400, + }); + expect(hooks.setCredentials).not.toHaveBeenCalled(); + }); + it('surfaces a hook failure as 500 with its message', async () => { const { client, hooks } = await serve('tui'); vi.mocked(hooks.setCredentials).mockRejectedValueOnce(new Error('no key')); diff --git a/src/store/control/__tests__/state.test.ts b/src/store/control/__tests__/state.test.ts index bc7a220fc..140f3d453 100644 --- a/src/store/control/__tests__/state.test.ts +++ b/src/store/control/__tests__/state.test.ts @@ -1,33 +1,25 @@ -import { describe, expect, it } from 'vitest'; -import { - buildSession, - OutroKind, - RunPhase, -} from '../../session/wizard-session.js'; -import { createTestStore } from '../../testing/index.js'; -import { setUI } from '../../ui/index.js'; -import { StoreUI } from '../../ui/store-ui.js'; +import { HostResolution } from '../../host-resolution.js'; +import { OutroKind, RunPhase } from '../../session/wizard-session.js'; +import { createControlledStore, expectNoSecrets } from '../../testing/index.js'; import { actionsFor, toActionView } from '../actions.js'; -import { CONTROL_SESSION_KEYS, projectState, redactContext } from '../state.js'; +import { + CONTROL_SESSION_KEYS, + isSecretKey, + projectState, + redactContext, +} from '../state.js'; -function storeFor(over: { apiKey?: string } = {}) { - const store = createTestStore(); - setUI(new StoreUI(store)); - store.session = buildSession({ - installDir: '/tmp/control-state', - ci: true, - ...over, - }); - return store; -} +const US = HostResolution.fromApiHost('https://us.posthog.com'); -describe('projectState', () => { +describe('the control state projection', () => { it('never carries a credential, an api key, a user, or a vaulted answer', () => { - const store = storeFor({ apiKey: 'phx_PERSONAL_SECRET' }); + const store = createControlledStore(undefined, { + apiKey: 'phx_PERSONAL_SECRET', + }); store.setCredentials({ accessToken: 'phx_ACCESS_SECRET', projectApiKey: 'phc_PROJECT_TOKEN', - host: {} as never, + host: US, projectId: 42, }); store.setApiUser({ @@ -42,17 +34,10 @@ describe('projectState', () => { ); const state = projectState(store); - const text = JSON.stringify(state); - for (const leak of [ - 'phx_', - 'phc_', - 'phs_', - 'secret:0b7c', + expectNoSecrets(JSON.stringify(state), [ 'someone@example.com', 'user-uuid', - ]) { - expect(text, leak).not.toContain(leak); - } + ]); expect(state.session.hasCredentials).toBe(true); expect(state.session.projectId).toBe(42); expect(state.session.frameworkContext).toEqual({ @@ -62,12 +47,33 @@ describe('projectState', () => { }); }); + it('projects exactly the listed session keys plus the two credential facts', () => { + const state = projectState(createControlledStore()); + expect(Object.keys(state.session).sort()).toEqual( + [...CONTROL_SESSION_KEYS, 'hasCredentials', 'projectId'].sort(), + ); + expect(Object.keys(state).sort()).toEqual( + [ + 'actions', + 'currentScreen', + 'eventPlan', + 'handoffText', + 'session', + 'setupQuestions', + 'statusMessages', + 'tasks', + 'version', + ].sort(), + ); + }); + it('mirrors the store: the listed session fields, the run atoms, the screen', () => { - const store = storeFor(); + const store = createControlledStore(); store.completeSetup(); store.setTasks([ { label: 'Install SDK', status: 'in_progress', done: false } as never, ]); + store.pushStatus('installing the SDK'); store.setEventPlan([{ name: 'signup', description: 'a user signed up' }]); store.setHandoffText('run this prompt'); store.setDashboardUrl('https://us.posthog.com/project/1/dashboard/2'); @@ -85,9 +91,13 @@ describe('projectState', () => { } expect(state.currentScreen).toBe(store.currentScreen); expect(state.version).toBe(store.getVersion()); - expect(state.tasks).toEqual(store.tasks); - expect(state.statusMessages).toEqual(store.statusMessages); - expect(state.eventPlan).toEqual(store.eventPlan); + expect(state.tasks).toEqual([ + { label: 'Install SDK', status: 'in_progress', done: false }, + ]); + expect(state.statusMessages).toEqual(['installing the SDK']); + expect(state.eventPlan).toEqual([ + { name: 'signup', description: 'a user signed up' }, + ]); expect(state.handoffText).toBe('run this prompt'); expect(state.actions).toEqual( actionsFor(store.flow, store.currentScreen).map(toActionView), @@ -95,30 +105,102 @@ describe('projectState', () => { expect(JSON.stringify(state)).not.toContain('"apply"'); }); - it('bumps the version on a commit and offers the screen actions', () => { - const store = storeFor(); - const before = projectState(store); - expect(before.currentScreen).toBe('intro'); - expect(before.actions.map((a) => a.id)).toEqual(['confirm_setup']); + it('offers the screen actions without their closures and with their params', () => { + const store = createControlledStore(); + expect(projectState(store).currentScreen).toBe('intro'); + expect(projectState(store).actions).toMatchObject([ + { id: 'confirm_setup', params: { share: 'boolean (optional)' } }, + ]); + expect(projectState(store).actions[0]).not.toHaveProperty('apply'); + const before = projectState(store).version; store.completeSetup(); const after = projectState(store); - expect(after.version).toBeGreaterThan(before.version); + expect(after.version).toBeGreaterThan(before); expect(after.session.setupConfirmed).toBe(true); }); - it('redacts by key and by secret ref only', () => { + it('lists only the setup questions the session has not answered', () => { + const store = createControlledStore(); + store.setFrameworkConfig( + 'nextjs' as never, + { + metadata: { + setup: { + questions: [ + { + key: 'router', + message: 'Which router?', + options: [{ label: 'App', value: 'app' }], + detect: () => Promise.resolve(null), + }, + { + key: 'styling', + message: 'Which styling?', + options: [{ label: 'CSS', value: 'css', hint: 'plain' }], + detect: () => Promise.resolve(null), + }, + ], + }, + }, + } as never, + ); + expect(projectState(store).setupQuestions.map((q) => q.key)).toEqual([ + 'router', + 'styling', + ]); + store.setFrameworkContext('router', 'app'); + expect(projectState(store).setupQuestions).toEqual([ + { + key: 'styling', + message: 'Which styling?', + options: [{ label: 'CSS', value: 'css', hint: 'plain' }], + }, + ]); + }); + + it('keeps only the allow-listed outro error detail', () => { + const store = createControlledStore(); + store.setOutroData({ + kind: OutroKind.Error, + message: 'boom', + errorDetail: { + reason: 'no manifest', + response: { headers: { authorization: 'Bearer phx_LEAK' } }, + }, + }); + expect(projectState(store).session.outroData).toEqual({ + kind: OutroKind.Error, + message: 'boom', + errorDetail: { reason: 'no manifest' }, + }); + }); + + it('redacts secret-named keys and secret refs, and nothing else', () => { expect( redactContext({ a: 1, token: 'x', + apiKey: 'y', + ACCESS_TOKEN: 'z', nested: { k: 'v' }, ref: 'secret:abcdef0123456789', + short: 'secret:abc', + monkey: 'business', + keyboard: 'qwerty', }), ).toEqual({ a: 1, token: '[redacted]', + apiKey: '[redacted]', + ACCESS_TOKEN: '[redacted]', nested: { k: 'v' }, ref: '[secret-ref]', + short: 'secret:abc', + monkey: 'business', + keyboard: 'qwerty', }); + expect(isSecretKey('upload-api-key')).toBe(true); + expect(isSecretKey('projectApiKey')).toBe(true); + expect(isSecretKey('hotkeys')).toBe(false); }); }); diff --git a/src/store/control/actions.ts b/src/store/control/actions.ts index 9c10303dd..3a2851f0c 100644 --- a/src/store/control/actions.ts +++ b/src/store/control/actions.ts @@ -1,15 +1,20 @@ -/** - * The commits a controlling parent may make, keyed by flow key or interrupt. - * Generic entries call one store setter each and know no program; a program - * adds its own through `FlowStep.controlActions`, merged per flow here. - */ -import { McpOutcome, type AskAnswers } from '../session/wizard-session.js'; +/** The commits a parent may make, keyed by flow key or interrupt; a program adds its own through `FlowStep.controlActions`. */ +import { + McpOutcome, + ScanConsent, + type AskAnswers, +} from '../session/wizard-session.js'; import type { Flow } from '../state/flow.js'; +import { FLOW_KEY } from '../state/flow-resolution.js'; import { Interrupt } from '../state/interrupts.js'; -import { requireString } from './params.js'; -import type { DriverAction } from './types.js'; - -export { BadParamError, MissingParamError } from './params.js'; +import { + optionalBoolean, + optionalOneOf, + optionalStringArray, + requireRecord, + requireString, +} from './params.js'; +import type { ActionView, DriverAction } from './types.js'; /** Thrown when an action is not legal on the current screen. Maps to 400. */ export class UnknownActionError extends Error { @@ -22,15 +27,12 @@ export class UnknownActionError extends Error { } } -/** - * Screens with no commit: the runner or the agent advances them, or they are - * terminal. Listed so the exhaustiveness test tells "empty" from "forgotten". - */ +/** Screens with no commit: the runner or the agent advances them, or they are terminal. */ export const NO_ACTION_SCREENS: ReadonlySet = new Set([ - 'auth', - 'run', + FLOW_KEY.Auth, + FLOW_KEY.Run, 'ai-opt-in', - 'exit', + FLOW_KEY.Exit, 'audit-run', 'doctor-report', Interrupt.ManagedSettings, @@ -44,6 +46,30 @@ const confirmSetup: DriverAction = { apply: (store) => store.completeSetup(), }; +/** The default intro also decides scan sharing; Enter grants when undecided, as its key handler does. */ +const confirmSetupWithSharing: DriverAction = { + id: 'confirm_setup', + description: + 'Confirm the intro and continue. share: true grants and false declines ' + + 'sharing scan results; absent keeps the toggle (granted when undecided).', + params: { share: 'boolean (optional)' }, + apply: (store, params) => { + const share = + params.share === undefined + ? undefined + : optionalBoolean('confirm_setup', params, 'share', true); + if (share === false) { + store.declineSharing(); + } else if ( + share === true || + store.session.scanConsent === ScanConsent.Undecided + ) { + store.grantSharing(); + } + store.completeSetup(); + }, +}; + const dismissOutro: DriverAction = { id: 'dismiss_outro', description: 'Dismiss the outro (sets outroDismissed).', @@ -54,17 +80,21 @@ const setMcpOutcome = (description: string): DriverAction => ({ id: 'set_mcp_outcome', description, params: { - outcome: '"installed" | "skipped"', + outcome: '"installed" | "skipped" (default skipped)', clients: 'string[] (optional)', }, apply: (store, params) => { - const raw = (params.outcome as string) ?? 'skipped'; - const outcome = - raw === 'installed' ? McpOutcome.Installed : McpOutcome.Skipped; - const clients = Array.isArray(params.clients) - ? (params.clients as string[]) - : []; - store.setMcpComplete(outcome, clients); + const outcome = optionalOneOf( + 'set_mcp_outcome', + params, + 'outcome', + ['installed', 'skipped'] as const, + 'skipped', + ); + store.setMcpComplete( + outcome === 'installed' ? McpOutcome.Installed : McpOutcome.Skipped, + optionalStringArray('set_mcp_outcome', params, 'clients'), + ); }, }); @@ -92,10 +122,10 @@ export const GENERIC_ACTIONS: Readonly< }, }, ], - outro: [dismissOutro], + [FLOW_KEY.Outro]: [dismissOutro], 'audit-outro': [dismissOutro], 'source-maps-outro': [dismissOutro], - 'mint-failure': [ + [FLOW_KEY.MintFailure]: [ { id: 'continue_setup', description: 'Continue to MCP and Slack after the skill is saved.', @@ -107,7 +137,7 @@ export const GENERIC_ACTIONS: Readonly< apply: (store) => store.setMintHandoff('exit'), }, ], - mcp: [ + [FLOW_KEY.Mcp]: [ setMcpOutcome( 'Complete the MCP step. outcome is installed or skipped; clients optional.', ), @@ -121,7 +151,7 @@ export const GENERIC_ACTIONS: Readonly< apply: (store) => store.setMcpSuggestedPromptsDismissed(), }, ], - 'slack-connect': [ + [FLOW_KEY.SlackConnect]: [ { id: 'dismiss_slack', description: 'Skip or finish the Connect-Slack step.', @@ -130,18 +160,23 @@ export const GENERIC_ACTIONS: Readonly< { id: 'set_slack_connected', description: 'Mark Slack as connected (then dismiss to advance).', - params: { connected: 'boolean' }, + params: { connected: 'boolean (default true)' }, apply: (store, params) => - store.setSlackConnected(params.connected !== false), + store.setSlackConnected( + optionalBoolean('set_slack_connected', params, 'connected', true), + ), }, ], - 'keep-skills': [ + [FLOW_KEY.KeepSkills]: [ { id: 'keep_skills', description: 'Decide whether to keep installed skills; completes the run.', params: { kept: 'boolean (default true)' }, - apply: (store, params) => store.setSkillsComplete(params.kept !== false), + apply: (store, params) => + store.setSkillsComplete( + optionalBoolean('keep_skills', params, 'kept', true), + ), }, ], [Interrupt.WizardAsk]: [ @@ -152,7 +187,9 @@ export const GENERIC_ACTIONS: Readonly< 'map: { [questionId]: string | string[] }. See state.session.pendingQuestion.', params: { answers: 'Record' }, apply: (store, params) => - store.resolvePendingQuestion((params.answers ?? {}) as AskAnswers), + store.resolvePendingQuestion( + requireRecord('answer_question', params, 'answers') as AskAnswers, + ), }, { id: 'cancel_question', @@ -167,7 +204,10 @@ export const GENERIC_ACTIONS: Readonly< 'Resolve the task-notice overlay a program shows before an optional ' + 'step. keep=true runs the step, keep=false skips it. See state.session.taskNotice.', params: { keep: 'boolean (default true)' }, - apply: (store, params) => store.resolveTaskNotice(params.keep !== false), + apply: (store, params) => + store.resolveTaskNotice( + optionalBoolean('resolve_notice', params, 'keep', true), + ), }, ], [Interrupt.SettingsOverride]: [ @@ -210,21 +250,22 @@ function isIntro(screen: string): boolean { return screen === 'intro' || screen.endsWith('-intro'); } +function genericActionsFor(screen: string): readonly DriverAction[] { + if (screen === 'intro') return [confirmSetupWithSharing]; + if (isIntro(screen)) return [confirmSetup]; + return GENERIC_ACTIONS[screen] ?? []; +} + /** Actions legal on `screen` in `flow`: the flow's own first, then generic. */ export function actionsFor(flow: Flow, screen: string): DriverAction[] { const own = flow.steps .filter((step) => step.screenId === screen) .flatMap((step) => step.controlActions ?? []); - const generic = isIntro(screen) - ? [confirmSetup] - : GENERIC_ACTIONS[screen] ?? []; const seen = new Set(own.map((a) => a.id)); - return [...own, ...generic.filter((a) => !seen.has(a.id))]; + return [...own, ...genericActionsFor(screen).filter((a) => !seen.has(a.id))]; } -export function toActionView( - action: DriverAction, -): Omit { +export function toActionView(action: DriverAction): ActionView { return { id: action.id, description: action.description, diff --git a/src/store/control/client.ts b/src/store/control/client.ts index 4f191c988..d1628b669 100644 --- a/src/store/control/client.ts +++ b/src/store/control/client.ts @@ -2,6 +2,7 @@ import * as http from 'node:http'; import type { ControlState, DetectRequest, + HealthResponse, RunRecord, RunRequest, } from './types.js'; @@ -13,14 +14,6 @@ export class ControlClientError extends Error { } } -interface HealthResponse { - ok: true; - version: string; - surface: string; - pid: number; - program: string; -} - /** The parent's side of the control API: one unix socket, JSON in and out. */ export class ControlClient { constructor(private readonly socketPath: string) {} diff --git a/src/store/control/driver.ts b/src/store/control/driver.ts index 89cc69ddc..689f87fbc 100644 --- a/src/store/control/driver.ts +++ b/src/store/control/driver.ts @@ -3,11 +3,7 @@ import { actionsFor, UnknownActionError } from './actions.js'; import { projectState } from './state.js'; import type { ControlState } from './types.js'; -/** - * Read and act on one store. Reads the committed state; acts through the exact - * setter the screen's keyboard handler would call. In-progress keystroke state - * is React-local and invisible here by design. - */ +/** Reads the committed store and acts through the setter the screen's key handler would call. */ export class ControlDriver { constructor(private readonly store: WizardStore) {} @@ -29,14 +25,15 @@ export class ControlDriver { return this.readState(); } - /** - * Resolve on the first commit with `version > since`, or after `timeoutMs` - * with whatever is current. Every commit bumps the version, including the - * agent's `getUI()` calls, so a parent can block instead of poll. - */ - waitForVersion(since: number, timeoutMs: number): Promise { - if (this.store.getVersion() > since) + /** Resolve on the first commit past `since`, on `timeoutMs`, or when `signal` aborts. */ + waitForVersion( + since: number, + timeoutMs: number, + signal?: AbortSignal, + ): Promise { + if (this.store.getVersion() > since || signal?.aborted) { return Promise.resolve(this.readState()); + } return new Promise((resolve) => { let settled = false; const finish = (): void => { @@ -44,12 +41,14 @@ export class ControlDriver { settled = true; clearTimeout(timer); unsub(); + signal?.removeEventListener('abort', finish); resolve(this.readState()); }; const timer = setTimeout(finish, timeoutMs); const unsub = this.store.subscribe(() => { if (this.store.getVersion() > since) finish(); }); + signal?.addEventListener('abort', finish, { once: true }); }); } } diff --git a/src/store/control/index.ts b/src/store/control/index.ts index 772b033cd..4ba6065aa 100644 --- a/src/store/control/index.ts +++ b/src/store/control/index.ts @@ -1,18 +1,13 @@ -/** - * The control API: an HTTP/1.1 server over a unix socket that reads and acts - * on one store. Import only dynamically, from the cli runners, so a published - * TUI never loads it. - */ -export { attachControlServer } from './server.js'; +/** The control API over a unix socket; shipped code imports it dynamically from the cli runners only. */ +export { attachControlServer, ROUTES } from './server.js'; export { ControlClient, ControlClientError } from './client.js'; export { ControlDriver } from './driver.js'; export { actionsFor, GENERIC_ACTIONS, NO_ACTION_SCREENS, - MissingParamError, - BadParamError, UnknownActionError, } from './actions.js'; +export { BadParamError, MissingParamError } from './params.js'; export { CONTROL_SESSION_KEYS } from './state.js'; export { CONTROL_SERVER_MARKER } from './marker.js'; diff --git a/src/store/control/params.ts b/src/store/control/params.ts index a8c133c24..1431f6dc9 100644 --- a/src/store/control/params.ts +++ b/src/store/control/params.ts @@ -1,27 +1,113 @@ /** Thrown when an action lacks a required param. Maps to 400. */ export class MissingParamError extends Error { - constructor(action: string, param: string) { - super(`Action "${action}" requires param "${param}".`); + constructor(subject: string, param: string) { + super(`"${subject}" requires param "${param}".`); this.name = 'MissingParamError'; } } /** Thrown when a param is present but unusable. Maps to 400. */ export class BadParamError extends Error { - constructor(action: string, param: string, detail: string) { - super(`Action "${action}" param "${param}": ${detail}`); + constructor(subject: string, param: string, detail: string) { + super(`"${subject}" param "${param}": ${detail}`); this.name = 'BadParamError'; } } +type Params = Record; + +export function isRecord(value: unknown): value is Params { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + export function requireString( - action: string, - params: Record, + subject: string, + params: Params, key: string, ): string { const v = params[key]; if (typeof v !== 'string' || v.length === 0) { - throw new MissingParamError(action, key); + throw new MissingParamError(subject, key); + } + return v; +} + +export function optionalString( + subject: string, + params: Params, + key: string, +): string | undefined { + const v = params[key]; + if (v === undefined) return undefined; + if (typeof v !== 'string' || v.length === 0) { + throw new BadParamError(subject, key, 'expected a non-empty string'); + } + return v; +} + +export function optionalBoolean( + subject: string, + params: Params, + key: string, + fallback: boolean, +): boolean { + const v = params[key]; + if (v === undefined) return fallback; + if (typeof v !== 'boolean') { + throw new BadParamError(subject, key, 'expected a boolean'); + } + return v; +} + +export function optionalOneOf( + subject: string, + params: Params, + key: string, + allowed: readonly T[], + fallback: T, +): T { + const v = params[key]; + if (v === undefined) return fallback; + if (typeof v !== 'string' || !(allowed as readonly string[]).includes(v)) { + throw new BadParamError( + subject, + key, + `expected one of ${allowed.join(', ')}`, + ); + } + return v as T; +} + +export function optionalStringArray( + subject: string, + params: Params, + key: string, +): string[] { + const v = params[key]; + if (v === undefined) return []; + if (!Array.isArray(v) || v.some((item) => typeof item !== 'string')) { + throw new BadParamError(subject, key, 'expected an array of strings'); } + return v as string[]; +} + +export function requireRecord( + subject: string, + params: Params, + key: string, +): Params { + const v = params[key]; + if (!isRecord(v)) throw new MissingParamError(subject, key); + return v; +} + +export function optionalRecord( + subject: string, + params: Params, + key: string, +): Params | undefined { + const v = params[key]; + if (v === undefined) return undefined; + if (!isRecord(v)) throw new BadParamError(subject, key, 'expected an object'); return v; } diff --git a/src/store/control/runs.ts b/src/store/control/runs.ts index aa51232a2..2ed1944b4 100644 --- a/src/store/control/runs.ts +++ b/src/store/control/runs.ts @@ -2,10 +2,12 @@ import { randomUUID } from 'node:crypto'; import type { ProgramId } from '../programs/program-registry.js'; import type { ControlState, RunRecord } from './types.js'; -/** Thrown when a run is requested while one is in flight. Maps to 409. */ +/** Thrown when a route needs an idle store while a run is in flight. Maps to 409. */ export class RunInFlightError extends Error { - constructor(runId: string) { - super(`A run is already in flight: ${runId}`); + constructor(runId?: string) { + super( + runId ? `A run is already in flight: ${runId}` : 'A run is in flight', + ); this.name = 'RunInFlightError'; } } diff --git a/src/store/control/server.ts b/src/store/control/server.ts index 09167515f..690dc9f45 100644 --- a/src/store/control/server.ts +++ b/src/store/control/server.ts @@ -5,28 +5,46 @@ import { PROGRAM_REGISTRY, type ProgramId, } from '../programs/program-registry.js'; +import { RunPhase } from '../session/wizard-session.js'; import { logToFile } from '../shared/debug.js'; +import { resolveInstallDir } from '../shared/paths.js'; import type { WizardStore } from '../state/store.js'; +import { UnknownActionError } from './actions.js'; +import { ControlDriver } from './driver.js'; +import { CONTROL_SERVER_MARKER } from './marker.js'; import { BadParamError, MissingParamError, - UnknownActionError, -} from './actions.js'; -import { ControlDriver } from './driver.js'; -import { CONTROL_SERVER_MARKER } from './marker.js'; + optionalRecord, + optionalString, +} from './params.js'; import { RunInFlightError, RunLedger } from './runs.js'; import type { ControlHooks, ControlState, ControlSurface, DetectRequest, + HealthResponse, RunRequest, } from './types.js'; export const MAX_BODY_BYTES = 64 * 1024; const PROBE_TIMEOUT_MS = 200; /** Long polls cap here so a stuck parent never pins a connection forever. */ -const MAX_WAIT_MS = 10 * 60 * 1000; +const MAX_WAIT_MS = 600_000; + +/** Every route the server answers; the docs table is checked against it. */ +export const ROUTES = [ + 'GET /health', + 'GET /state', + 'GET /runs', + 'POST /actions/:id', + 'POST /credentials', + 'POST /run', + 'POST /detect', + 'POST /runs', + 'POST /shutdown', +] as const; export interface ControlServerOptions { socketPath: string; @@ -63,24 +81,30 @@ function statusFor(err: unknown): number { err instanceof UnknownActionError || err instanceof MissingParamError || err instanceof BadParamError - ) + ) { return 400; + } if (err instanceof RunInFlightError) return 409; if (err instanceof SurfaceUnavailableError) return 501; return 500; } function requireProgram(programId: unknown): ProgramId { - if (typeof programId !== 'string' || !programId) + if (typeof programId !== 'string' || !programId) { throw new HttpError(400, 'programId is required'); - if (!PROGRAM_REGISTRY.some((c) => c.id === programId)) + } + if (!PROGRAM_REGISTRY.some((c) => c.id === programId)) { throw new HttpError(400, `unknown program "${programId}"`); + } return programId; } -/** Refuse a live socket; unlink a stale file. */ +/** Refuse a live socket or a non-socket path; unlink a stale socket. */ async function claimSocketPath(socketPath: string): Promise { if (!fs.existsSync(socketPath)) return; + if (!fs.lstatSync(socketPath).isSocket()) { + throw new Error(`control socket path is not a socket: ${socketPath}`); + } const live = await new Promise((resolve) => { const probe = net.connect(socketPath); const done = (value: boolean): void => { @@ -108,9 +132,12 @@ function readBody(req: http.IncomingMessage): Promise> { req.on('data', (chunk: Buffer) => { size += chunk.length; if (size > MAX_BODY_BYTES) { + // Stop reading but keep the connection: the 413 still has to go out. + req.pause(); reject(new HttpError(413, `body over ${MAX_BODY_BYTES} bytes`)); - req.destroy(); - } else chunks.push(chunk); + } else { + chunks.push(chunk); + } }); req.on('end', () => { const text = Buffer.concat(chunks).toString('utf8').trim(); @@ -121,8 +148,9 @@ function readBody(req: http.IncomingMessage): Promise> { parsed === null || typeof parsed !== 'object' || Array.isArray(parsed) - ) + ) { throw new Error('not an object'); + } resolve(parsed as Record); } catch { reject(new HttpError(400, 'body is not a JSON object')); @@ -141,11 +169,17 @@ function send(res: http.ServerResponse, status: number, body: unknown): void { res.end(text); } -/** - * Serve the control API for one store over a unix socket. HTTP/1.1, JSON in - * and out. The store never learns who is listening; the hooks do the work the - * composition root owns. - */ +function actionId(pathname: string): string | null { + const match = /^\/actions\/([^/]+)$/.exec(pathname); + if (!match) return null; + try { + return decodeURIComponent(match[1]); + } catch { + throw new HttpError(400, 'action id is not valid percent-encoding'); + } +} + +/** Serve the control API for one store over a unix socket: HTTP/1.1, JSON in and out. */ export async function attachControlServer( store: WizardStore, options: ControlServerOptions, @@ -153,40 +187,53 @@ export async function attachControlServer( const { socketPath, surface, hooks } = options; const ledger = new RunLedger(); const driver = new ControlDriver(store); + const polls = new AbortController(); let shuttingDown = false; const requireSurface = (route: string, wanted: ControlSurface): void => { if (surface !== wanted) throw new SurfaceUnavailableError(route, surface); }; + /** Routes that rewrite the session or end the process wait for the run to end. */ + const requireIdle = (): void => { + const active = ledger.active; + if (active) throw new RunInFlightError(active.runId); + if (store.session.runPhase === RunPhase.Running) { + throw new RunInFlightError(); + } + }; + const startRun = (body: Record) => { + const route = 'POST /runs'; + const frameworkContext = optionalRecord(route, body, 'frameworkContext'); + const skillId = optionalString(route, body, 'skillId'); + const installDir = resolveInstallDir( + store.session.installDir, + optionalString(route, body, 'installDir'), + ); const req: RunRequest = { programId: requireProgram(body.programId), - ...(typeof body.installDir === 'string' - ? { installDir: body.installDir } - : {}), - ...(body.frameworkContext && - typeof body.frameworkContext === 'object' && - !Array.isArray(body.frameworkContext) - ? { frameworkContext: body.frameworkContext as Record } - : {}), - ...(typeof body.skillId === 'string' ? { skillId: body.skillId } : {}), + installDir, + ...(frameworkContext ? { frameworkContext } : {}), + ...(skillId ? { skillId } : {}), }; - const record = ledger.start( - req.programId, - req.installDir ?? store.session.installDir, - ); + const record = ledger.start(req.programId, installDir); // The state keeps this run's outcome until the next run starts; the hook // resets it then, so a poller reading after completion sees the result. - void hooks.startRun(req).then( - () => ledger.finish(record.runId, driver.readState()), - (err: unknown) => - ledger.fail( - record.runId, - err instanceof Error ? err.message : String(err), - driver.readState(), - ), - ); + Promise.resolve() + .then(() => hooks.startRun(req)) + .then( + () => ledger.finish(record.runId, driver.readState()), + (err: unknown) => + ledger.fail( + record.runId, + err instanceof Error ? err.message : String(err), + driver.readState(), + ), + ) + .catch((err: unknown) => + logToFile('[control] ledger update failed:', err), + ); return { ...record }; }; @@ -199,13 +246,14 @@ export async function attachControlServer( const route = `${method} ${url.pathname}`; if (route === 'GET /health') { - return send(res, 200, { + const health: HealthResponse = { ok: true, version: options.version, surface, pid: process.pid, program: options.program, - }); + }; + return send(res, 200, health); } if (route === 'GET /state') { const wait = Number(url.searchParams.get('wait') ?? ''); @@ -213,56 +261,77 @@ export async function attachControlServer( let state: ControlState; if (Number.isFinite(wait) && wait > 0) { const from = Number.isFinite(since) ? since : store.getVersion(); - state = await driver.waitForVersion(from, Math.min(wait, MAX_WAIT_MS)); - } else state = driver.readState(); + state = await driver.waitForVersion( + from, + Math.min(wait, MAX_WAIT_MS), + polls.signal, + ); + } else { + state = driver.readState(); + } return send(res, 200, { ok: true, state }); } - if (route === 'GET /runs') + if (route === 'GET /runs') { return send(res, 200, { ok: true, runs: ledger.list() }); + } if (method !== 'POST') throw new HttpError(404, `no route ${route}`); const body = await readBody(req); - const actionMatch = /^\/actions\/([^/]+)$/.exec(url.pathname); - if (actionMatch) { - const params = - body.params && typeof body.params === 'object' - ? (body.params as Record) - : {}; - const state = driver.performAction( - decodeURIComponent(actionMatch[1]), - params, - ); - return send(res, 200, { ok: true, state }); + const action = actionId(url.pathname); + if (action !== null) { + const params = optionalRecord(route, body, 'params') ?? {}; + return send(res, 200, { + ok: true, + state: driver.performAction(action, params), + }); } switch (url.pathname) { case '/credentials': + requireIdle(); + if (!store.session.apiKey) { + throw new HttpError(400, 'this session has no API key to resolve'); + } await hooks.setCredentials(); return send(res, 200, { ok: true, state: driver.readState() }); case '/run': - requireSurface('POST /run', 'tui'); + requireSurface(route, 'tui'); store.requestRun(); return send(res, 200, { ok: true, state: driver.readState() }); case '/detect': { - requireSurface('POST /detect', 'headless'); + requireSurface(route, 'headless'); + requireIdle(); + const installDir = optionalString(route, body, 'installDir'); const detect: DetectRequest = { ...(body.programId !== undefined ? { programId: requireProgram(body.programId) } : {}), - ...(typeof body.installDir === 'string' - ? { installDir: body.installDir } + ...(installDir + ? { + installDir: resolveInstallDir( + store.session.installDir, + installDir, + ), + } : {}), }; await hooks.detect(detect); return send(res, 200, { ok: true, state: driver.readState() }); } case '/runs': - requireSurface('POST /runs', 'headless'); + requireSurface(route, 'headless'); return send(res, 200, { ok: true, run: startRun(body) }); case '/shutdown': + requireIdle(); send(res, 200, { ok: true }); if (!shuttingDown) { shuttingDown = true; - res.once('finish', () => void hooks.shutdown()); + res.once('finish', () => { + hooks + .shutdown() + .catch((err: unknown) => + logToFile('[control] shutdown hook failed:', err), + ); + }); } return; default: @@ -274,25 +343,40 @@ export async function attachControlServer( handle(req, res).catch((err: unknown) => { const status = statusFor(err); const message = err instanceof Error ? err.message : String(err); - if (status === 500) - logToFile(`[${CONTROL_SERVER_MARKER}] ${route(req)} failed:`, err); - if (!res.headersSent) send(res, status, { ok: false, error: message }); - else res.end(); + if (status === 500) { + logToFile(`[control] ${describeRequest(req)} failed:`, err); + } + if (res.headersSent) { + res.end(); + return; + } + if (status === 413) { + // The unread body would stall this connection; close it after the reply. + res.setHeader('connection', 'close'); + res.once('finish', () => req.destroy()); + } + send(res, status, { ok: false, error: message }); }); }); server.keepAliveTimeout = 1000; await claimSocketPath(socketPath); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(socketPath, () => { - server.off('error', reject); - resolve(); + // Listen with a tight umask so the socket never exists with wider permissions. + const previousUmask = process.umask(0o077); + try { + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(socketPath, () => { + server.off('error', reject); + resolve(); + }); }); - }); + } finally { + process.umask(previousUmask); + } fs.chmodSync(socketPath, 0o600); logToFile( - `[${CONTROL_SERVER_MARKER}] listening on ${socketPath} (${surface})`, + `[control] listening on ${socketPath} (${surface}) ${CONTROL_SERVER_MARKER}`, ); let closed = false; @@ -311,20 +395,23 @@ export async function attachControlServer( return { socketPath, ledger, - close: () => - new Promise((resolve) => { - if (closed) return resolve(); - closed = true; - process.off('exit', onExit); - server.closeAllConnections(); - server.close(() => { - unlink(); - resolve(); - }); - }), + close: async () => { + if (closed) return; + closed = true; + process.off('exit', onExit); + unlink(); + // Aborted long polls answer first; idle keep-alive connections are swept until none remain. + polls.abort(); + await new Promise((resolve) => setImmediate(resolve)); + const sweep = setInterval(() => server.closeIdleConnections(), 10); + const forced = setTimeout(() => server.closeAllConnections(), 1000); + await new Promise((resolve) => server.close(() => resolve())); + clearInterval(sweep); + clearTimeout(forced); + }, }; } -function route(req: http.IncomingMessage): string { +function describeRequest(req: http.IncomingMessage): string { return `${req.method ?? 'GET'} ${req.url ?? '/'}`; } diff --git a/src/store/control/state.ts b/src/store/control/state.ts index 876adc08a..2daf143d3 100644 --- a/src/store/control/state.ts +++ b/src/store/control/state.ts @@ -1,4 +1,5 @@ import type { WizardSession } from '../session/wizard-session.js'; +import { sanitizeErrorDetail } from '../shared/errors/sanitize.js'; import type { WizardStore } from '../state/store.js'; import { actionsFor, toActionView } from './actions.js'; import type { ControlSession, ControlState } from './types.js'; @@ -28,19 +29,42 @@ export const CONTROL_SESSION_KEYS = [ 'skillsComplete', ] as const satisfies readonly (keyof WizardSession)[]; +const SECRET_WORDS = new Set([ + 'key', + 'keys', + 'token', + 'tokens', + 'secret', + 'secrets', + 'password', + 'passwords', + 'credential', + 'credentials', +]); const SECRET_REF = /^secret:[0-9a-f-]{16,}$/i; -const SECRET_KEY = /(key|token|secret|password|credential)/i; -/** Values a driver may read; secret refs and secret-looking keys never leave. */ +/** `upload-api-key`, `accessToken`, and `ACCESS_TOKEN` name a secret; `monkey` does not. */ +export function isSecretKey(name: string): boolean { + return name + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .toLowerCase() + .split(/[^a-z0-9]+/) + .some((word) => SECRET_WORDS.has(word)); +} + +/** Values a driver may read; secret refs and secret-named keys never leave. */ export function redactContext( ctx: Record, ): Record { const out: Record = {}; for (const [key, value] of Object.entries(ctx)) { - if (SECRET_KEY.test(key)) out[key] = '[redacted]'; - else if (typeof value === 'string' && SECRET_REF.test(value)) + if (isSecretKey(key)) { + out[key] = '[redacted]'; + } else if (typeof value === 'string' && SECRET_REF.test(value)) { out[key] = '[secret-ref]'; - else out[key] = value; + } else { + out[key] = value; + } } return out; } @@ -52,6 +76,14 @@ function projectSession(s: WizardSession): ControlSession { return { ...picked, frameworkContext: redactContext(s.frameworkContext), + outroData: s.outroData + ? { + ...s.outroData, + ...(s.outroData.errorDetail + ? { errorDetail: sanitizeErrorDetail(s.outroData.errorDetail) } + : {}), + } + : null, hasCredentials: s.credentials !== null, projectId: s.credentials?.projectId ?? null, }; diff --git a/src/store/control/types.ts b/src/store/control/types.ts index 56cf071e8..edee5e0fa 100644 --- a/src/store/control/types.ts +++ b/src/store/control/types.ts @@ -15,6 +15,9 @@ export interface DriverAction { apply: (store: WizardStore, params: Record) => void; } +/** An action as the wire carries it: no closure. */ +export type ActionView = Omit; + /** The session as a parent reads it: the listed fields, credentials as a flag. */ export type ControlSession = Pick< WizardSession, @@ -24,11 +27,7 @@ export type ControlSession = Pick< projectId: number | null; }; -/** - * The store as a parent reads it: the committed session whitelist, the run - * atoms, and what the flow derives for the current screen. No access token, - * API key, user record, or answer value is ever projected. - */ +/** The store as a parent reads it; no token, key, user record, or answer value is ever projected. */ export interface ControlState { version: number; currentScreen: string; @@ -40,7 +39,7 @@ export interface ControlState { /** Setup questions the session has not answered yet. */ setupQuestions: Array>; /** The commits legal on `currentScreen`. */ - actions: Array>; + actions: ActionView[]; } export type DetectRequest = { programId?: ProgramId } & Partial< @@ -66,6 +65,14 @@ export interface RunRecord { export type ControlSurface = 'tui' | 'headless'; +export interface HealthResponse { + ok: true; + version: string; + surface: ControlSurface; + pid: number; + program: string; +} + /** What the composition root does on the parent's behalf; the store never runs agents. */ export interface ControlHooks { /** Resolve credentials host side and commit them, advancing `auth`. */ diff --git a/src/store/index.ts b/src/store/index.ts index d06771cd6..1b716db11 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -210,6 +210,7 @@ export { classifyRunFailure } from './shared/errors/run-failure.js'; export { skillErrorCode } from './shared/errors/skill-map.js'; export { headlessOption, + isControlledTui, isHeadless, regionOption, } from './shared/headless-mode.js'; @@ -219,6 +220,7 @@ export { WIZARD_BENCHMARK_FILE, WIZARD_LOG_FILE, relativeToInstallDir, + resolveInstallDir, } from './shared/paths.js'; export { provisionNewAccount, requestDeepLink } from './shared/provisioning.js'; export { getOrAskForProjectData } from './shared/setup-utils.js'; diff --git a/src/store/shared/environment.ts b/src/store/shared/environment.ts index 8827e46fb..422c9d04d 100644 --- a/src/store/shared/environment.ts +++ b/src/store/shared/environment.ts @@ -22,13 +22,12 @@ export function isNonInteractiveEnvironment(): boolean { } /** - * Session fields the environment must never set, matched case-insensitively - * against the camel-cased key `read-env` produces. - * - * `e2eAsk` re-wires the `wizard_ask` bridge in an otherwise non-interactive - * run. Only the e2e TUI host may set it: a real `--ci` run has nobody to answer, - * so every question would stall for the bridge timeout instead of failing fast - * with an actionable error. See `shouldDisableAsk`. + * Session fields the raw environment spread must never set, matched + * case-insensitively against the camel-cased key `read-env` produces. They + * arrive through their flags instead (yargs also reads those from + * `POSTHOG_WIZARD_*` in dev builds). `e2eAsk` re-wires the `wizard_ask` bridge + * in an otherwise non-interactive run; a real `--ci` run has nobody to answer, + * so every question would stall instead of failing fast. See `shouldDisableAsk`. */ const NEVER_FROM_ENV = ['e2eAsk', 'controlSocket', 'runRequested']; diff --git a/src/store/shared/headless-mode.ts b/src/store/shared/headless-mode.ts index 4925e83c1..ac4c0fb26 100644 --- a/src/store/shared/headless-mode.ts +++ b/src/store/shared/headless-mode.ts @@ -52,6 +52,11 @@ export function isHeadless(options: Record): boolean { return options[HEADLESS_FLAG] === true; } +/** `--ci` with a control socket: the real TUI, API-key auth, a parent driving it. */ +export function isControlledTui(options: Record): boolean { + return options.ci === true && Boolean(options.controlSocket); +} + // `--region` only means something non-interactively (API-key auth has no OAuth // token response to read `posthog_region` from), so only headless-capable // commands declare it. diff --git a/src/store/shared/paths.ts b/src/store/shared/paths.ts index 76f0bbb5a..356b50b98 100644 --- a/src/store/shared/paths.ts +++ b/src/store/shared/paths.ts @@ -1,5 +1,5 @@ import { tmpdir } from 'node:os'; -import { join, sep } from 'node:path'; +import { isAbsolute, join, sep } from 'node:path'; // /tmp is stable and discoverable on macOS/Linux; Windows needs os.tmpdir() const TMP = process.platform === 'win32' ? tmpdir() : '/tmp'; @@ -25,3 +25,12 @@ export function relativeToInstallDir(file: string, installDir: string): string { const prefix = installDir.endsWith(sep) ? installDir : installDir + sep; return file.startsWith(prefix) ? file.slice(prefix.length) : file; } + +/** A sub-app path stays relative to the live install dir; an absolute path wins. */ +export function resolveInstallDir( + live: string, + requested: string | undefined, +): string { + if (!requested) return live; + return isAbsolute(requested) ? requested : join(live, requested); +} diff --git a/src/store/state/__tests__/reset-run-state.test.ts b/src/store/state/__tests__/reset-run-state.test.ts new file mode 100644 index 000000000..a5d91e92b --- /dev/null +++ b/src/store/state/__tests__/reset-run-state.test.ts @@ -0,0 +1,65 @@ +import { HostResolution } from '../../host-resolution.js'; +import { OutroKind, RunPhase } from '../../session/wizard-session.js'; +import { createControlledStore } from '../../testing/index.js'; + +describe('resetRunState', () => { + it('clears exactly what one run produced and keeps what the next run needs', async () => { + const store = createControlledStore(); + store.setCredentials({ + accessToken: 't', + projectApiKey: 'k', + host: HostResolution.fromApiHost('https://us.posthog.com'), + projectId: 3, + }); + store.setFrameworkContext('router', 'app'); + store.completeRunStep('earlier'); + store.setTasks([ + { label: 'Install', status: 'completed', done: true } as never, + ]); + store.pushStatus('installing'); + store.setEventPlan([{ name: 'signup', description: 'd' }]); + store.setHandoffText('prompt'); + store.setCurrentStage('verify' as never); + store.setRunPhase(RunPhase.Completed); + store.setOutroData({ kind: OutroKind.Success, message: 'ok' }); + store.setOutroDismissed(); + store.setDashboardUrl('https://us.posthog.com/d/1'); + const question = store.requestQuestion({ + id: 'q', + source: 'test', + questions: [{ id: 'a', prompt: 'A?', kind: 'text' }], + } as never); + const notice = store.showTaskNotice({ + title: 't', + body: [], + confirmLabel: 'y', + cancelLabel: 'n', + prompt: 'p', + }); + + const version = store.getVersion(); + store.resetRunState(); + + expect(store.tasks).toEqual([]); + expect(store.statusMessages).toEqual([]); + expect(store.eventPlan).toEqual([]); + expect(store.handoffText).toBeNull(); + expect(store.currentStage).toBeNull(); + expect(store.session).toMatchObject({ + runPhase: RunPhase.Idle, + outroData: null, + outroDismissed: false, + dashboardUrl: null, + notebookUrl: null, + pendingQuestion: null, + taskNotice: null, + }); + await expect(question).resolves.toEqual({ a: '__cancelled__' }); + await expect(notice).resolves.toBe(false); + + expect(store.session.credentials?.projectId).toBe(3); + expect(store.session.frameworkContext).toEqual({ router: 'app' }); + expect(store.session.completedRuns).toContain('earlier'); + expect(store.getVersion()).toBeGreaterThan(version); + }); +}); diff --git a/src/store/task-stream/task-stream-push.ts b/src/store/task-stream/task-stream-push.ts index a3e0a9c45..7b1ee84ec 100644 --- a/src/store/task-stream/task-stream-push.ts +++ b/src/store/task-stream/task-stream-push.ts @@ -120,6 +120,8 @@ export interface TaskStreamPushOptions { auditChecks?: () => unknown; /** When false, destination subscription/delivery remains disabled. */ enabled?: boolean; + /** Keys the stream by this skill instead of the store's; set for an independent run. */ + skillId?: string; } export class TaskStreamPush { @@ -127,6 +129,7 @@ export class TaskStreamPush { private readonly destinations: TaskStreamDestination[]; private readonly startedAt: string; private readonly programId: string; + private readonly skillId: string | null; private readonly sessionId: string; private readonly eventPlanWatcher: EventPlanWatcher | null; private readonly auditChecks: (() => unknown) | null; @@ -156,8 +159,9 @@ export class TaskStreamPush { // skillId may not be set yet — fall back to programId so the // session_id is stable for the whole run regardless of when the // program metadata is populated. + this.skillId = opts.skillId ?? null; const skillId = sanitizeChannelId( - this.store.session.skillId ?? this.programId, + this.skillId ?? this.store.session.skillId ?? this.programId, ); this.sessionId = `${this.programId}-${skillId}-${this.startedAt}`; } @@ -299,7 +303,9 @@ export class TaskStreamPush { private async sendOnce(): Promise { const { session, tasks, eventPlan, handoffText } = this.store; - const skillId = sanitizeChannelId(session.skillId ?? this.programId); + const skillId = sanitizeChannelId( + this.skillId ?? session.skillId ?? this.programId, + ); const phase = session.runPhase; // Program rows carry the phase; the area rows carry the audit's progress. diff --git a/src/store/testing/index.ts b/src/store/testing/index.ts index 98a69fd25..bc6ea4c16 100644 --- a/src/store/testing/index.ts +++ b/src/store/testing/index.ts @@ -1,6 +1,9 @@ import { flowFor } from '../programs/flow-for.js'; import { Program, type ProgramId } from '../programs/program-registry.js'; +import { buildSession } from '../session/wizard-session.js'; import { WizardStore } from '../state/store.js'; +import { setUI } from '../ui/index.js'; +import { StoreUI } from '../ui/store-ui.js'; /** A real store on a real program flow; tests fake nothing below it. */ export function createTestStore( @@ -8,3 +11,31 @@ export function createTestStore( ): WizardStore { return new WizardStore(flowFor(programId).flow); } + +/** A store behind `StoreUI` with a non-interactive session: what a controlled run drives. */ +export function createControlledStore( + programId: ProgramId = Program.PostHogIntegration, + session: Partial[0]> = {}, +): WizardStore { + const store = createTestStore(programId); + setUI(new StoreUI(store)); + store.session = buildSession({ + installDir: '/tmp/controlled-store', + ci: true, + ...session, + }); + return store; +} + +const SECRET_MARKERS = ['phx_', 'phc_', 'phs_', 'secret:', 'sk_live_']; + +/** Fails when any known secret shape appears in text meant for a parent or a log. */ +export function expectNoSecrets(text: string, extra: string[] = []): void { + for (const marker of [...SECRET_MARKERS, ...extra]) { + if (text.includes(marker)) { + throw new Error( + `secret marker "${marker}" leaked into: ${text.slice(0, 120)}`, + ); + } + } +} diff --git a/src/store/types.ts b/src/store/types.ts index 7be544def..2b950493a 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -95,12 +95,14 @@ export type { export type { WizardSpellbook } from './wizard-spellbook.js'; export type { StoreBoundaryMember, WizardStoreApi } from './state/store-api.js'; export type { + ActionView, ControlHooks, ControlSession, ControlState, ControlSurface, DetectRequest, DriverAction, + HealthResponse, RunRecord, RunRequest, } from './control/types.js'; From 4e867359897c622b3e94b1dd447ddea7dfc02c92 Mon Sep 17 00:00:00 2001 From: "Vincent (Wen Yu) Ge" Date: Sun, 20 Sep 2026 23:02:50 -0400 Subject: [PATCH 7/7] chore(build): refresh the chunk manifest fixtures The bundle audit compares these with the manifests CI builds; the new modules and the regrouped chunks belong in them. A failed comparison now uploads its manifests and reports both files. Generated-By: PostHog Desktop Task-Id: d14e92bb-6ee1-49b5-8502-39cb80079589 --- .github/workflows/surfaces.yml | 8 +- scripts/__fixtures__/chunk-manifest.ci.json | 23 ++---- scripts/__fixtures__/chunk-manifest.prod.json | 74 +++++-------------- 3 files changed, 32 insertions(+), 73 deletions(-) diff --git a/.github/workflows/surfaces.yml b/.github/workflows/surfaces.yml index 63fb12450..7848ced63 100644 --- a/.github/workflows/surfaces.yml +++ b/.github/workflows/surfaces.yml @@ -83,9 +83,13 @@ jobs: chunk-manifest.ci.json - name: Chunk layout matches the committed fixtures run: | - diff scripts/__fixtures__/chunk-manifest.prod.json chunk-manifest.prod.json - diff scripts/__fixtures__/chunk-manifest.ci.json chunk-manifest.ci.json + status=0 + diff scripts/__fixtures__/chunk-manifest.prod.json chunk-manifest.prod.json || status=1 + diff scripts/__fixtures__/chunk-manifest.ci.json chunk-manifest.ci.json || status=1 + exit $status - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + # A failed comparison still uploads: the artifact is how the fixtures get refreshed. + if: always() with: name: chunk-manifests-${{ github.sha }} path: chunk-manifest.*.json diff --git a/scripts/__fixtures__/chunk-manifest.ci.json b/scripts/__fixtures__/chunk-manifest.ci.json index ece09eac7..2e2ade035 100644 --- a/scripts/__fixtures__/chunk-manifest.ci.json +++ b/scripts/__fixtures__/chunk-manifest.ci.json @@ -219,8 +219,7 @@ "src/store/agent-protocol/agent-env-isolation.ts", "src/store/agent-protocol/agent-signals.ts", "src/store/auth-session-state.ts", - "src/tui/App.tsx", - "src/tui/console/headless-ui.ts" + "src/tui/App.tsx" ] }, "src/cli/commands/basic-integration/ci-install.ts": { @@ -230,8 +229,7 @@ "imports": [ "src/cli/control-hooks.ts", "src/store/agent-protocol/agent-signals.ts", - "src/store/auth-session-state.ts", - "src/tui/console/headless-ui.ts" + "src/store/auth-session-state.ts" ] }, "src/cli/commands/basic-integration/interactive.ts": { @@ -275,8 +273,7 @@ "src/store/auth-session-state.ts", "src/store/control/actions.ts", "src/store/programs/ai-opt-in-gate.ts", - "src/tui/App.tsx", - "src/tui/console/headless-ui.ts" + "src/tui/App.tsx" ] }, "src/store/agent-protocol/agent-env-isolation.ts": { @@ -300,8 +297,6 @@ "src/store/shared/env-api-key.ts", "src/store/shared/environment.ts", "src/store/shared/terminal-bell.ts", - "src/store/state/flow-resolution.ts", - "src/store/state/run-failure.ts", "src/store/state/store.ts", "src/store/task-stream/audit-areas.ts", "src/store/task-stream/destinations/file.ts", @@ -474,6 +469,8 @@ "src/store/shared/env-scan.ts", "src/store/shared/headless-mode.ts", "src/store/skill-install.ts", + "src/store/state/flow-resolution.ts", + "src/store/state/run-failure.ts", "src/store/tools/tools.ts" ], "imports": [ @@ -491,6 +488,7 @@ "src/store/control/state.ts" ], "imports": [ + "src/store/agent-protocol/agent-signals.ts", "src/store/auth-session-state.ts" ] }, @@ -726,14 +724,5 @@ "src/store/programs/ai-opt-in-gate.ts", "src/store/services/steps/add-mcp-server-to-clients/MCPClient.ts" ] - }, - "src/tui/console/headless-ui.ts": { - "sources": [ - "src/tui/console/headless-ui.ts", - "src/tui/console/logging-ui.ts" - ], - "imports": [ - "src/store/auth-session-state.ts" - ] } } diff --git a/scripts/__fixtures__/chunk-manifest.prod.json b/scripts/__fixtures__/chunk-manifest.prod.json index 09163190e..3e48dd1ac 100644 --- a/scripts/__fixtures__/chunk-manifest.prod.json +++ b/scripts/__fixtures__/chunk-manifest.prod.json @@ -23,9 +23,9 @@ ], "imports": [ "src/agent/runner/sequence/orchestrator/queue-tools.ts", - "src/store/agent-protocol/agent-env-isolation.ts", "src/store/agent-protocol/agent-signals.ts", - "src/store/auth-session-state.ts" + "src/store/auth-session-state.ts", + "src/store/services/steps/add-mcp-server-to-clients/MCPClient.ts" ] }, "src/agent/agent-prompt-loader.ts": { @@ -78,10 +78,10 @@ "src/agent/agent-prompt-loader.ts", "src/agent/aio-capture.ts", "src/agent/runner/sequence/orchestrator/queue-tools.ts", - "src/store/agent-protocol/agent-env-isolation.ts", "src/store/agent-protocol/agent-signals.ts", "src/store/auth-session-state.ts", - "src/store/programs/ai-opt-in-gate.ts" + "src/store/programs/ai-opt-in-gate.ts", + "src/store/services/steps/add-mcp-server-to-clients/MCPClient.ts" ] }, "src/agent/aio-capture.ts": { @@ -98,8 +98,9 @@ "src/agent/runner/harness/pi/task.ts", "src/agent/runner/harness/pi/tasks.ts", "src/agent/runner/harness/pi/tools.ts", - "src/store/agent-protocol/agent-env-isolation.ts", - "src/store/agent-protocol/agent-signals.ts" + "src/store/agent-protocol/agent-signals.ts", + "src/store/auth-session-state.ts", + "src/store/services/steps/add-mcp-server-to-clients/MCPClient.ts" ] }, "src/agent/runner/harness/pi/mcp.ts": { @@ -144,8 +145,9 @@ "src/agent/runner/harness/pi/security.ts", "src/agent/runner/harness/pi/tools.ts", "src/agent/runner/sequence/orchestrator/queue-tools.ts", - "src/store/agent-protocol/agent-env-isolation.ts", - "src/store/agent-protocol/agent-signals.ts" + "src/store/agent-protocol/agent-signals.ts", + "src/store/auth-session-state.ts", + "src/store/services/steps/add-mcp-server-to-clients/MCPClient.ts" ] }, "src/agent/runner/harness/pi/tasks.ts": { @@ -160,8 +162,9 @@ ], "imports": [ "src/agent/aio-capture.ts", - "src/store/agent-protocol/agent-env-isolation.ts", - "src/store/auth-session-state.ts" + "src/store/agent-protocol/agent-signals.ts", + "src/store/auth-session-state.ts", + "src/store/services/steps/add-mcp-server-to-clients/MCPClient.ts" ] }, "src/agent/runner/sequence/orchestrator/queue-tools.ts": { @@ -216,9 +219,9 @@ "src/cli/commands/basic-integration/non-interactive.ts", "src/cli/commands/basic-integration/playground.ts", "src/cli/control-hooks.ts", - "src/store/agent-protocol/agent-env-isolation.ts", "src/store/agent-protocol/agent-signals.ts", "src/store/auth-session-state.ts", + "src/store/services/steps/add-mcp-server-to-clients/MCPClient.ts", "src/tui/App.tsx" ] }, @@ -268,53 +271,14 @@ ], "imports": [ "src/agent/agent-prompt.ts", - "src/store/agent-protocol/agent-env-isolation.ts", "src/store/agent-protocol/agent-signals.ts", "src/store/auth-session-state.ts", "src/store/control/actions.ts", "src/store/programs/ai-opt-in-gate.ts", + "src/store/services/steps/add-mcp-server-to-clients/MCPClient.ts", "src/tui/App.tsx" ] }, - "src/store/agent-protocol/agent-env-isolation.ts": { - "sources": [ - "src/store/agent-protocol/agent-env-isolation.ts", - "src/store/agent-protocol/agent-phase.ts", - "src/store/agent-protocol/mcp-prompt.ts", - "src/store/agent-protocol/token-pricing.ts", - "src/store/file-watcher.ts", - "src/store/mcp-project-profile.ts", - "src/store/mcp-role-prompts.copy.json", - "src/store/mcp-role-prompts.ts", - "src/store/mcp-seed-events.ts", - "src/store/safe-tools.ts", - "src/store/services/claude-settings.ts", - "src/store/services/steps/add-mcp-server-to-clients/browser-client.ts", - "src/store/services/steps/add-mcp-server-to-clients/login-client.ts", - "src/store/session/secret-vault.ts", - "src/store/shared/clipboard.ts", - "src/store/shared/custom-headers.ts", - "src/store/shared/env-api-key.ts", - "src/store/shared/environment.ts", - "src/store/shared/terminal-bell.ts", - "src/store/state/flow-resolution.ts", - "src/store/state/run-failure.ts", - "src/store/state/store.ts", - "src/store/task-stream/audit-areas.ts", - "src/store/task-stream/destinations/file.ts", - "src/store/task-stream/destinations/posthog.ts", - "src/store/task-stream/event-plan-watcher.ts", - "src/store/task-stream/task-stream-push.ts", - "src/store/tools/handoff.ts", - "src/store/ui/store-ui.ts", - "src/store/ui/wizard-ui.ts", - "src/store/wizard-spellbook.ts" - ], - "imports": [ - "src/store/agent-protocol/agent-signals.ts", - "src/store/auth-session-state.ts" - ] - }, "src/store/agent-protocol/agent-signals.ts": { "sources": [ "src/store/agent-protocol/agent-signals.ts", @@ -471,6 +435,8 @@ "src/store/shared/env-scan.ts", "src/store/shared/headless-mode.ts", "src/store/skill-install.ts", + "src/store/state/flow-resolution.ts", + "src/store/state/run-failure.ts", "src/store/tools/tools.ts" ], "imports": [ @@ -488,6 +454,7 @@ "src/store/control/state.ts" ], "imports": [ + "src/store/agent-protocol/agent-signals.ts", "src/store/auth-session-state.ts" ] }, @@ -500,9 +467,9 @@ "src/store/programs/run-config.ts" ], "imports": [ - "src/store/agent-protocol/agent-env-isolation.ts", "src/store/agent-protocol/agent-signals.ts", - "src/store/auth-session-state.ts" + "src/store/auth-session-state.ts", + "src/store/services/steps/add-mcp-server-to-clients/MCPClient.ts" ] }, "src/store/services/steps/add-mcp-server-to-clients/MCPClient.ts": { @@ -717,7 +684,6 @@ "src/tui/ui-store.ts" ], "imports": [ - "src/store/agent-protocol/agent-env-isolation.ts", "src/store/agent-protocol/agent-signals.ts", "src/store/auth-session-state.ts", "src/store/programs/ai-opt-in-gate.ts",