Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
f752890
docs: design php extension opt-ins
munezaclovis Jun 22, 2026
4ef50d3
docs: plan php extension opt-ins
munezaclovis Jun 22, 2026
7c2fd57
feat(config): parse PHP extension requests
munezaclovis Jun 22, 2026
09bee62
feat(resources): add PHP extension metadata helpers
munezaclovis Jun 22, 2026
8dd68eb
feat(release): bundle optional PHP extension metadata
munezaclovis Jun 22, 2026
838ebf4
fix(release): validate optional PHP extension artifacts
munezaclovis Jun 22, 2026
115e90c
feat(state): persist PHP runtime extension identity
munezaclovis Jun 22, 2026
465ae81
fix(state): keep PHP runtime extension state consistent
munezaclovis Jun 22, 2026
23e94b6
feat(daemon): group PHP workers by extension runtime
munezaclovis Jun 22, 2026
b5902b4
fix(state): persist unsupported PHP extension names
munezaclovis Jun 22, 2026
024fb3f
feat(cli): load PHP extension overlays in shims
munezaclovis Jun 22, 2026
799b067
feat(cli): report ignored PHP extensions
munezaclovis Jun 22, 2026
1f6da72
docs: document PHP extension opt-ins
munezaclovis Jun 22, 2026
05b8036
fix(php): enforce persisted extension runtime reconstruction
munezaclovis Jun 22, 2026
6a7904f
fix(status): prioritize ignored PHP extension warnings
munezaclovis Jun 22, 2026
b8c733c
fix(ci): refresh PHP extension test expectations
munezaclovis Jun 23, 2026
3894539
fix: address PHP extension runtime review feedback
munezaclovis Jun 23, 2026
11b0610
fix(cli): align PHP shims with current extension config
munezaclovis Jun 24, 2026
a1abefe
fix(pv-release): validate advertised PHP extension modules
munezaclovis Jun 24, 2026
b8f9965
fix(resources): always append PHP runtime overlay path
munezaclovis Jun 24, 2026
9ecbecc
test(daemon): guard DNS fallback precondition
munezaclovis Jun 24, 2026
b08a092
fix(state): clarify PHP worker port runtime key wording
munezaclovis Jun 24, 2026
f2a6234
test: tighten PHP extension review fixtures
munezaclovis Jun 24, 2026
1cc5698
test(cli): align composer extension shim fixture
munezaclovis Jun 24, 2026
cbb5fe0
fix(cli): resolve PHP config track before extensions
munezaclovis Jun 24, 2026
7d10838
fix(cli): refresh PHP shim track for extension-only config
munezaclovis Jun 24, 2026
44cc1f1
fix(daemon): refresh PHP extensions after install
munezaclovis Jun 24, 2026
b4c16c6
fix(cli): reuse persisted PHP runtime for invalid config
munezaclovis Jun 24, 2026
a28178a
test(daemon): use portable archive fixture tools
munezaclovis Jun 24, 2026
ef6f9bb
fix(release): require PHP extension artifact metadata
munezaclovis Jun 24, 2026
f4bb620
fix: address PHP extension review feedback
munezaclovis Jun 24, 2026
1edef98
feat(php): build sockets by default
munezaclovis Jun 24, 2026
b2db34c
fix(resources): prune PHP runtime overlay files
munezaclovis Jun 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .superpowers/sdd/task-2-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Task 2 Report: PHP Extension Metadata And Overlay Helpers

## Status

DONE_WITH_CONCERNS

## Summary

- Added `resources::php_extensions` with artifact metadata parsing, request resolution, runtime overlay writing, and runtime environment helpers.
- Exported `PhpExtensionModule`, `PhpExtensionLoadKind`, `PhpExtensionResolution`, `PHP_EXTENSION_METADATA_PATH`, and the new helper functions from `resources`.
- Added optional `php_extensions` artifact metadata parsing to `ManifestArtifact` with a public `php_extensions()` accessor.
- Added integration coverage for metadata resolution and runtime overlay generation.
- Updated manifest snapshots to include parsed PHP extension metadata and default empty metadata for older manifests.

## TDD Notes

- Wrote `crates/resources/tests/php_extensions.rs` and extended `manifest_foundation.rs` before implementation.
- Red check failed as expected with missing exports and missing `ManifestArtifact::php_extensions()`.
- Implemented the minimal resources-side helpers and manifest parsing needed to satisfy the tests.

## Verification

- PASS: `cargo nextest run -p resources -E 'test(resolves_available_and_ignored_php_extensions_from_artifact_metadata) or test(writes_runtime_overlay_for_loaded_php_extensions) or test(manifest_parses_registry_backed_resources_tracks_and_artifacts)'`
- PASS: `cargo nextest run -p resources --no-fail-fast`
- PASS: `cargo clippy -p resources --all-targets --all-features --locked -- -D warnings`
- EXPECTED FAIL: `cargo build --workspace --all-targets` fails only in known staged daemon callers using `Option<PhpConfig>::as_deref()` at `crates/daemon/src/project_env.rs:119` and `crates/daemon/src/gateway.rs:482`.

## Scope Notes

- `crates/resources/src/runtime.rs` was listed in the task file, but no runtime adapter change was needed. Requiring PHP extension metadata during artifact validation would violate the approved design requirement that older PHP artifacts without metadata remain valid and are treated as supporting no optional extensions.

## Concerns

- Workspace build remains blocked by the known Task 1 downstream `PhpConfig` API migration work outside this task's scope.
49 changes: 27 additions & 22 deletions DESIGN.md

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions crates/cli/src/commands/doctor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -622,6 +622,9 @@ fn runtime_subject_label(subject: &state::RuntimeSubject) -> String {
match subject {
state::RuntimeSubject::Gateway => "gateway".to_string(),
state::RuntimeSubject::PhpWorker { php_track } => format!("worker:{php_track}"),
state::RuntimeSubject::PhpRuntimeWorker { php_runtime_key } => {
format!("worker:{php_runtime_key}")
}
state::RuntimeSubject::Resource { name, track } => format!("{name}:{track}"),
}
}
Expand Down
10 changes: 8 additions & 2 deletions crates/cli/src/commands/logs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -167,8 +167,14 @@ fn installed_worker_sources(paths: &PvPaths) -> Result<Vec<LogSource>, ExecuteEr
let mut tracks = BTreeSet::new();

for state in database.runtime_observed_states()? {
if let state::RuntimeSubject::PhpWorker { php_track } = state.subject {
tracks.insert(php_track);
match state.subject {
state::RuntimeSubject::PhpWorker { php_track } => {
tracks.insert(php_track);
}
state::RuntimeSubject::PhpRuntimeWorker { php_runtime_key } => {
tracks.insert(php_runtime_key);
}
state::RuntimeSubject::Gateway | state::RuntimeSubject::Resource { .. } => {}
}
}

Expand Down
219 changes: 196 additions & 23 deletions crates/cli/src/commands/php.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ use std::io;
use std::io::Write;
use std::process::ExitCode;

use camino::Utf8PathBuf;
use camino::{Utf8Path, Utf8PathBuf};
use resources::{
ArtifactManifestCache, ManagedResourceCommands, ManagedResourceUninstallOptions,
ResourceAdapter, ResourceHttpClient, ResourceName, TargetPlatform, TrackName, TrackSelector,
UreqResourceHttpClient,
ArtifactManifestCache, ConcreteTrackName, ManagedResourceCommands,
ManagedResourceUninstallOptions, ResourceAdapter, ResourceHttpClient, ResourceName,
TargetPlatform, TrackName, TrackSelector, UreqResourceHttpClient,
};
use serde::Serialize;
use state::{Database, ManagedResourceDesiredState, ProjectRecord, PvPaths, StateError};
Expand Down Expand Up @@ -259,40 +259,209 @@ pub(crate) fn shim_with_args_and_env(
) -> Result<ExitCode, ExecuteError> {
let paths = pv_paths(environment)?;
let database = Database::open(&paths)?;
let track = resolve_php_track_for_shim(&paths, &database, environment)?;
let installed = installed_php(&database, &track)?;
resources::ensure_php_track_defaults(&paths, &track)?;
env.extend(resources::php_track_exec_environment(&paths, &track)?);
let runtime = resolve_php_runtime_for_shim(&paths, &database, environment)?;
let installed = installed_php(&database, &runtime.track)?;
resources::ensure_php_track_defaults(&paths, &runtime.track)?;
let loaded_modules = resources::resolve_persisted_php_extension_modules(
installed.release_path(),
&runtime.loaded_extensions,
)?;
env.extend(resources::php_runtime_exec_environment(
&paths,
&runtime.track,
&runtime.runtime_key,
installed.release_path(),
&loaded_modules,
)?);
let executable = installed.executable()?;

environment
.exec_with_env(executable.as_std_path(), &args, &env)
.map_err(ExecuteError::from)
}

fn resolve_php_track_for_shim(
struct PhpShimRuntime {
track: String,
runtime_key: String,
loaded_extensions: Vec<String>,
}

fn resolve_php_runtime_for_shim(
paths: &PvPaths,
database: &Database,
environment: &impl Environment,
) -> Result<String, ExecuteError> {
) -> Result<PhpShimRuntime, ExecuteError> {
let current_dir = current_dir(environment)?;
if let Some(project) = database.nearest_project_for_path(&current_dir)?
&& let Some(track) = project.desired_php_track
{
return Ok(track);
}
if let Some(project) = database.nearest_project_for_path(&current_dir)? {
let config_file = match config::ProjectConfigFile::read_from_root(&project.path) {
Ok(config_file) => config_file,
Err(error) => {
if let Some(runtime) = persisted_project_php_runtime_for_shim(&project)? {
return Ok(runtime);
}

if let Some(track) = database.global_php_default_track()? {
return Ok(track);
return Err(error.into());
}
};
if let Some(track) = project.php_runtime.track.clone() {
let php = config_file.config.php.as_ref();
let requested_extensions = php
.map(|php| php.requested_extensions().to_vec())
.unwrap_or_default();
let config_track = if let Some(php) = php {
match php.version_selector() {
Some(selector) if selector != "latest" && selector != track => Some(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ php.version: latest still takes the _ => None branch here, so current_track falls back to the persisted runtime track instead of the manifest-resolved default. The new regression test sets the manifest default to 8.5 but asserts execution of the persisted 8.4 release, which locks in the stale-track behavior this path was meant to prevent.

Technical details
# `latest` shims still reuse stale persisted tracks

## Affected sites
- `crates/cli/src/commands/php.rs:313``latest` never calls `resolve_project_config_php_track_for_shim`, so `config_track` remains `None`.
- `crates/cli/src/commands/php.rs:332``current_track` therefore stays at the persisted `project.php_runtime.track`.
- `crates/cli/tests/php.rs:516` — the new `latest` test currently expects `old_release` even though the cached manifest default is `8.5`.

## Required outcome
- A current `php.version: latest` selector must be resolved through the cached manifest before choosing the shim runtime, regardless of stale persisted project runtime state.
- The regression test should fail on stale `8.4` execution and pass only when the shim uses the manifest-selected `8.5` runtime and `8.5+redis` overlay.

## Suggested approach
Resolve `Some(TrackSelector::Latest)` before the empty-extension and requested-extension reuse paths, then compare the resolved track with the persisted track the same way the concrete-track/default-track branches do.

resolve_project_config_php_track_for_shim(paths, database, php)?,
),
None if database.global_php_default_track()?.is_some()
|| paths.downloads().join("manifest.json").exists() =>
{
let resolved_track =
resolve_project_config_php_track_for_shim(paths, database, php)?;
if resolved_track == track {
None
} else {
Some(resolved_track)
}
}
_ => None,
}
} else {
None
};
let current_track = config_track.as_deref().unwrap_or(&track).to_string();
if requested_extensions.is_empty() {
Comment on lines +329 to +333

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Re-resolve configless shims through global defaults

For a linked Project with no php key but an old persisted project.php_runtime.track, this branch leaves config_track as None, so the empty-extension return immediately below keeps using the stale persisted track. After pv php:use --global 8.4 only queues reconciliation (or when the daemon is unavailable), pv php/Composer inside that configless Project continue execing the old track even though configless Projects are supposed to follow the current global/default PHP track. Resolve through the global/default flow when php is None instead of falling back to track here.

Useful? React with 👍 / 👎.

return Ok(PhpShimRuntime {
runtime_key: current_track.clone(),
track: current_track,
loaded_extensions: Vec::new(),
});
}
if config_track.is_none()
&& project.php_runtime.requested_extensions == requested_extensions
Comment thread
munezaclovis marked this conversation as resolved.
{
let runtime_key =
state::php_runtime_key(&track, &project.php_runtime.loaded_extensions)?;

return Ok(PhpShimRuntime {
track,
runtime_key,
loaded_extensions: project.php_runtime.loaded_extensions,
});
}
if let Some(php) = php {
return resolve_project_config_php_runtime_for_shim(database, &current_track, php);
}
} else if let Some(php) = config_file.config.php.as_ref() {
let track = resolve_project_config_php_track_for_shim(paths, database, php)?;

return resolve_project_config_php_runtime_for_shim(database, &track, php);
}
}

let manifest = ArtifactManifestCache::new(paths.downloads()).load_cached()?;
let php = ResourceName::new("php")?;
let track = match database.global_php_default_track()? {
Some(track) => track,
None => {
let manifest = ArtifactManifestCache::new(paths.downloads()).load_cached()?;
let php = ResourceName::new("php")?;

Ok(manifest
.resolve_track(&php, TrackSelector::Latest)?
.as_str()
.to_string())
manifest
.resolve_track(&php, TrackSelector::Latest)?
.as_str()
.to_string()
}
};

Ok(PhpShimRuntime {
runtime_key: track.clone(),
track,
loaded_extensions: Vec::new(),
})
}

fn persisted_project_php_runtime_for_shim(
project: &ProjectRecord,
) -> Result<Option<PhpShimRuntime>, ExecuteError> {
let Some(track) = project.php_runtime.track.clone() else {
return Ok(None);
};
let loaded_extensions = project.php_runtime.loaded_extensions.clone();
let runtime_key = state::php_runtime_key(&track, &loaded_extensions)?;

Ok(Some(PhpShimRuntime {
track,
runtime_key,
loaded_extensions,
}))
}

fn resolve_project_config_php_track_for_shim(
paths: &PvPaths,
database: &Database,
php: &config::PhpConfig,
) -> Result<String, ExecuteError> {
let selector = php
.version_selector()
.map(TrackSelector::parse)
.transpose()?;
let track = match selector {
Some(TrackSelector::Latest) => {
let manifest = ArtifactManifestCache::new(paths.downloads()).load_cached()?;
let php = ResourceName::new("php")?;

manifest
.resolve_track(&php, TrackSelector::Latest)?
.as_str()
.to_string()
}
Some(TrackSelector::Track(track)) => track.as_str().to_string(),
None => match database.global_php_default_track()? {
Some(track) => track,
None => {
let manifest = ArtifactManifestCache::new(paths.downloads()).load_cached()?;
let php = ResourceName::new("php")?;

manifest
.resolve_track(&php, TrackSelector::Latest)?
.as_str()
.to_string()
}
},
};
let track = ConcreteTrackName::new(track)?;

Ok(track.as_str().to_string())
}

fn resolve_project_config_php_runtime_for_shim(
database: &Database,
track: &str,
php: &config::PhpConfig,
) -> Result<PhpShimRuntime, ExecuteError> {
let requested_extensions = php.requested_extensions().to_vec();
if requested_extensions.is_empty() {
return Ok(PhpShimRuntime {
runtime_key: track.to_string(),
track: track.to_string(),
loaded_extensions: Vec::new(),
});
}

let installed = installed_php(database, track)?;
let resolution =
resources::resolve_php_extension_request(installed.release_path(), &requested_extensions)?;
let loaded_extensions = resolution
.loaded
.iter()
.map(|module| module.name.clone())
.collect::<Vec<_>>();
let runtime_key = state::php_runtime_key(track, &loaded_extensions)?;

Ok(PhpShimRuntime {
track: track.to_string(),
runtime_key,
loaded_extensions,
})
}

fn effective_global_php_default_track(
Expand Down Expand Up @@ -323,6 +492,10 @@ impl InstalledPhp {

Ok(adapter.executable_path(&self.release))
}

fn release_path(&self) -> &Utf8Path {
&self.release
}
}

fn installed_php(database: &Database, track: &str) -> Result<InstalledPhp, ExecuteError> {
Expand Down
41 changes: 36 additions & 5 deletions crates/cli/src/commands/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,14 @@ pub(crate) fn link(
let original_project_path = resolve_project_path(args.path.as_deref(), environment)?;
let config_file = ProjectConfigFile::read_from_root(&original_project_path)?;
let project_path = project_root_from_config_path(&config_file.path)?;
let desired_php_track = resolved_project_php_track(&paths, config_file.config.php.as_deref())?;
let desired_php_track = resolved_project_php_track(
&paths,
config_file
.config
.php
.as_ref()
.and_then(|php| php.version_selector()),
)?;
let mut database = Database::open(&paths)?;
let existing = database.project_by_path(&project_path)?;
let primary_hostname = match (args.hostname, existing.as_ref()) {
Expand Down Expand Up @@ -618,14 +625,21 @@ fn project_list_env_status(
has_env_mappings: bool,
observed: Option<ProjectEnvObservedStateRecord>,
) -> (ProjectEnvStatus, Option<String>) {
if !has_env_mappings {
return (ProjectEnvStatus::None, None);
}

let Some(observed) = observed else {
if !has_env_mappings {
return (ProjectEnvStatus::None, None);
}

return (ProjectEnvStatus::Pending, None);
};

if !has_env_mappings
&& (observed.status != ProjectEnvObservedStatus::Warning
|| !has_ignored_php_extension_warning(&observed))
{
return (ProjectEnvStatus::None, None);
}

match observed.status {
ProjectEnvObservedStatus::Failed => (
ProjectEnvStatus::Failed,
Expand All @@ -641,6 +655,16 @@ fn project_list_env_status(
}

fn project_env_observed_warning_summary(observed: &ProjectEnvObservedStateRecord) -> String {
let ignored = observed
.warnings
.iter()
.filter(|warning| warning.kind == "ignored_php_extension")
.map(|warning| warning.message.as_str())
.collect::<Vec<_>>();
if !ignored.is_empty() {
return format!("warning: {}", ignored.join("; "));
}

match observed.warnings.as_slice() {
[warning] => format!("warning: {}", warning.message),
[] => observed
Expand All @@ -651,3 +675,10 @@ fn project_env_observed_warning_summary(observed: &ProjectEnvObservedStateRecord
warnings => format!("warning: {} warnings", warnings.len()),
}
}

fn has_ignored_php_extension_warning(observed: &ProjectEnvObservedStateRecord) -> bool {
observed
.warnings
.iter()
.any(|warning| warning.kind == "ignored_php_extension")
}
Loading
Loading