diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 944dd1a..3941957 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -300,10 +300,10 @@ jobs: uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust with: clients: pip,python,composer,cargo,curl,git - - name: Install Rust 1.86 + - name: Install Rust 1.99 uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable with: - toolchain: 1.86.0 + toolchain: 1.99.0 components: rustfmt, clippy - run: cargo fmt --check - run: cargo clippy --locked --all-targets -- -D warnings @@ -367,10 +367,10 @@ jobs: uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust with: clients: pip,python,cargo,curl,git - - name: Install Rust 1.86 + - name: Install Rust 1.99 uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable with: - toolchain: 1.86.0 + toolchain: 1.99.0 - run: cargo build --locked - name: Fetch the pinned 2.x reference CLI run: parity/fetch-reference.sh diff --git a/.github/workflows/rust-release-build.yml b/.github/workflows/rust-release-build.yml index f12b1f5..eb8123f 100644 --- a/.github/workflows/rust-release-build.yml +++ b/.github/workflows/rust-release-build.yml @@ -95,10 +95,10 @@ jobs: with: clients: pip,python,cargo,curl,git - - name: Install Rust 1.86 + - name: Install Rust 1.99 uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable with: - toolchain: 1.86.0 + toolchain: 1.99.0 targets: ${{ matrix.target }} - name: Install the musl C toolchain @@ -128,12 +128,14 @@ jobs: export DW_CLI_BUILD_DATE SOURCE_DATE_EPOCH="$epoch" # Keep runner paths out of the binary. registry="${CARGO_HOME:-$HOME/.cargo}/registry/src" + git_sources="${CARGO_HOME:-$HOME/.cargo}/git/checkouts" workspace="$GITHUB_WORKSPACE" if [ "$RUNNER_OS" = "Windows" ]; then registry="$(cygpath -w "$registry")" + git_sources="$(cygpath -w "$git_sources")" workspace="$(cygpath -w "$workspace")" fi - flags="--remap-path-prefix=$registry=/cargo/registry/src --remap-path-prefix=$workspace=/build" + flags="--remap-path-prefix=$registry=/cargo/registry/src --remap-path-prefix=$git_sources=/cargo/git/checkouts --remap-path-prefix=$workspace=/build" if [ "$RUNNER_OS" = "Windows" ]; then flags="$flags -C target-feature=+crt-static" fi @@ -196,6 +198,11 @@ jobs: exit 1 fi + - name: Exercise the built-in native development Server + env: + ASSET: ${{ matrix.asset }} + run: python3 -I -S scripts/ci/smoke-native-dev.py --binary "dist/$ASSET" + - name: Write the dependency inventory env: ASSET: ${{ matrix.asset }} diff --git a/README.md b/README.md index 67f3f41..7b6d947 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,28 @@ See the [distribution guide](docs/distribution.md) for exact-version installs, direct downloads, checksum and attestation verification, Homebrew, and reproducible builds. +## Start a local Server + +The native CLI includes a development Server and SQLite. Installing `dw` is +all you need to start the runtime: + +```bash +dw server:start-dev +``` + +It listens on `127.0.0.1:8080`, initializes the database and default namespace, +and runs timers and maintenance. In another terminal, connect the CLI: + +```bash +dw env:set local --server=http://127.0.0.1:8080 --token=dev-token --make-default +dw doctor +``` + +Data persists in `dev-server` under the CLI config directory. Use +`--data-dir=./workflow-data` to choose a directory or `--port=9000` to choose a +port. Ctrl+C stops the runtime and preserves its data. Application workers +still run your workflow and activity code through an SDK. + ## Connect Named profiles keep a runtime URL, namespace, token source, TLS policy, and diff --git a/docs/cli-reference.md b/docs/cli-reference.md index a76e287..ebbca45 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -430,29 +430,39 @@ dw server:info dw doctor dw doctor --env=prod --output=json -# Serve an existing, configured Laravel project (requires PHP on PATH) +# Start the built-in native development Server dw server:start-dev -dw server:start-dev --port=9090 --db=sqlite -``` - -`server:start-dev` runs `php artisan serve` in the current directory and defaults -to `127.0.0.1`. Use `--host=0.0.0.0` explicitly to bind to all interfaces. Install -the project's dependencies, configure its database and run migrations first. -The native CLI still requires a separate PHP executable for this command. -The selected `--db` value is passed as `DB_CONNECTION`. Clear Laravel's cached -configuration when changing drivers. -Restart the command after editing `.env`. Artisan's automatic environment reload -is disabled so the selected database driver stays applied to HTTP requests. - -MySQL and PostgreSQL modes require Docker Compose services named `mysql` or -`pgsql`, respectively, and `redis` in that project. A failed dependency launch -stops startup. Compose services remain running when the HTTP process ends. -Run queue workers and the scheduler separately as your application requires. - -POSIX native binaries forward SIGINT and SIGTERM to the active HTTP or Compose -process and wait up to five seconds before stopping it. Source and PHAR installs -use this forwarding when their PHP runtime provides `pcntl` and `posix`. Windows uses its -console interruption behavior. Existing Compose services remain caller-owned. +dw server:start-dev --port=9090 --data-dir=./workflow-data +``` + +`server:start-dev` runs the native Server and bundled SQLite compiled into +`dw`. No PHP, Docker, Redis, separate Server installation or runtime download +is needed. It works from an empty directory, creates the schema and default +namespace, and runs its own timers and maintenance. Application SDK workers +run separately. + +The default address is `127.0.0.1:8080`. Only loopback binds are accepted. +`--host=localhost` and `--host=::1` are supported. `--port=0` chooses an available +port and prints the selected URL. The bundled database driver is `sqlite`. + +Data persists in `dev-server` beside the CLI's config file. `--data-dir` selects +another directory. Only one development Server can use a directory at a time. +The development runtime refuses incompatible existing database ownership. +It does not take over or upgrade an unrelated PHP database. + +The default development bearer token is `dev-token`. Pass `--token` to use a +custom token, which is not printed. Connect from another terminal: + +```bash +dw env:set local --server=http://127.0.0.1:8080 --token=dev-token --make-default +dw doctor +``` + +Existing native `DW_*` payload, query and storage limits also apply. The runtime +reports itself as a development build. Its source is pinned in the CLI +dependency lock. It is not the separately qualified published Server image. +Ctrl+C and SIGTERM shut down HTTP and native maintenance and preserve the data. +No background dependency services are launched or left running. For a standalone Server using published images, follow the [service quickstart](https://durable-workflow.com/docs/quickstart/). diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 93ff273..d8cb23b 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -8,6 +8,27 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aliasable" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + [[package]] name = "apache-avro" version = "0.21.0" @@ -16,9 +37,9 @@ checksum = "36fa98bc79671c7981272d91a8753a928ff6a1cd8e4f20a44c45bd5d313840bf" dependencies = [ "bigdecimal", "bon", - "digest", + "digest 0.10.7", "log", - "miniz_oxide", + "miniz_oxide 0.8.9", "num-bigint", "quad-rand", "rand 0.9.5", @@ -26,12 +47,44 @@ dependencies = [ "serde", "serde_bytes", "serde_json", - "strum", - "strum_macros", + "strum 0.27.2", + "strum_macros 0.27.2", + "thiserror 2.0.21", + "uuid", +] + +[[package]] +name = "apache-avro" +version = "0.22.0" +source = "git+https://github.com/durable-workflow/server?rev=63cea4854ec6ebe56ab1603b014e246b907aedc0#63cea4854ec6ebe56ab1603b014e246b907aedc0" +dependencies = [ + "bigdecimal", + "bon", + "digest 0.11.3", + "log", + "miniz_oxide 0.9.1", + "num-bigint", + "ouroboros", + "quad-rand", + "rand 0.10.3", + "regex-lite", + "serde", + "serde_bytes", + "serde_json", + "strum 0.28.0", "thiserror 2.0.21", "uuid", ] +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -44,6 +97,56 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + [[package]] name = "base64" version = "0.22.1" @@ -75,6 +178,9 @@ name = "bitflags" version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] [[package]] name = "block-buffer" @@ -85,29 +191,36 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "bon" -version = "3.8.1" +version = "3.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebeb9aaf9329dff6ceb65c689ca3db33dbf15f324909c60e4e5eef5701ce31b1" +checksum = "a05442cd7005532d0cfedec676b5b9333d2b4c7c3afabe2b1a9ce2742fbe2ee6" dependencies = [ "bon-macros", - "rustversion", ] [[package]] name = "bon-macros" -version = "3.8.1" +version = "3.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77e9d642a7e3a318e37c2c9427b5a6a48aa1ad55dcd986f3034ab2239045a645" +checksum = "93833e97ac7c46ba97045afcf247238c27057538fcf2f06344662ddd5f567be6" dependencies = [ "darling", "ident_case", "prettyplease", "proc-macro2", "quote", - "rustversion", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] @@ -116,6 +229,12 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.12.1" @@ -161,9 +280,24 @@ version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ + "iana-time-zone", "num-traits", + "serde", + "windows-link", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -182,6 +316,36 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crossbeam-queue" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + [[package]] name = "crypto-common" version = "0.1.7" @@ -192,11 +356,29 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "261af4dc7a06ea8a527a8717d8cbba3417530d6ea14e1edb3de6b67806be474f" +dependencies = [ + "cmov", +] + [[package]] name = "darling" -version = "0.21.3" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" dependencies = [ "darling_core", "darling_macro", @@ -204,27 +386,26 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.21.3" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" dependencies = [ - "fnv", "ident_case", "proc-macro2", "quote", "strsim", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] name = "darling_macro" -version = "0.21.3" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" dependencies = [ "darling_core", "quote", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] @@ -242,8 +423,19 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "ctutils", ] [[package]] @@ -258,14 +450,18 @@ dependencies = [ ] [[package]] -name = "durable-workflow" -version = "4.12.0" +name = "dotenvy" +version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "620a8287af73c363dac61f0a91f20971a39f3b8cdc9513fbea85cdbd95a74ba4" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "durable-workflow" +version = "4.17.0" +source = "git+https://github.com/durable-workflow/sdk-rust?rev=ff75c2a59029cc5c6ab510ca5af2e3acc06ae7b4#ff75c2a59029cc5c6ab510ca5af2e3acc06ae7b4" dependencies = [ - "apache-avro", + "apache-avro 0.21.0", "base64 0.22.1", - "bon", "chrono", "futures-util", "icu_collections", @@ -278,7 +474,7 @@ dependencies = [ "serde", "serde-value", "serde_json", - "sha2", + "sha2 0.10.9", "thiserror 1.0.69", "tokio", "tracing", @@ -287,23 +483,65 @@ dependencies = [ "yoke-derive", ] +[[package]] +name = "durable-workflow-server" +version = "0.0.0" +source = "git+https://github.com/durable-workflow/server?rev=63cea4854ec6ebe56ab1603b014e246b907aedc0#63cea4854ec6ebe56ab1603b014e246b907aedc0" +dependencies = [ + "apache-avro 0.22.0", + "axum", + "base64 0.22.1", + "chrono", + "crc", + "form_urlencoded", + "futures-util", + "getrandom 0.3.4", + "hmac", + "jiff", + "libc", + "log", + "miniz_oxide 0.9.1", + "phpserz", + "regex-lite", + "serde", + "serde_json", + "sha2 0.11.1", + "sqlx", + "subtle", + "tempfile", + "thiserror 2.0.21", + "tokio", + "tokio-util", + "ulid", +] + [[package]] name = "dw" version = "2.8.0" dependencies = [ + "axum", "durable-workflow", + "durable-workflow-server", "getrandom 0.2.17", "libc", - "os_pipe", "rcgen", "reqwest", "rustls", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "tokio", ] +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" +dependencies = [ + "serde", +] + [[package]] name = "equivalent" version = "1.0.3" @@ -320,6 +558,38 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "etcetera" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de48cc4d1c1d97a20fd819def54b890cadde72ed3ad0c614822a0a433361be96" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "fast-float2" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6e8948ce679d00a02a94739ea185595dca7118ed04feb991127e443bd3d761f" + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "find-msvc-tools" version = "0.1.15" @@ -327,10 +597,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" [[package]] -name = "fnv" -version = "1.0.7" +name = "flume" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] [[package]] name = "foldhash" @@ -354,6 +629,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -362,6 +638,34 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + [[package]] name = "futures-macro" version = "0.3.34" @@ -373,6 +677,12 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + [[package]] name = "futures-task" version = "0.3.34" @@ -386,8 +696,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", + "futures-io", "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -441,18 +754,68 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +[[package]] +name = "hashlink" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" +dependencies = [ + "hashbrown 0.16.1", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + [[package]] name = "http" version = "1.5.0" @@ -492,6 +855,21 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.12.0" @@ -504,6 +882,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -551,6 +930,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.2.0" @@ -667,7 +1070,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", ] [[package]] @@ -682,6 +1085,48 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jiff" +version = "0.2.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26699905cc795a23e3949585cc7ba3ee8ade090872a27e4316304a777297894a" +dependencies = [ + "jiff-core", + "jiff-static", + "jiff-tzdb", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-core" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffe6854081f5ac0a3046a9b223729ccd7a20b1e0e8277e44dc0660eedf67dfe2" +dependencies = [ + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b4ff49b2887190c760b86207b55cdcaabbdecaf7a0660d68aa2ed6f652abf0a" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa8377070c6bae868759445e5a77f66d84f0b72f3a054bfb00e6d038b8282da7" + [[package]] name = "js-sys" version = "0.3.107" @@ -705,12 +1150,38 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[package]] +name = "libsqlite3-sys" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.35" @@ -723,12 +1194,34 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "mime" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5848f5a9e8117cf94f2f90faac24a87ff81600ff8628c1e3deaab572e76bfa1" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -738,6 +1231,15 @@ dependencies = [ "adler2", ] +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", +] + [[package]] name = "mio" version = "1.2.4" @@ -800,13 +1302,56 @@ dependencies = [ ] [[package]] -name = "os_pipe" -version = "1.2.3" +name = "ouroboros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0f050db9c44b97a94723127e6be766ac5c340c48f2c4bb3ffa11713744be59" +dependencies = [ + "aliasable", + "ouroboros_macro", + "static_assertions", +] + +[[package]] +name = "ouroboros_macro" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c7028bdd3d43083f6d8d4d5187680d0d3560d54df4cc9d752005268b41e64d0" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ + "cfg-if", "libc", - "windows-sys 0.61.2", + "redox_syscall", + "smallvec", + "windows-link", ] [[package]] @@ -825,12 +1370,42 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phpserz" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8e1c56e7b9593a3dd62215f3e97db051885f282a947d8a7714b593cc5ace8d1" +dependencies = [ + "fast-float2", +] + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + [[package]] name = "potential_utf" version = "0.1.6" @@ -857,12 +1432,12 @@ dependencies = [ [[package]] name = "prettyplease" -version = "0.2.37" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" dependencies = [ "proc-macro2", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] @@ -874,6 +1449,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "version_check", + "yansi", +] + [[package]] name = "quad-rand" version = "0.2.3" @@ -1025,6 +1613,15 @@ dependencies = [ "yasna", ] +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "regex-lite" version = "0.1.9" @@ -1089,6 +1686,19 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" +[[package]] +name = "rustix" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.46" @@ -1136,6 +1746,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "serde" version = "1.0.229" @@ -1201,6 +1817,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1213,6 +1840,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + [[package]] name = "sha2" version = "0.10.9" @@ -1221,7 +1859,18 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", ] [[package]] @@ -1251,6 +1900,9 @@ name = "smallvec" version = "1.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" +dependencies = [ + "serde", +] [[package]] name = "socket2" @@ -1262,12 +1914,212 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "sqlx" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" +dependencies = [ + "base64 0.22.1", + "bytes", + "cfg-if", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.16.1", + "hashlink", + "indexmap", + "log", + "memchr", + "percent-encoding", + "rustls", + "serde", + "serde_json", + "sha2 0.10.9", + "smallvec", + "thiserror 2.0.21", + "tokio", + "tokio-stream", + "tracing", + "url", + "webpki-roots", +] + +[[package]] +name = "sqlx-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.119", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3" +dependencies = [ + "cfg-if", + "dotenvy", + "either", + "heck 0.5.0", + "hex", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2 0.10.9", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.119", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.9.0" +source = "git+https://github.com/durable-workflow/server?rev=63cea4854ec6ebe56ab1603b014e246b907aedc0#63cea4854ec6ebe56ab1603b014e246b907aedc0" +dependencies = [ + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest 0.11.3", + "dotenvy", + "either", + "futures-core", + "futures-util", + "generic-array", + "log", + "percent-encoding", + "serde", + "sha1", + "sha2 0.11.1", + "sqlx-core", + "thiserror 2.0.21", + "tracing", +] + +[[package]] +name = "sqlx-postgres" +version = "0.9.0" +source = "git+https://github.com/durable-workflow/server?rev=63cea4854ec6ebe56ab1603b014e246b907aedc0#63cea4854ec6ebe56ab1603b014e246b907aedc0" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "rand 0.10.3", + "serde", + "serde_json", + "sha2 0.11.1", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.21", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c" +dependencies = [ + "atoi", + "chrono", + "flume", + "form_urlencoded", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "sqlx-core", + "thiserror 2.0.21", + "tracing", + "url", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + [[package]] name = "strsim" version = "0.11.1" @@ -1280,13 +2132,34 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros 0.28.0", +] + [[package]] name = "strum_macros" version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" dependencies = [ - "heck", + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.119", @@ -1351,6 +2224,19 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -1463,6 +2349,30 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + [[package]] name = "tower" version = "0.5.3" @@ -1514,10 +2424,23 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -1539,12 +2462,43 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "ulid" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe" +dependencies = [ + "rand 0.9.5", + "web-time", +] + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + [[package]] name = "unicode-ident" version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" +[[package]] +name = "unicode-normalization" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75cd3fb03d5288b3ba9a1ee103ac7e9cbb59f0b03d64f8ddfcc04dd42758a85d" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993ad287eb20e8ba755312801cfe1413e0f25d604075863acaddca10b04c1833" + [[package]] name = "untrusted" version = "0.9.0" @@ -1590,6 +2544,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.5" @@ -1704,12 +2664,71 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "whoami" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -1804,6 +2823,12 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "yasna" version = "0.5.2" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 50b774b..bed5841 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -2,7 +2,7 @@ name = "dw" version = "2.8.0" edition = "2021" -rust-version = "1.86" +rust-version = "1.99" description = "Command-line interface for Durable Workflow Server and Cloud" license = "MIT" repository = "https://github.com/durable-workflow/cli" @@ -13,21 +13,23 @@ name = "dw" path = "src/main.rs" [dependencies] -# Pinned to the latest qualified published SDK release. Move only after -# sdk-rust's published-package acceptance. -durable-workflow = "=4.12.0" +# Native development Server, built into every standalone dw binary. Keep its +# dependency patches on this exact source revision as well. +durable-workflow-server = { git = "https://github.com/durable-workflow/server", rev = "63cea4854ec6ebe56ab1603b014e246b907aedc0" } +axum = { version = "=0.8.9", default-features = false, features = ["http1", "tokio"] } +# Composition candidate for sdk-rust#279. Replace with the qualified package +# before this development-runtime change can be released. +durable-workflow = { git = "https://github.com/durable-workflow/sdk-rust", rev = "ff75c2a59029cc5c6ab510ca5af2e3acc06ae7b4", default-features = false } serde = "1" sha2 = "0.10" # Random generated request IDs, as 2.x random_bytes(); already in the release # graph through ring. getrandom = "0.2" -# Merge child stdout/stderr without a shell on Rust 1.86, including Windows. -os_pipe = "1.2.3" # preserve_order keeps Server response keys in their original order, matching # the PHP CLI's pass-through JSON. float_roundtrip parses every float to the # exact value PHP decodes, so re-encoded output and checksums match 2.x. serde_json = { version = "1", features = ["preserve_order", "float_roundtrip"] } -tokio = { version = "1", features = ["rt", "macros"] } +tokio = { version = "1", features = ["rt", "rt-multi-thread", "macros", "net", "signal"] } # Release downloads for `dw upgrade`; the same version and TLS stack the SDK uses. reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } @@ -47,3 +49,16 @@ libc = "0.2" codegen-units = 1 lto = true strip = "symbols" + +# Cargo only applies patches from the top-level manifest. These are the +# reviewed Server dependency sources, including its Avro and SQL adapters. +[patch.crates-io] +apache-avro = { git = "https://github.com/durable-workflow/server", rev = "63cea4854ec6ebe56ab1603b014e246b907aedc0" } +sqlx-mysql = { git = "https://github.com/durable-workflow/server", rev = "63cea4854ec6ebe56ab1603b014e246b907aedc0" } +sqlx-postgres = { git = "https://github.com/durable-workflow/server", rev = "63cea4854ec6ebe56ab1603b014e246b907aedc0" } + +[profile.dev] +debug = 0 + +[profile.test] +debug = 0 diff --git a/rust/README.md b/rust/README.md index 9e4f69e..91041e0 100644 --- a/rust/README.md +++ b/rust/README.md @@ -110,7 +110,7 @@ compatibility check, without a request, as 2.x reports it. ## Build and test -Requires Rust 1.86 or newer. +Requires Rust 1.99 or newer, including the bundled native development Server. ```bash cargo test --locked diff --git a/rust/parity/catalog-updates.json b/rust/parity/catalog-updates.json index 3389b44..c5174a7 100644 --- a/rust/parity/catalog-updates.json +++ b/rust/parity/catalog-updates.json @@ -411,5 +411,247 @@ } } } + }, + "server:start-dev": { + "description": { + "reference": "Serve an existing Laravel project for local development", + "candidate": "Start the built-in native development Server" + }, + "synopsis": { + "reference": "server:start-dev [-p|--port [PORT]] [--host [HOST]] [--db [DB]]", + "candidate": "server:start-dev [-p|--port [PORT]] [--host [HOST]] [--db [DB]] [--data-dir DATA-DIR] [--token TOKEN]" + }, + "help": { + "reference": "Run this command from an installed, configured Laravel project's directory.\nAn external PHP executable is required, even when using the native dw binary.\nThis launches Artisan's HTTP server. Configure the database and run migrations\nfirst, and run the project's queue worker and scheduler separately when needed.\nRestart this command after editing the project's .env configuration.\n\nSQLite is the default database driver. For mysql or\npgsql, your project's Docker Compose configuration must\ndefine that service and redis. Dependencies are left running\nwhen the HTTP process stops. The bind address defaults to loopback.\n\nFor a standalone Durable Workflow Server, follow the Docker-backed quickstart:\nhttps://durable-workflow.com/docs/quickstart/\n\nExamples:\n\n dw server:start-dev\n dw server:start-dev --port=9000\n dw server:start-dev --db=mysql", + "candidate": "Start a local Durable Workflow Server using the native runtime and SQLite\ncompiled into dw. Run it from any directory. No PHP, Docker, Redis, separate\nServer installation or runtime download is needed. The runtime creates its\nschema and default namespace and runs its own timers and maintenance.\n\nThe address defaults to 127.0.0.1:8080 and only loopback binds are accepted.\nUse --port=0 to choose an available port. Data persists under dw's config\nfolder in dev-server, or the directory selected with --data-dir. Only one\nServer may use a data directory at a time. Ctrl+C closes the runtime and keeps\nthe data. SDK workers still run your application code separately.\n\nThe local development token is dev-token. Set --token to choose another.\nExisting native DW_* payload, query and storage limits also apply. This is\na CLI-linked development build. Its source is pinned in the CLI dependency lock.\n\nExamples:\n\n dw server:start-dev\n dw server:start-dev --port=9000 --data-dir=./workflow-data\n\n # In another terminal, connect the CLI to the local Server\n dw env:set local --server=http://127.0.0.1:8080 --token=dev-token --make-default\n dw doctor" + }, + "definition": { + "reference": { + "arguments": [], + "options": { + "port": { + "name": "--port", + "shortcut": "-p", + "accept_value": true, + "is_value_required": false, + "is_multiple": false, + "description": "Server port", + "default": "8080" + }, + "host": { + "name": "--host", + "shortcut": "", + "accept_value": true, + "is_value_required": false, + "is_multiple": false, + "description": "Bind address (IP address or localhost)", + "default": "127.0.0.1" + }, + "db": { + "name": "--db", + "shortcut": "", + "accept_value": true, + "is_value_required": false, + "is_multiple": false, + "description": "Database driver (sqlite, mysql, pgsql)", + "default": "sqlite" + }, + "help": { + "name": "--help", + "shortcut": "-h", + "accept_value": false, + "is_value_required": false, + "is_multiple": false, + "description": "Display help for the given command. When no command is given display help for the list command", + "default": false + }, + "silent": { + "name": "--silent", + "shortcut": "", + "accept_value": false, + "is_value_required": false, + "is_multiple": false, + "description": "Do not output any message", + "default": false + }, + "quiet": { + "name": "--quiet", + "shortcut": "-q", + "accept_value": false, + "is_value_required": false, + "is_multiple": false, + "description": "Only errors are displayed. All other output is suppressed", + "default": false + }, + "verbose": { + "name": "--verbose", + "shortcut": "-v|-vv|-vvv", + "accept_value": false, + "is_value_required": false, + "is_multiple": false, + "description": "Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug", + "default": false + }, + "version": { + "name": "--version", + "shortcut": "-V", + "accept_value": false, + "is_value_required": false, + "is_multiple": false, + "description": "Display this application version", + "default": false + }, + "ansi": { + "name": "--ansi", + "shortcut": "", + "accept_value": false, + "is_value_required": false, + "is_multiple": false, + "description": "Force (or disable --no-ansi) ANSI output", + "default": null + }, + "no-ansi": { + "name": "--no-ansi", + "shortcut": "", + "accept_value": false, + "is_value_required": false, + "is_multiple": false, + "description": "Negate the \"--ansi\" option", + "default": null + }, + "no-interaction": { + "name": "--no-interaction", + "shortcut": "-n", + "accept_value": false, + "is_value_required": false, + "is_multiple": false, + "description": "Do not ask any interactive question", + "default": false + } + } + }, + "candidate": { + "arguments": [], + "options": { + "port": { + "name": "--port", + "shortcut": "-p", + "accept_value": true, + "is_value_required": false, + "is_multiple": false, + "description": "Server port (0 chooses an available port)", + "default": "8080" + }, + "host": { + "name": "--host", + "shortcut": "", + "accept_value": true, + "is_value_required": false, + "is_multiple": false, + "description": "Loopback bind address (127.0.0.1, ::1 or localhost)", + "default": "127.0.0.1" + }, + "db": { + "name": "--db", + "shortcut": "", + "accept_value": true, + "is_value_required": false, + "is_multiple": false, + "description": "Bundled database driver (sqlite)", + "default": "sqlite" + }, + "help": { + "name": "--help", + "shortcut": "-h", + "accept_value": false, + "is_value_required": false, + "is_multiple": false, + "description": "Display help for the given command. When no command is given display help for the list command", + "default": false + }, + "silent": { + "name": "--silent", + "shortcut": "", + "accept_value": false, + "is_value_required": false, + "is_multiple": false, + "description": "Do not output any message", + "default": false + }, + "quiet": { + "name": "--quiet", + "shortcut": "-q", + "accept_value": false, + "is_value_required": false, + "is_multiple": false, + "description": "Only errors are displayed. All other output is suppressed", + "default": false + }, + "verbose": { + "name": "--verbose", + "shortcut": "-v|-vv|-vvv", + "accept_value": false, + "is_value_required": false, + "is_multiple": false, + "description": "Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug", + "default": false + }, + "version": { + "name": "--version", + "shortcut": "-V", + "accept_value": false, + "is_value_required": false, + "is_multiple": false, + "description": "Display this application version", + "default": false + }, + "ansi": { + "name": "--ansi", + "shortcut": "", + "accept_value": false, + "is_value_required": false, + "is_multiple": false, + "description": "Force (or disable --no-ansi) ANSI output", + "default": null + }, + "no-ansi": { + "name": "--no-ansi", + "shortcut": "", + "accept_value": false, + "is_value_required": false, + "is_multiple": false, + "description": "Negate the \"--ansi\" option", + "default": null + }, + "no-interaction": { + "name": "--no-interaction", + "shortcut": "-n", + "accept_value": false, + "is_value_required": false, + "is_multiple": false, + "description": "Do not ask any interactive question", + "default": false + }, + "data-dir": { + "name": "--data-dir", + "shortcut": "", + "accept_value": true, + "is_value_required": true, + "is_multiple": false, + "description": "Directory for persistent local development data", + "default": null + }, + "token": { + "name": "--token", + "shortcut": "", + "accept_value": true, + "is_value_required": true, + "is_multiple": false, + "description": "Development bearer token (defaults to dev-token)", + "default": null + } + } + } + } } } diff --git a/rust/parity/parity.py b/rust/parity/parity.py index 21bde67..53d8ea7 100755 --- a/rust/parity/parity.py +++ b/rust/parity/parity.py @@ -578,6 +578,14 @@ def invocation_path(binary, role): def run_all(candidate_binary, servers, verbose, server_only=False, skip_references=()): with open(CASES, encoding="utf-8") as handle: cases = json.load(handle)["cases"] + help_cases() + # The native start-dev command now runs a real embedded Server. PHP's + # fake-Artisan launch cases are retained as historical reference data; + # Rust's HTTP/data/restart/shutdown contract is in tests/start_dev.rs. + legacy_dev = [case for case in cases if case.get("argv", [None])[0] == "server:start-dev"] + if legacy_dev: + print(f"Skipping {len(legacy_dev)} legacy PHP start-dev launcher cases; " + "the embedded native Server is exercised by cargo test --test start_dev (cli#160).") + cases = [case for case in cases if case not in legacy_dev] if server_only: cases = [case for case in cases if case.get("server")] skipped = [case for case in cases if case.get("reference") in skip_references] diff --git a/rust/src/catalog.json b/rust/src/catalog.json index 66d4190..243d525 100644 --- a/rust/src/catalog.json +++ b/rust/src/catalog.json @@ -781,7 +781,7 @@ "synopsis": "upgrade [--tag TAG] [--dry-run] [--force] [--output OUTPUT]", "aliases": [], "hidden": false, - "help": "Replace the currently running `dw` binary with a newer release from\n`durable-workflow/cli` on GitHub. The command verifies the downloaded\nasset against `SHA256SUMS` before replacing the binary, and only\nrewrites standalone release installs \u2014 Composer, Homebrew, and PHAR\ninstalls are refused with a pointer at the right managing tool.\n\nExamples:\n\n dw upgrade\n dw upgrade --tag=\\\n dw upgrade --dry-run\n dw upgrade --output=json", + "help": "Replace the currently running `dw` binary with a newer release from\n`durable-workflow/cli` on GitHub. The command verifies the downloaded\nasset against `SHA256SUMS` before replacing the binary, and only\nrewrites standalone release installs — Composer, Homebrew, and PHAR\ninstalls are refused with a pointer at the right managing tool.\n\nExamples:\n\n dw upgrade\n dw upgrade --tag=\\\n dw upgrade --dry-run\n dw upgrade --output=json", "definition": { "arguments": [], "options": { @@ -1083,7 +1083,7 @@ "synopsis": "activity:complete [-s|--server [SERVER]] [--namespace [NAMESPACE]] [--token [TOKEN]] [--tls-verify TLS-VERIFY] [--env ENV] [--output OUTPUT] [--lease-owner [LEASE-OWNER]] [--json] [-i|--input [INPUT]] [--input-file [INPUT-FILE]] [--input-encoding [INPUT-ENCODING]] [--] ", "aliases": [], "hidden": false, - "help": "Complete an activity from outside the worker process. Supply the leased\nactivity_attempt_id so the server can validate ownership\nand enforce the at-most-one terminal outcome per attempt. If the attempt\nhas already settled (for example, a redelivery recorded completion\nfirst), the server returns recorded=false with a reason\ncode \u2014 that is the redelivery path, not a failure.\n\nExamples:\n\n dw activity:complete act-123 att-456 --input='{\"ok\":true}'\n dw activity:complete act-123 att-456 --input-file=result.json --json", + "help": "Complete an activity from outside the worker process. Supply the leased\nactivity_attempt_id so the server can validate ownership\nand enforce the at-most-one terminal outcome per attempt. If the attempt\nhas already settled (for example, a redelivery recorded completion\nfirst), the server returns recorded=false with a reason\ncode — that is the redelivery path, not a failure.\n\nExamples:\n\n dw activity:complete act-123 att-456 --input='{\"ok\":true}'\n dw activity:complete act-123 att-456 --input-file=result.json --json", "definition": { "arguments": { "task-id": { @@ -1440,7 +1440,7 @@ "synopsis": "activity:fail [-s|--server [SERVER]] [--namespace [NAMESPACE]] [--token [TOKEN]] [--tls-verify TLS-VERIFY] [--env ENV] [--output OUTPUT] [--lease-owner [LEASE-OWNER]] [-m|--message MESSAGE] [-t|--type [TYPE]] [--failure-kind FAILURE-KIND] [--retryable] [--non-retryable] [--timeout-type TIMEOUT-TYPE] [--cancelled] [--malformed-output] [--json] [--] ", "aliases": [], "hidden": false, - "help": "Fail an activity from outside the worker process. Activity attempts are\nat-least-once; each retry runs as a fresh attempt with a new\nactivity_attempt_id while the\nactivity_execution_id stays stable across the retry\nchain. --non-retryable tells the server to skip the\nretry policy and surface the failure to the workflow immediately. If the\nnamed activity_attempt_id has already settled, the\nserver returns recorded=false with a reason \u2014 that is\nredelivery, not a failure to fail.\n\nExamples:\n\n dw activity:fail act-123 att-456 -m \"upstream returned 500\"\n dw activity:fail act-123 att-456 -m \"bad input\" -t ValidationError --non-retryable", + "help": "Fail an activity from outside the worker process. Activity attempts are\nat-least-once; each retry runs as a fresh attempt with a new\nactivity_attempt_id while the\nactivity_execution_id stays stable across the retry\nchain. --non-retryable tells the server to skip the\nretry policy and surface the failure to the workflow immediately. If the\nnamed activity_attempt_id has already settled, the\nserver returns recorded=false with a reason — that is\nredelivery, not a failure to fail.\n\nExamples:\n\n dw activity:fail act-123 att-456 -m \"upstream returned 500\"\n dw activity:fail act-123 att-456 -m \"bad input\" -t ValidationError --non-retryable", "definition": { "arguments": { "task-id": { @@ -3127,7 +3127,7 @@ "synopsis": "env:delete [--output OUTPUT] [--json] [--] ", "aliases": [], "hidden": false, - "help": "Delete a named dw environment profile from the config file.\n\nIf the deleted profile is the current default (set via\ndw env:use), the default is cleared \u2014 the next command\nwithout --env and without DW_ENV will fall\nback to environment variables and the built-in defaults.\n\nExamples:\n\n dw env:delete staging", + "help": "Delete a named dw environment profile from the config file.\n\nIf the deleted profile is the current default (set via\ndw env:use), the default is cleared — the next command\nwithout --env and without DW_ENV will fall\nback to environment variables and the built-in defaults.\n\nExamples:\n\n dw env:delete staging", "definition": { "arguments": { "name": { @@ -3644,7 +3644,7 @@ "synopsis": "env:use [--output OUTPUT] [--json] [--] ", "aliases": [], "hidden": false, - "help": "Persist name as the default dw environment profile.\n\nSubsequent commands use this profile unless the caller passes an\nexplicit --env or sets the DW_ENV environment\nvariable. Unknown profile names are rejected \u2014 use\ndw env:list to see the available profiles.\n\nExamples:\n\n dw env:use dev\n dw env:use prod --json", + "help": "Persist name as the default dw environment profile.\n\nSubsequent commands use this profile unless the caller passes an\nexplicit --env or sets the DW_ENV environment\nvariable. Unknown profile names are rejected — use\ndw env:list to see the available profiles.\n\nExamples:\n\n dw env:use dev\n dw env:use prod --json", "definition": { "arguments": { "name": { @@ -3755,7 +3755,7 @@ "synopsis": "namespace:create [-s|--server [SERVER]] [--namespace [NAMESPACE]] [--token [TOKEN]] [--tls-verify TLS-VERIFY] [--env ENV] [--output OUTPUT] [-d|--description [DESCRIPTION]] [-r|--retention [RETENTION]] [--json] [--] ", "aliases": [], "hidden": false, - "help": "Create a logical namespace for workflow executions. Namespaces\npartition the visibility surface \u2014 workflows in different namespaces\nnever appear in each other's lists.\n\nExamples:\n\n dw namespace:create billing\n dw namespace:create billing --description=\"prod invoicing\" --retention=90", + "help": "Create a logical namespace for workflow executions. Namespaces\npartition the visibility surface — workflows in different namespaces\nnever appear in each other's lists.\n\nExamples:\n\n dw namespace:create billing\n dw namespace:create billing --description=\"prod invoicing\" --retention=90", "definition": { "arguments": { "name": { @@ -4085,7 +4085,7 @@ "synopsis": "namespace:describe [-s|--server [SERVER]] [--namespace [NAMESPACE]] [--token [TOKEN]] [--tls-verify TLS-VERIFY] [--env ENV] [--output OUTPUT] [--json] [--] ", "aliases": [], "hidden": false, - "help": "Show configuration for a namespace \u2014 retention period, status, and\naudit timestamps.\n\nExamples:\n\n dw namespace:describe billing\n dw namespace:describe billing --json | jq '.retention_days'", + "help": "Show configuration for a namespace — retention period, status, and\naudit timestamps.\n\nExamples:\n\n dw namespace:describe billing\n dw namespace:describe billing --json | jq '.retention_days'", "definition": { "arguments": { "name": { @@ -5629,7 +5629,7 @@ "synopsis": "schedule:create [-s|--server [SERVER]] [--namespace [NAMESPACE]] [--token [TOKEN]] [--tls-verify TLS-VERIFY] [--env ENV] [--output OUTPUT] [--schedule-id [SCHEDULE-ID]] [-t|--workflow-type WORKFLOW-TYPE] [-c|--cron [CRON]] [--interval [INTERVAL]] [--task-queue [TASK-QUEUE]] [--timezone [TIMEZONE]] [--execution-timeout EXECUTION-TIMEOUT] [--run-timeout RUN-TIMEOUT] [--overlap-policy [OVERLAP-POLICY]] [--jitter JITTER] [--max-runs MAX-RUNS] [--paused] [--note [NOTE]] [--json] [-i|--input [INPUT]] [--input-file [INPUT-FILE]] [--input-encoding [INPUT-ENCODING]]", "aliases": [], "hidden": false, - "help": "Create a schedule that periodically starts a workflow. Provide either\na cron expression or an ISO 8601 interval (PT1H,\nPT30M, \u2026). Overlap policy controls what happens when\nthe next fire time arrives while the previous run is still executing.\n\nExamples:\n\n # Cron-based daily schedule\n dw schedules create --schedule-id=daily-report --workflow-type=reports.Daily --cron=\"0 6 * * *\"\n\n # Interval every 15 minutes\n dw schedules create --schedule-id=poller --workflow-type=sync.Poll --interval=PT15M\n\n # Create paused with a one-time cap\n dw schedules create --schedule-id=one-shot --workflow-type=migrate.Once --cron=\"0 0 1 * *\" --max-runs=1 --paused", + "help": "Create a schedule that periodically starts a workflow. Provide either\na cron expression or an ISO 8601 interval (PT1H,\nPT30M, …). Overlap policy controls what happens when\nthe next fire time arrives while the previous run is still executing.\n\nExamples:\n\n # Cron-based daily schedule\n dw schedules create --schedule-id=daily-report --workflow-type=reports.Daily --cron=\"0 6 * * *\"\n\n # Interval every 15 minutes\n dw schedules create --schedule-id=poller --workflow-type=sync.Poll --interval=PT15M\n\n # Create paused with a one-time cap\n dw schedules create --schedule-id=one-shot --workflow-type=migrate.Once --cron=\"0 0 1 * *\" --max-runs=1 --paused", "definition": { "arguments": [], "options": { @@ -5921,7 +5921,7 @@ "synopsis": "schedule:delete [-s|--server [SERVER]] [--namespace [NAMESPACE]] [--token [TOKEN]] [--tls-verify TLS-VERIFY] [--env ENV] [--output OUTPUT] [--json] [--] ", "aliases": [], "hidden": false, - "help": "Delete a schedule. In-flight runs it has already spawned keep running\n\u2014 only future fires are cancelled.\n\nExamples:\n\n dw schedules delete daily-report\n dw schedules delete daily-report --json", + "help": "Delete a schedule. In-flight runs it has already spawned keep running\n— only future fires are cancelled.\n\nExamples:\n\n dw schedules delete daily-report\n dw schedules delete daily-report --json", "definition": { "arguments": { "schedule-id": { @@ -7104,7 +7104,7 @@ "synopsis": "schedule:update [-s|--server [SERVER]] [--namespace [NAMESPACE]] [--token [TOKEN]] [--tls-verify TLS-VERIFY] [--env ENV] [--output OUTPUT] [-c|--cron [CRON]] [--interval [INTERVAL]] [-t|--workflow-type [WORKFLOW-TYPE]] [--task-queue [TASK-QUEUE]] [--execution-timeout EXECUTION-TIMEOUT] [--run-timeout RUN-TIMEOUT] [--overlap-policy [OVERLAP-POLICY]] [--jitter JITTER] [--max-runs MAX-RUNS] [--note [NOTE]] [--json] [-i|--input [INPUT]] [--input-file [INPUT-FILE]] [--input-encoding [INPUT-ENCODING]] [--] ", "aliases": [], "hidden": false, - "help": "Change a schedule in place. Pass only the fields you want to update \u2014\neverything else is left alone. At least one field is required.\n\nExamples:\n\n dw schedules update daily-report --cron=\"0 5 * * *\"\n dw schedules update daily-report --overlap-policy=skip --jitter=60\n dw schedules update daily-report --max-runs=100", + "help": "Change a schedule in place. Pass only the fields you want to update —\neverything else is left alone. At least one field is required.\n\nExamples:\n\n dw schedules update daily-report --cron=\"0 5 * * *\"\n dw schedules update daily-report --overlap-policy=skip --jitter=60\n dw schedules update daily-report --max-runs=100", "definition": { "arguments": { "schedule-id": { @@ -7974,7 +7974,7 @@ "search-attributes:list" ], "hidden": false, - "help": "List every search attribute \u2014 system-defined attributes shipped by the\nserver and any custom attributes registered for this namespace. When\n--namespace is omitted, the command queries the\nresolved default namespace only; it never enumerates all namespaces.\n\nExamples:\n\n dw search-attributes list\n dw search-attribute:list\n dw search-attribute:list --namespace=orders\n dw search-attributes list --output=json | jq '.custom_attributes'\n dw search-attribute:list --output=json | jq '.custom_attributes'", + "help": "List every search attribute — system-defined attributes shipped by the\nserver and any custom attributes registered for this namespace. When\n--namespace is omitted, the command queries the\nresolved default namespace only; it never enumerates all namespaces.\n\nExamples:\n\n dw search-attributes list\n dw search-attribute:list\n dw search-attribute:list --namespace=orders\n dw search-attributes list --output=json | jq '.custom_attributes'\n dw search-attribute:list --output=json | jq '.custom_attributes'", "definition": { "arguments": [], "options": { @@ -8398,11 +8398,11 @@ }, { "name": "server:start-dev", - "description": "Serve an existing Laravel project for local development", - "synopsis": "server:start-dev [-p|--port [PORT]] [--host [HOST]] [--db [DB]]", + "description": "Start the built-in native development Server", + "synopsis": "server:start-dev [-p|--port [PORT]] [--host [HOST]] [--db [DB]] [--data-dir DATA-DIR] [--token TOKEN]", "aliases": [], "hidden": false, - "help": "Run this command from an installed, configured Laravel project's directory.\nAn external PHP executable is required, even when using the native dw binary.\nThis launches Artisan's HTTP server. Configure the database and run migrations\nfirst, and run the project's queue worker and scheduler separately when needed.\nRestart this command after editing the project's .env configuration.\n\nSQLite is the default database driver. For mysql or\npgsql, your project's Docker Compose configuration must\ndefine that service and redis. Dependencies are left running\nwhen the HTTP process stops. The bind address defaults to loopback.\n\nFor a standalone Durable Workflow Server, follow the Docker-backed quickstart:\nhttps://durable-workflow.com/docs/quickstart/\n\nExamples:\n\n dw server:start-dev\n dw server:start-dev --port=9000\n dw server:start-dev --db=mysql", + "help": "Start a local Durable Workflow Server using the native runtime and SQLite\ncompiled into dw. Run it from any directory. No PHP, Docker, Redis, separate\nServer installation or runtime download is needed. The runtime creates its\nschema and default namespace and runs its own timers and maintenance.\n\nThe address defaults to 127.0.0.1:8080 and only loopback binds are accepted.\nUse --port=0 to choose an available port. Data persists under dw's config\nfolder in dev-server, or the directory selected with --data-dir. Only one\nServer may use a data directory at a time. Ctrl+C closes the runtime and keeps\nthe data. SDK workers still run your application code separately.\n\nThe local development token is dev-token. Set --token to choose another.\nExisting native DW_* payload, query and storage limits also apply. This is\na CLI-linked development build. Its source is pinned in the CLI dependency lock.\n\nExamples:\n\n dw server:start-dev\n dw server:start-dev --port=9000 --data-dir=./workflow-data\n\n # In another terminal, connect the CLI to the local Server\n dw env:set local --server=http://127.0.0.1:8080 --token=dev-token --make-default\n dw doctor", "definition": { "arguments": [], "options": { @@ -8412,7 +8412,7 @@ "accept_value": true, "is_value_required": false, "is_multiple": false, - "description": "Server port", + "description": "Server port (0 chooses an available port)", "default": "8080" }, "host": { @@ -8421,7 +8421,7 @@ "accept_value": true, "is_value_required": false, "is_multiple": false, - "description": "Bind address (IP address or localhost)", + "description": "Loopback bind address (127.0.0.1, ::1 or localhost)", "default": "127.0.0.1" }, "db": { @@ -8430,7 +8430,7 @@ "accept_value": true, "is_value_required": false, "is_multiple": false, - "description": "Database driver (sqlite, mysql, pgsql)", + "description": "Bundled database driver (sqlite)", "default": "sqlite" }, "help": { @@ -8504,6 +8504,24 @@ "is_multiple": false, "description": "Do not ask any interactive question", "default": false + }, + "data-dir": { + "name": "--data-dir", + "shortcut": "", + "accept_value": true, + "is_value_required": true, + "is_multiple": false, + "description": "Directory for persistent local development data", + "default": null + }, + "token": { + "name": "--token", + "shortcut": "", + "accept_value": true, + "is_value_required": true, + "is_multiple": false, + "description": "Development bearer token (defaults to dev-token)", + "default": null } } } @@ -11896,7 +11914,7 @@ "synopsis": "worker:register [-s|--server [SERVER]] [--namespace [NAMESPACE]] [--token [TOKEN]] [--tls-verify TLS-VERIFY] [--env ENV] [--output OUTPUT] [--task-queue TASK-QUEUE] [--runtime RUNTIME] [--sdk-version [SDK-VERSION]] [--build-id [BUILD-ID]] [--workflow-type [WORKFLOW-TYPE]] [--activity-type [ACTIVITY-TYPE]] [--max-workflow-tasks [MAX-WORKFLOW-TASKS]] [--max-activity-tasks [MAX-ACTIVITY-TASKS]] [--json] [--] []", "aliases": [], "hidden": false, - "help": "Register a worker identity for direct worker-protocol diagnostics. Real SDK\nworkers register automatically; this command is useful when smoke-testing\ntask routing, leases, and workflow-task completion from the CLI.\n\nThe --build-id option names the compatibility marker this\ndiagnostic worker advertises as part of its supported set. Markers are\nopaque strings; the engine performs exact-string equality and the\nworkers-only * wildcard only, so a diagnostic worker\nregistered without --build-id sees only runs that were\nstamped with no required marker. Reserve the wildcard for single-build\nfleets and test harnesses \u2014 runs themselves are never stamped with\n*.\n\nExamples:\n\n dw worker:register cli-worker --task-queue=orders --workflow-type=orders.Checkout\n dw worker:register py-worker --runtime=python --activity-type=email.send --json\n dw worker:register cli-2026-04-17 --task-queue=orders --build-id=build-2026-04-17", + "help": "Register a worker identity for direct worker-protocol diagnostics. Real SDK\nworkers register automatically; this command is useful when smoke-testing\ntask routing, leases, and workflow-task completion from the CLI.\n\nThe --build-id option names the compatibility marker this\ndiagnostic worker advertises as part of its supported set. Markers are\nopaque strings; the engine performs exact-string equality and the\nworkers-only * wildcard only, so a diagnostic worker\nregistered without --build-id sees only runs that were\nstamped with no required marker. Reserve the wildcard for single-build\nfleets and test harnesses — runs themselves are never stamped with\n*.\n\nExamples:\n\n dw worker:register cli-worker --task-queue=orders --workflow-type=orders.Checkout\n dw worker:register py-worker --runtime=python --activity-type=email.send --json\n dw worker:register cli-2026-04-17 --task-queue=orders --build-id=build-2026-04-17", "definition": { "arguments": { "worker-id": { @@ -13913,7 +13931,7 @@ "synopsis": "workflow:repair [-s|--server [SERVER]] [--namespace [NAMESPACE]] [--token [TOKEN]] [--tls-verify TLS-VERIFY] [--env ENV] [--output OUTPUT] [--json] [--] ", "aliases": [], "hidden": false, - "help": "Ask the server to re-enqueue the workflow's next task if it has stalled\nbehind a lost worker or expired lease. Repair is engine-level recovery:\nit routes to the same decision set, does not duplicate history, and the\nexactly-once-at-commit guarantee still holds for the typed history rows\nthe decision batch writes. Safe to call on a healthy workflow \u2014 the\nserver will no-op.\n\nExamples:\n\n dw workflow:repair chk-42\n dw workflow:repair chk-42 --json", + "help": "Ask the server to re-enqueue the workflow's next task if it has stalled\nbehind a lost worker or expired lease. Repair is engine-level recovery:\nit routes to the same decision set, does not duplicate history, and the\nexactly-once-at-commit guarantee still holds for the typed history rows\nthe decision batch writes. Safe to call on a healthy workflow — the\nserver will no-op.\n\nExamples:\n\n dw workflow:repair chk-42\n dw workflow:repair chk-42 --json", "definition": { "arguments": { "workflow-id": { @@ -14612,7 +14630,7 @@ "accept_value": true, "is_value_required": true, "is_multiple": false, - "description": "Relative weight (1..1000) for the fairness class \u2014 higher weights claim a proportionally larger share of dispatch slots", + "description": "Relative weight (1..1000) for the fairness class — higher weights claim a proportionally larger share of dispatch slots", "default": null }, "wait": { @@ -14741,7 +14759,7 @@ "synopsis": "workflow:terminate [-s|--server [SERVER]] [--namespace [NAMESPACE]] [--token [TOKEN]] [--tls-verify TLS-VERIFY] [--env ENV] [--output OUTPUT] [--reason [REASON]] [--run-id [RUN-ID]] [--json] [--] ", "aliases": [], "hidden": false, - "help": "Force-stop a workflow. Unlike workflow:cancel,\ntermination does not give the workflow a chance to clean up \u2014 use it\nfor runaway or unreachable workflows.\n\nExamples:\n\n dw workflow:terminate chk-42 --reason=\"runaway loop\"\n dw workflow:terminate chk-42 --run-id=01HZ...", + "help": "Force-stop a workflow. Unlike workflow:cancel,\ntermination does not give the workflow a chance to clean up — use it\nfor runaway or unreachable workflows.\n\nExamples:\n\n dw workflow:terminate chk-42 --reason=\"runaway loop\"\n dw workflow:terminate chk-42 --run-id=01HZ...", "definition": { "arguments": { "workflow-id": { @@ -15322,7 +15340,7 @@ "synopsis": "workflow-task:fail [-s|--server [SERVER]] [--namespace [NAMESPACE]] [--token [TOKEN]] [--tls-verify TLS-VERIFY] [--env ENV] [--output OUTPUT] [--lease-owner LEASE-OWNER] [-m|--message MESSAGE] [-t|--type [TYPE]] [--stack-trace [STACK-TRACE]] [--json] [--] ", "aliases": [], "hidden": false, - "help": "Report a worker-side failure for the named workflow-task attempt through\nthe worker protocol. Workflow tasks are replayed deterministically,\nnot retried against application logic, so this records that the worker\ncould not durably commit the decision \u2014 the engine replays the same\nhistory into a fresh task. It is distinct from completing a task with\na workflow-level fail_workflow command, which writes WorkflowFailed\ninto the run.\n\nExamples:\n\n dw workflow-task:fail task-123 1 --lease-owner=cli-worker --message=\"replay mismatch\"\n dw workflow-task:fail task-123 1 --message=\"bad command\" --type=NonDeterminism --stack-trace=trace.txt --json", + "help": "Report a worker-side failure for the named workflow-task attempt through\nthe worker protocol. Workflow tasks are replayed deterministically,\nnot retried against application logic, so this records that the worker\ncould not durably commit the decision — the engine replays the same\nhistory into a fresh task. It is distinct from completing a task with\na workflow-level fail_workflow command, which writes WorkflowFailed\ninto the run.\n\nExamples:\n\n dw workflow-task:fail task-123 1 --lease-owner=cli-worker --message=\"replay mismatch\"\n dw workflow-task:fail task-123 1 --message=\"bad command\" --type=NonDeterminism --stack-trace=trace.txt --json", "definition": { "arguments": { "task-id": { diff --git a/rust/src/commands/completion.rs b/rust/src/commands/completion.rs index 4ffb6fc..e338a99 100644 --- a/rust/src/commands/completion.rs +++ b/rust/src/commands/completion.rs @@ -678,7 +678,7 @@ fn run_complete(input: &Input) -> Result { if let Some(version) = version { if crate::compat::version::php_version_compare(version, COMPLETION_API_VERSION) < 0 { let message = format!("Completion script version is not supported (\"{version}\" given, \">={COMPLETION_API_VERSION}\" required)."); - log(&[message.clone()])?; + log(std::slice::from_ref(&message))?; writeln_out(&format!("{message} Install the Symfony completion script again by using the \"completion\" command.")); return Ok(126); } diff --git a/rust/src/commands/schema.rs b/rust/src/commands/schema.rs index 3256114..a999bfc 100644 --- a/rust/src/commands/schema.rs +++ b/rust/src/commands/schema.rs @@ -138,7 +138,7 @@ pub fn list(_input: &Input) -> Result { .iter() .filter_map(|(name, entry)| entry.as_object().map(|entry| (name, entry))) .collect(); - configs.sort_by(|(left, _), (right, _)| left.cmp(right)); + configs.sort_by_key(|(left, _)| *left); rows.extend(configs.into_iter().map(|(name, entry)| { vec![ "config".into(), diff --git a/rust/src/commands/start_dev.rs b/rust/src/commands/start_dev.rs index 248e017..379ecb3 100644 --- a/rust/src/commands/start_dev.rs +++ b/rust/src/commands/start_dev.rs @@ -1,490 +1,193 @@ -//! Run the current Laravel project's development server, as in 2.x. +//! Self-contained native Server for local development. use crate::cli::Input; use crate::error::CliError; -use crate::formatter::{self, writeln_out}; -use std::ffi::OsString; -use std::io::{self, Read, Write}; -use std::path::{Path, PathBuf}; -use std::process::{Command, ExitStatus, Stdio}; -use std::time::{Duration, Instant}; - -const COMPOSE_TIMEOUT: Duration = Duration::from_secs(120); +use crate::{config, formatter}; +use durable_workflow_server::runtime::{router, Runtime}; +use std::fs::{File, OpenOptions}; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::path::PathBuf; +use std::time::Duration; + +const DEVELOPMENT_TOKEN: &str = "dev-token"; const SHUTDOWN_GRACE: Duration = Duration::from_secs(5); -pub fn run(input: &Input) -> Result { - let Some(port) = input.opt_str("port").filter(|value| { - !value.is_empty() - && value.bytes().all(|byte| byte.is_ascii_digit()) - && value.parse::().is_ok_and(|port| port > 0) - }) else { - writeln_out("--port must be an integer between 1 and 65535."); - return Ok(2); - }; - let Some(db @ ("sqlite" | "mysql" | "pgsql")) = input.opt_str("db") else { - writeln_out("--db must be sqlite, mysql or pgsql."); - return Ok(2); - }; - let Some(host) = input - .opt_str("host") - .filter(|value| *value == "localhost" || value.parse::().is_ok()) - else { - writeln_out("--host must be an IP address or localhost."); - return Ok(2); - }; - let directory = match std::env::current_dir() { - Ok(directory) if readable_file(&directory.join("artisan")) => directory, - _ => { - writeln_out("No readable artisan file. Run this command from an installed, configured Laravel project."); - writeln_out( - "Standalone Server quickstart: https://durable-workflow.com/docs/quickstart/", - ); - return Ok(1); - } - }; - let Some(php) = executable("php", &directory) else { - writeln_out( - "An external PHP executable is required on PATH to run artisan.", - ); - return Ok(1); - }; - let signals = match signals::Guard::new() { - Ok(signals) => signals, - Err(_) => { - writeln_out("Development startup failed."); - writeln_out("Cannot register development process signal handlers."); - return Ok(1); - } - }; - let result = launch(&directory, &php, db, host, port, &signals); - match result { - Ok(code) => Ok(code), - Err(message) => { - writeln_out("Development startup failed."); - if formatter::verbosity() == formatter::Verbosity::Normal { - formatter::emit(formatter::Stream::Stdout, &(message + "\n")); +struct Settings { + address: SocketAddr, + directory: PathBuf, + token: String, +} + +impl Settings { + fn parse(input: &Input) -> Result { + let port = input + .opt_str("port") + .filter(|value| value.bytes().all(|byte| byte.is_ascii_digit())) + .and_then(|value| value.parse::().ok()) + .ok_or_else(|| { + CliError::Invalid( + "--port must be an integer between 0 and 65535 (0 selects an available port)." + .into(), + ) + })?; + if input.opt_str("db") != Some("sqlite") { + return Err(CliError::Invalid("The built-in development Server uses bundled SQLite. Use --db=sqlite; no database service is needed.".into())); + } + let address = match input.opt_str("host") { + Some("localhost") => IpAddr::V4(Ipv4Addr::LOCALHOST), + Some(value) => value.parse::().map_err(|_| { + CliError::Invalid("--host must be a loopback IP address or localhost.".into()) + })?, + None => { + return Err(CliError::Invalid( + "--host requires a loopback IP address or localhost.".into(), + )) } - Ok(1) - } - } -} - -fn launch( - directory: &Path, - php: &Path, - db: &str, - host: &str, - port: &str, - signals: &signals::Guard, -) -> Result { - if db != "sqlite" { - let Some(docker) = executable("docker", directory) else { - writeln_out("Docker Compose is required for mysql or pgsql mode. Install Docker and configure the project services."); - return Ok(1); }; - writeln_out(&format!( - "Starting the project's {db} and redis Compose services..." - )); - let mut command = Command::new(docker); - command - .args(["compose", "up", "-d", db, "redis"]) - .current_dir(directory); - let result = process(command, Some(COMPOSE_TIMEOUT), false, signals)?; - match result { - Outcome::Interrupted(signal) => return Ok(128 + signal), - Outcome::Exit(code) if code != 0 => { - writeln_out("Dependencies failed to start. Check the project Compose configuration and diagnostics above."); - return Ok(code); - } - _ => {} - } - } - if let Some(signal) = signals.received() { - return Ok(128 + signal); - } - let address = if host.contains(':') { - format!("[{host}]") - } else { - host.into() - }; - writeln_out(&format!( - "Starting Laravel HTTP server at http://{address}:{port}" - )); - writeln_out(&format!( - "Database driver: {db}. Press Ctrl+C to stop the HTTP process." - )); - let mut command = Command::new(php); - command - .arg("artisan") - .arg("serve") - .arg("--no-reload") - .arg(format!("--port={port}")) - .arg(format!("--host={host}")) - .current_dir(directory) - .env("DB_CONNECTION", db); - match process(command, None, true, signals)? { - Outcome::Interrupted(signal) => Ok(128 + signal), - Outcome::Exit(code) => { - if code != 0 { - writeln_out("Laravel HTTP process failed. Check the project configuration and diagnostics above."); - } - Ok(code) - } - } -} - -fn readable_file(path: &Path) -> bool { - path.is_file() && std::fs::File::open(path).is_ok() -} - -fn executable(name: &str, directory: &Path) -> Option { - let paths = std::env::var_os("PATH")?; - let extensions: Vec = if cfg!(windows) { - std::iter::once(OsString::new()) - .chain( - std::env::var_os("PATHEXT") - .unwrap_or_else(|| ".EXE;.BAT;.CMD;.COM".into()) - .to_string_lossy() - .split(';') - .map(OsString::from) - .collect::>(), - ) - .collect() - } else { - vec![OsString::new()] - }; - for path in std::env::split_paths(&paths) { - for extension in &extensions { - let mut filename = OsString::from(name); - filename.push(extension); - let candidate = directory.join(&path).join(filename); - if candidate.is_file() && executable_file(&candidate) { - return Some(candidate); - } - } - } - None -} - -#[cfg(unix)] -fn executable_file(path: &Path) -> bool { - use std::os::unix::ffi::OsStrExt; - std::ffi::CString::new(path.as_os_str().as_bytes()) - .is_ok_and(|path| unsafe { libc::access(path.as_ptr(), libc::X_OK) } == 0) -} - -#[cfg(not(unix))] -fn executable_file(_path: &Path) -> bool { - true -} - -enum Outcome { - Exit(i32), - Interrupted(i32), -} - -struct ManagedChild { - child: std::process::Child, - armed: bool, -} - -impl Drop for ManagedChild { - fn drop(&mut self) { - if self.armed { - signals::force_stop(&mut self.child); - } + if !address.is_loopback() { + return Err(CliError::Invalid( + "The development Server binds to loopback only. Use 127.0.0.1, ::1 or localhost." + .into(), + )); + } + let token = match input.opt("token").flatten() { + Some(value) if !value.trim().is_empty() => value.to_owned(), + Some(_) => return Err(CliError::Invalid("--token must not be empty.".into())), + None => DEVELOPMENT_TOKEN.into(), + }; + let directory = match input.opt("data-dir").flatten() { + Some("") => return Err(CliError::Invalid("--data-dir must not be empty.".into())), + Some(path) => PathBuf::from(path), + None => config::default_path()?.with_file_name("dev-server"), + }; + Ok(Self { + address: SocketAddr::new(address, port), + directory, + token, + }) + } + + fn prepare_directory(&mut self) -> Result { + let mut builder = std::fs::DirBuilder::new(); + builder.recursive(true); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + } + builder.create(&self.directory).map_err(|error| { + CliError::Failure(format!("Cannot create development data directory: {error}")) + })?; + self.directory = self.directory.canonicalize().map_err(|error| { + CliError::Failure(format!( + "Cannot resolve development data directory: {error}" + )) + })?; + let mut options = OpenOptions::new(); + options.create(true).truncate(false).read(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let lock = options + .open(self.directory.join("server.lock")) + .map_err(|error| { + CliError::Failure(format!("Cannot open development data lock: {error}")) + })?; + lock.try_lock().map_err(|_| CliError::Failure("The development data directory is already in use, or cannot be locked. Stop its Server or select another --data-dir.".into()))?; + Ok(lock) } } -fn terminal() -> Option { +pub fn run(input: &Input) -> Result { + let mut settings = Settings::parse(input)?; + let _lock = settings.prepare_directory()?; + // Keep SQLite and native's relative payload storage inside the selected + // data directory. A relative DB filename also works with Windows drives. + std::env::set_current_dir(&settings.directory).map_err(|error| { + CliError::Failure(format!("Cannot use development data directory: {error}")) + })?; + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .build() + .map_err(|error| { + CliError::Failure(format!("Cannot start the development runtime: {error}")) + })?; + runtime.block_on(serve(settings)) +} + +async fn stopped() -> i32 { #[cfg(unix)] { - std::fs::OpenOptions::new() - .read(true) - .write(true) - .open("/dev/tty") - .ok() + let mut terminate = + tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) + .expect("install SIGTERM handler"); + tokio::select! { + _ = tokio::signal::ctrl_c() => 130, + _ = terminate.recv() => 143, + } } #[cfg(not(unix))] { - None - } -} - -fn command_line(command: &Command) -> String { - std::iter::once(command.get_program()) - .chain(command.get_args()) - .map(|part| format!("'{}'", part.to_string_lossy().replace('\'', "'\\''"))) - .collect::>() - .join(" ") -} - -fn process( - mut command: Command, - timeout: Option, - allow_tty: bool, - signals: &signals::Guard, -) -> Result { - if let Some(signal) = signals.received() { - return Ok(Outcome::Interrupted(signal)); - } - let description = command_line(&command); - let reader = if let Some(tty) = allow_tty.then(terminal).flatten() { - command - .stdin(tty.try_clone().map_err(|error| error.to_string())?) - .stdout(tty.try_clone().map_err(|error| error.to_string())?) - .stderr(tty); - None - } else { - let (reader, writer) = os_pipe::pipe().map_err(|error| error.to_string())?; - command - .stdin(Stdio::null()) - .stdout(writer.try_clone().map_err(|error| error.to_string())?) - .stderr(writer); - Some(reader) + let _ = tokio::signal::ctrl_c().await; + 130 + } +} + +async fn serve(settings: Settings) -> Result { + let shutdown = stopped(); + tokio::pin!(shutdown); + // Reserve the address before opening/migrating the database. A busy port + // must not launch a second maintenance loop over the same data. + let listener = tokio::net::TcpListener::bind(settings.address) + .await + .map_err(|error| { + CliError::Failure(format!( + "Cannot bind development Server at {}: {error}", + settings.address + )) + })?; + let address = listener + .local_addr() + .map_err(|error| CliError::Failure(error.to_string()))?; + let runtime = tokio::select! { + result = Runtime::open("database.sqlite", settings.token.clone()) => result.map_err(|error| CliError::Failure(format!("Cannot open development database: {error}")))?, + code = &mut shutdown => return Ok(code), }; - signals::session(&mut command); - let child = command - .spawn() - .map_err(|error| format!("Unable to launch the development process: {error}"))?; - let mut managed = ManagedChild { child, armed: true }; - // Command retains copies of its pipe writers after spawn. Close them - // before waiting for EOF from the output reader. - drop(command); - let output = reader.map(|reader| std::thread::spawn(move || stream_output(reader))); - let start = Instant::now(); - let mut shutdown = None; - let mut status = None; - let result = loop { - if let Some(signal) = signals.received() { - let deadline = *shutdown.get_or_insert_with(|| { - signals::send(&mut managed.child, signal); - Instant::now() + SHUTDOWN_GRACE - }); - if status.is_none() { - status = poll(&mut managed.child)?; - } - if status.is_some() && !signals::group_alive(managed.child.id()) { - managed.armed = false; - break Ok(Outcome::Interrupted(signal)); - } - if Instant::now() >= deadline { - signals::force_stop(&mut managed.child); - managed.armed = false; - break Ok(Outcome::Interrupted(signal)); - } - } else { - if status.is_none() { - status = poll(&mut managed.child)?; - } - // Descendants may still hold the output pipe after the direct - // child exits. Keep handling interruption while it drains. - if let Some(status) = - status.filter(|_| output.as_ref().is_none_or(|reader| reader.is_finished())) - { - managed.armed = false; - break exit_status(status); - } - if timeout.is_some_and(|timeout| start.elapsed() >= timeout) { - signals::force_stop(&mut managed.child); - managed.armed = false; - break Err(format!( - "The process \"{description}\" exceeded the timeout of {} seconds.", - timeout.expect("checked").as_secs() - )); - } + runtime.set_maintenance_repair(true); + formatter::writeln_out(&format!( + "Durable Workflow development Server listening on http://{address}" + )); + formatter::writeln_out(&format!( + "Data: {}", + formatter::display_text(&settings.directory.to_string_lossy()) + )); + formatter::writeln_out("Built-in native runtime (development build)."); + if settings.token == DEVELOPMENT_TOKEN { + formatter::writeln_out("Development token: dev-token"); + formatter::writeln_out(&format!("Connect in another terminal: dw env:set local --server=http://{address} --token=dev-token --make-default")); + } else { + formatter::writeln_out("Authentication: the custom bearer token supplied with --token."); + } + formatter::writeln_out("Press Ctrl+C to stop. Workflow data is kept for the next start."); + + let (stop, finished) = tokio::sync::oneshot::channel::<()>(); + let service = router(runtime.clone()).into_make_service_with_connect_info::(); + let http = axum::serve(listener, service).with_graceful_shutdown(async { + let _ = finished.await; + }); + let http = std::future::IntoFuture::into_future(http); + tokio::pin!(http); + let outcome = tokio::select! { + result = &mut http => result.map(|()| 0).map_err(|error| CliError::Failure(format!("Development HTTP Server failed: {error}"))), + code = &mut shutdown => { + let _ = stop.send(()); + let _ = tokio::time::timeout(SHUTDOWN_GRACE, &mut http).await; + Ok(code) } - std::thread::sleep(Duration::from_millis(10)); }; - if let Some(output) = output { - let _ = output.join(); - } - result -} - -fn exit_status(status: ExitStatus) -> Result { - if let Some(code) = status.code() { - return Ok(Outcome::Exit(code)); - } - #[cfg(unix)] - { - use std::os::unix::process::ExitStatusExt; - Err(format!( - "The process has been signaled with signal \"{}\".", - status.signal().unwrap_or(0) - )) - } - #[cfg(not(unix))] - { - Ok(Outcome::Exit(1)) - } -} - -fn poll(child: &mut std::process::Child) -> Result, String> { - match child.try_wait() { - Err(error) if error.kind() == io::ErrorKind::Interrupted => Ok(None), - other => other.map_err(|error| error.to_string()), - } -} - -fn stream_output(mut reader: impl Read) { - let mut buffer = [0; 8192]; - let mut pending = Vec::new(); - loop { - let count = match reader.read(&mut buffer) { - Ok(count) => count, - Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, - Err(_) => break, - }; - if count == 0 { - break; - } - pending.extend_from_slice(&buffer[..count]); - loop { - match std::str::from_utf8(&pending) { - Ok(text) => { - formatter::write_out(text); - pending.clear(); - break; - } - Err(error) => { - let valid = error.valid_up_to(); - if valid > 0 { - formatter::write_out( - std::str::from_utf8(&pending[..valid]).expect("valid prefix"), - ); - } - let Some(length) = error.error_len() else { - pending.drain(..valid); - break; - }; - raw_output(&pending[valid..valid + length]); - pending.drain(..valid + length); - } - } - } - } - raw_output(&pending); -} - -fn raw_output(bytes: &[u8]) { - if formatter::verbosity() == formatter::Verbosity::Normal { - let _ = io::stdout().lock().write_all(bytes); - } -} - -#[cfg(unix)] -mod signals { - use super::*; - use std::sync::atomic::{AtomicI32, Ordering}; - static RECEIVED: AtomicI32 = AtomicI32::new(0); - extern "C" fn capture(signal: i32) { - // This lock-free operation is the only work in the signal handler. - let _ = RECEIVED.compare_exchange(0, signal, Ordering::Relaxed, Ordering::Relaxed); - } - pub struct Guard { - previous: Vec<(i32, libc::sigaction)>, - } - impl Guard { - pub fn new() -> io::Result { - RECEIVED.store(0, Ordering::Relaxed); - let mut guard = Self { - previous: Vec::new(), - }; - for signal in [libc::SIGINT, libc::SIGTERM] { - let mut action: libc::sigaction = unsafe { std::mem::zeroed() }; - action.sa_sigaction = capture as *const () as usize; - unsafe { - libc::sigemptyset(&mut action.sa_mask); - } - let mut previous = unsafe { std::mem::zeroed() }; - if unsafe { libc::sigaction(signal, &action, &mut previous) } != 0 { - return Err(io::Error::last_os_error()); - } - guard.previous.push((signal, previous)); - } - Ok(guard) - } - pub fn received(&self) -> Option { - match RECEIVED.load(Ordering::Relaxed) { - 0 => None, - signal => Some(signal), - } - } - } - impl Drop for Guard { - fn drop(&mut self) { - for (signal, action) in &self.previous { - unsafe { - libc::sigaction(*signal, action, std::ptr::null_mut()); - } - } - } - } - pub fn session(command: &mut Command) { - use std::os::unix::process::CommandExt; - unsafe { - command.pre_exec(|| { - if libc::setsid() < 0 { - Err(io::Error::last_os_error()) - } else { - Ok(()) - } - }); - } - } - pub fn send(child: &mut std::process::Child, signal: i32) { - unsafe { - libc::kill(-(child.id() as i32), signal); - } - } - pub fn group_alive(pid: u32) -> bool { - unsafe { libc::kill(-(pid as i32), 0) == 0 } - } - pub fn force_stop(child: &mut std::process::Child) { - send(child, libc::SIGKILL); - let _ = child.kill(); - let _ = child.wait(); - } -} - -#[cfg(not(unix))] -mod signals { - use super::*; - pub struct Guard; - impl Guard { - pub fn new() -> io::Result { - Ok(Self) - } - pub fn received(&self) -> Option { - None - } - } - pub fn session(_command: &mut Command) {} - pub fn group_alive(_pid: u32) -> bool { - false - } - pub fn send(child: &mut std::process::Child, _signal: i32) { - let _ = child.kill(); - } - pub fn force_stop(child: &mut std::process::Child) { - let _ = child.kill(); - let _ = child.wait(); - } -} - -#[cfg(all(test, unix))] -mod tests { - use super::*; - - #[test] - fn a_timed_out_compose_process_is_stopped_before_returning() { - let signals = signals::Guard::new().unwrap(); - let mut command = Command::new("/bin/sh"); - command.args(["-c", "exec /bin/sleep 30"]); - let started = Instant::now(); - let result = process(command, Some(Duration::from_millis(20)), false, &signals); - assert!(result.is_err_and(|message| message.contains("exceeded the timeout"))); - assert!(started.elapsed() < Duration::from_secs(1)); - } + runtime.close().await; + outcome } diff --git a/rust/src/compat/request_contract.rs b/rust/src/compat/request_contract.rs index a9d362e..b4e6956 100644 --- a/rust/src/compat/request_contract.rs +++ b/rust/src/compat/request_contract.rs @@ -112,8 +112,7 @@ fn rejected_alias<'a>(aliases: Option<&'a Value>, value: &str) -> Option<&'a str .filter(|item| !item.is_empty())?; Some((php::trim(key), item)) }) - .filter(|(key, _)| *key == value) - .next_back() + .rfind(|(key, _)| *key == value) .map(|(_, item)| item) } diff --git a/rust/src/completion.json b/rust/src/completion.json index 1660c65..cece758 100644 --- a/rust/src/completion.json +++ b/rust/src/completion.json @@ -27,7 +27,7 @@ }, { "value": "server:start-dev", - "description": "Serve an existing Laravel project for local development" + "description": "Start the built-in native development Server" }, { "value": "doctor", @@ -956,7 +956,10 @@ "mysql", "pgsql" ] - } + }, + "db": [ + "sqlite" + ] }, "service-call:describe": { "options": { diff --git a/rust/src/php.rs b/rust/src/php.rs index 2d24ed8..9c6b2f9 100644 --- a/rust/src/php.rs +++ b/rust/src/php.rs @@ -571,7 +571,7 @@ pub fn number_format(number: i64) -> String { let digits = number.unsigned_abs().to_string(); let mut grouped = String::new(); for (index, digit) in digits.chars().enumerate() { - if index > 0 && (digits.len() - index) % 3 == 0 { + if index > 0 && (digits.len() - index).is_multiple_of(3) { grouped.push(','); } grouped.push(digit); diff --git a/rust/tests/start_dev.rs b/rust/tests/start_dev.rs index 13553e9..77f3939 100644 --- a/rust/tests/start_dev.rs +++ b/rust/tests/start_dev.rs @@ -1,376 +1,419 @@ -#![cfg(unix)] - +//! The built dw starts a real native Server without any external executables. +use serde_json::Value; use std::fs; -use std::os::unix::fs::{DirBuilderExt, PermissionsExt}; -use std::os::unix::process::CommandExt; +use std::io::{BufRead, BufReader}; use std::path::PathBuf; use std::process::{Child, Command, Output, Stdio}; use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{mpsc, Arc, Mutex}; +use std::thread::JoinHandle; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; -static PROJECT_SEQUENCE: AtomicU64 = AtomicU64::new(0); - struct Project(PathBuf); - impl Project { fn new() -> Self { - let nonce = SystemTime::now() + static NEXT: AtomicU64 = AtomicU64::new(0); + let now = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_nanos(); - let sequence = PROJECT_SEQUENCE.fetch_add(1, Ordering::Relaxed); let root = std::env::temp_dir().join(format!( - "dw-dev-test-{}-{nonce}-{sequence}", - std::process::id() + "dw-native-dev-{}-{now}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) )); - fs::DirBuilder::new().mode(0o700).create(&root).unwrap(); - fs::create_dir(root.join("bin")).unwrap(); - fs::write(root.join("artisan"), "fixture project\n").unwrap(); + fs::create_dir(&root).unwrap(); + fs::create_dir(root.join("empty-bin")).unwrap(); Self(root) } - - fn tool(&self, name: &str, body: &str) { - let path = self.0.join("bin").join(name); - fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap(); - fs::set_permissions(path, fs::Permissions::from_mode(0o700)).unwrap(); - } - - fn php(&self) { - self.tool( - "php", - &format!( - "{}\n{}", - SIGNAL_FIXTURE, - r#" -printf '%s\n' "$@" > launch.args -printf '%s' "$DB_CONNECTION" > launch.db -if [ -f compose.started ]; then printf yes > compose.before; fi -printf 'child stdout λ\n' -printf 'child stderr\n' >&2 -exit "${DW_DEV_PHP_EXIT:-0}" -"# - ), - ); - } - - fn docker(&self) { - self.tool( - "docker", - &format!( - "{}\n{}", - SIGNAL_FIXTURE, - r#" -printf '%s\n' "$@" > compose.args -printf started > compose.started -exit "${DW_DEV_DOCKER_EXIT:-0}" -"# - ), - ); - } - - fn command(&self, args: &[&str]) -> Command { + fn command(&self, arguments: &[&str]) -> Command { let mut command = Command::new(env!("CARGO_BIN_EXE_dw")); command - .arg("server:start-dev") - .args(args) + .args(arguments) .current_dir(&self.0) .env_clear() .env("HOME", &self.0) - .env("DW_ENV", "missing-dev-test-profile") - .env("PATH", self.0.join("bin")) + .env("DW_CONFIG_HOME", self.0.join("config")) + .env("PATH", self.0.join("empty-bin")) .stdin(Stdio::null()); - // Keep these tests independent of the test runner's own terminal. - unsafe { - command.pre_exec(|| { - if libc::setsid() < 0 { - Err(std::io::Error::last_os_error()) - } else { - Ok(()) - } - }); + #[cfg(windows)] + { + // Windows' network and SQLite libraries need the OS directory, + // independent of PATH and any developer tools. + if let Some(root) = std::env::var_os("SystemRoot") { + command.env("SystemRoot", root); + } } command } - - fn run(&self, args: &[&str], env: &[(&str, &str)]) -> Output { - let mut command = self.command(args); - command.envs(env.iter().copied()); - wait( - command - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .unwrap(), - Duration::from_secs(8), - ) + fn run(&self, args: &[&str]) -> Output { + let child = self + .command(args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + wait(child, Duration::from_secs(10)) + } + fn client(&self, server: &DevServer, command: &[&str]) -> Value { + let mut args = command.to_vec(); + args.extend([ + "--server", + &server.url, + "--token=dev-token", + "--output=json", + ]); + let output = self.run(&args); + assert!( + output.status.success(), + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + serde_json::from_slice(&output.stdout).unwrap() } } - impl Drop for Project { fn drop(&mut self) { - if let Ok(pid) = fs::read_to_string(self.0.join("child.pid")) { - if let Ok(pid) = pid.trim().parse::() { - // Only the fixture's still-existing session may be cleaned up. - let descendant = fs::read_to_string(self.0.join("descendant.pid")) - .ok() - .and_then(|pid| pid.trim().parse::().ok()); - if unsafe { libc::getpgid(pid) } == pid - || descendant.is_some_and(|child| unsafe { libc::getpgid(child) } == pid) - { - unsafe { - libc::kill(-pid, libc::SIGKILL); - } - } - } - } let _ = fs::remove_dir_all(&self.0); } } -const SIGNAL_FIXTURE: &str = r#" -if [ -n "$DW_DEV_SIGNAL_MODE" ]; then - trap 'printf 2 > received.signal; exit 0' INT - trap 'printf 15 > received.signal; exit 0' TERM - if [ "$DW_DEV_SIGNAL_MODE" = ignore ]; then trap '' INT TERM; fi - printf '%s' "$$" > child.pid - while :; do /bin/sleep 1; done -fi -"#; - -fn wait(mut child: Child, timeout: Duration) -> Output { - let deadline = Instant::now() + timeout; +fn wait(mut child: Child, duration: Duration) -> Output { + let deadline = Instant::now() + duration; while child.try_wait().unwrap().is_none() { if Instant::now() >= deadline { let _ = child.kill(); let _ = child.wait(); - panic!("development process exceeded its test deadline"); + panic!("dw exceeded test deadline"); } std::thread::sleep(Duration::from_millis(10)); } child.wait_with_output().unwrap() } - -fn recorded_pid(project: &Project, filename: &str) -> Option { - fs::read_to_string(project.0.join(filename)) - .ok()? - .trim() - .parse() - .ok() +struct DevServer { + child: Child, + url: String, + text: Arc>, + reader: Option>, } - -#[test] -fn validation_precedes_every_child_launch() { - let project = Project::new(); - project.php(); - project.docker(); - for args in [ - vec!["--port=0"], - vec!["--port=65536"], - vec!["--port=-1"], - vec!["--port=8080.5"], - vec!["--port"], - vec!["--db=unknown"], - vec!["--db"], - vec!["--host=http://localhost"], - vec!["--host"], - ] { - let output = project.run(&args, &[]); - assert_eq!(output.status.code(), Some(2)); - assert!(output.stderr.is_empty()); - assert!(!project.0.join("launch.args").exists()); - assert!(!project.0.join("compose.args").exists()); +impl DevServer { + fn start(project: &Project, extra: &[&str]) -> Self { + let mut args = vec!["server:start-dev", "--port=0"]; + args.extend(extra); + let mut child = project + .command(&args) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .spawn() + .unwrap(); + let stdout = child.stdout.take().unwrap(); + let (ready, receiver) = mpsc::channel(); + let text = Arc::new(Mutex::new(String::new())); + let captured = text.clone(); + let reader = std::thread::spawn(move || { + for line in BufReader::new(stdout).lines() { + let Ok(line) = line else { break }; + if let Some((_, url)) = line.split_once("listening on ") { + let _ = ready.send(url.to_owned()); + } + captured.lock().unwrap().push_str(&line); + captured.lock().unwrap().push('\n'); + } + }); + let url = match receiver.recv_timeout(Duration::from_secs(15)) { + Ok(url) => url, + Err(error) => { + let _ = child.kill(); + let _ = child.wait(); + let _ = reader.join(); + panic!("native startup failed: {error}: {}", text.lock().unwrap()); + } + }; + Self { + child, + url, + text, + reader: Some(reader), + } + } + fn stop(&mut self) -> i32 { + #[cfg(unix)] + unsafe { + libc::kill(self.child.id() as i32, libc::SIGTERM); + } + #[cfg(not(unix))] + { + let _ = self.child.kill(); + } + let deadline = Instant::now() + Duration::from_secs(8); + let code = loop { + if let Some(status) = self.child.try_wait().unwrap() { + break status.code().unwrap_or(1); + } + if Instant::now() >= deadline { + let _ = self.child.kill(); + let _ = self.child.wait(); + panic!("native shutdown exceeded deadline"); + } + std::thread::sleep(Duration::from_millis(10)); + }; + if let Some(reader) = self.reader.take() { + reader.join().unwrap(); + } + code + } + fn get(&self, path: &str, token: Option<&str>) -> (u16, Value) { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime.block_on(async { + let client = reqwest::Client::builder() + .no_proxy() + .timeout(Duration::from_secs(3)) + .build() + .unwrap(); + let mut request = client.get(format!("{}{path}", self.url)); + if let Some(token) = token { + request = request.bearer_auth(token); + } + let response = request.send().await.unwrap(); + let status = response.status().as_u16(); + let body = response.json().await.unwrap(); + (status, body) + }) + } +} +impl Drop for DevServer { + fn drop(&mut self) { + if self.child.try_wait().ok().flatten().is_none() { + let _ = self.child.kill(); + let _ = self.child.wait(); + } + if let Some(reader) = self.reader.take() { + let _ = reader.join(); + } } } #[test] -fn missing_prerequisites_refuse_without_launching_http() { +fn starts_in_an_empty_directory_with_bundled_sqlite_and_real_cli_admission() { let project = Project::new(); - let output = project.run(&[], &[]); - assert_eq!(output.status.code(), Some(1)); - assert!(String::from_utf8_lossy(&output.stdout).contains("external PHP executable")); - project.php(); - let output = project.run(&["--db=mysql"], &[]); - assert_eq!(output.status.code(), Some(1)); - assert!(String::from_utf8_lossy(&output.stdout).contains("Docker Compose is required")); - fs::remove_file(project.0.join("artisan")).unwrap(); - let output = project.run(&[], &[]); - assert_eq!(output.status.code(), Some(1)); - assert!(String::from_utf8_lossy(&output.stdout).contains("No readable artisan file")); - assert!(!project.0.join("launch.args").exists()); + let mut server = DevServer::start(&project, &[]); + assert_eq!(server.get("/api/health", None).0, 200); + let (status, ready) = server.get("/api/ready", None); + assert_eq!(status, 200); + assert_eq!(ready["development"], true); + assert_eq!(server.get("/api/cluster/info", None).0, 401); + let cluster = project.client(&server, &["server:info"]); + assert!(cluster.get("capabilities").is_some()); + let started = project.client( + &server, + &[ + "workflow:start", + "--type=dev.example", + "--workflow-id=dev-first", + "--input={}", + ], + ); + assert!(started.to_string().contains("dev-first")); + let listed = project.client(&server, &["workflow:list"]); + assert!(listed.to_string().contains("dev-first")); + let described = project.client(&server, &["workflow:describe", "dev-first"]); + assert!(described.to_string().contains("dev-first")); + assert!(project + .0 + .join("config/dev-server/database.sqlite") + .is_file()); + assert!(!project.0.join("artisan").exists()); + let code = server.stop(); + #[cfg(unix)] + assert_eq!(code, 143); } #[test] -fn sqlite_forwards_arguments_and_merges_formatted_stdout_and_stderr() { - for host in ["127.0.0.1", "localhost", "0.0.0.0", "::1"] { - let project = Project::new(); - project.php(); - let output = project.run(&["--port=09090", &format!("--host={host}")], &[]); - assert!(output.status.success()); - assert!(output.stderr.is_empty()); - assert_eq!( - fs::read_to_string(project.0.join("launch.args")).unwrap(), - format!("artisan\nserve\n--no-reload\n--port=09090\n--host={host}\n") - ); - assert_eq!( - fs::read_to_string(project.0.join("launch.db")).unwrap(), - "sqlite" +fn restart_preserves_workflow_data_and_releases_the_directory_lock() { + let project = Project::new(); + let directory = project.0.join("data with spaces"); + let args = ["--data-dir", directory.to_str().unwrap()]; + let mut first = DevServer::start(&project, &args); + project.client( + &first, + &[ + "workflow:start", + "--type=dev.example", + "--workflow-id=keep-me", + "--input=null", + ], + ); + let before = project.client(&first, &["workflow:describe", "keep-me"]); + assert_eq!(before["workflow_id"], "keep-me"); + for name in ["run_id", "workflow_type", "status"] { + assert!( + before[name].as_str().is_some_and(|value| !value.is_empty()), + "missing {name}" ); - assert!(String::from_utf8_lossy(&output.stdout).ends_with("child stdout λ\nchild stderr\n")); } + first.stop(); + let mut second = DevServer::start(&project, &args); + let after = project.client(&second, &["workflow:describe", "keep-me"]); + for name in ["workflow_id", "run_id", "workflow_type", "status"] { + assert_eq!(before[name], after[name], "{name}"); + } + assert!(after.to_string().contains("keep-me")); + second.stop(); } #[test] -fn compose_must_succeed_before_http_and_both_child_exit_codes_are_preserved() { - for db in ["mysql", "pgsql"] { - let project = Project::new(); - project.php(); - project.docker(); - let output = project.run(&[&format!("--db={db}")], &[("DW_DEV_DOCKER_EXIT", "17")]); - assert_eq!(output.status.code(), Some(17)); - assert!(!project.0.join("launch.args").exists()); - assert!(!String::from_utf8_lossy(&output.stdout).contains("Starting Laravel HTTP server")); - let output = project.run(&[&format!("--db={db}")], &[("DW_DEV_PHP_EXIT", "42")]); - assert_eq!(output.status.code(), Some(42)); - assert_eq!( - fs::read_to_string(project.0.join("compose.args")).unwrap(), - format!("compose\nup\n-d\n{db}\nredis\n") - ); - assert_eq!(fs::read_to_string(project.0.join("launch.db")).unwrap(), db); - assert_eq!( - fs::read_to_string(project.0.join("compose.before")).unwrap(), - "yes" - ); - assert!(String::from_utf8_lossy(&output.stdout).contains("Laravel HTTP process failed")); - } +fn same_data_directory_cannot_start_two_runtimes() { + let project = Project::new(); + let mut first = DevServer::start(&project, &[]); + let output = project.run(&["server:start-dev", "--port=0"]); + assert_eq!(output.status.code(), Some(1)); + assert!(String::from_utf8_lossy(&output.stderr).contains("already in use")); + assert_eq!(first.get("/api/ready", None).0, 200); + first.stop(); } #[test] -fn quiet_suppresses_child_output_without_changing_its_exit_status() { +fn busy_port_refuses_before_creating_a_database() { let project = Project::new(); - project.php(); - let output = project.run(&["-q"], &[("DW_DEV_PHP_EXIT", "42")]); - assert_eq!(output.status.code(), Some(42)); - assert!(output.stdout.is_empty() && output.stderr.is_empty()); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = format!("--port={}", listener.local_addr().unwrap().port()); + let output = project.run(&["server:start-dev", &port]); + assert_eq!(output.status.code(), Some(1)); + assert!(!project.0.join("config/dev-server/database.sqlite").exists()); } -fn interrupt(db: &str, signal: i32, ignore: bool, second: bool) { +#[test] +fn invalid_arguments_do_not_create_runtime_data() { let project = Project::new(); - project.php(); - project.docker(); - let mut command = project.command(&[&format!("--db={db}")]); - command.env("DW_DEV_SIGNAL_MODE", if ignore { "ignore" } else { "wait" }); - let mut child = command - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .unwrap(); - let start_deadline = Instant::now() + Duration::from_secs(5); - while recorded_pid(&project, "child.pid").is_none() { - assert!( - child.try_wait().unwrap().is_none(), - "CLI stopped before child startup" - ); - if Instant::now() >= start_deadline { - let _ = child.kill(); - let _ = child.wait(); - panic!("fixture child did not start"); - } - std::thread::sleep(Duration::from_millis(10)); - } - let started = Instant::now(); - assert_eq!(unsafe { libc::kill(child.id() as i32, signal) }, 0); - if second { - std::thread::sleep(Duration::from_secs(2)); - assert_eq!(unsafe { libc::kill(child.id() as i32, libc::SIGTERM) }, 0); - } - let output = wait(child, Duration::from_secs(7)); - assert_eq!(output.status.code(), Some(128 + signal)); - let pid: i32 = fs::read_to_string(project.0.join("child.pid")) - .unwrap() - .trim() - .parse() - .unwrap(); - assert_ne!( - unsafe { libc::kill(pid, 0) }, - 0, - "managed child survived interruption" - ); - if db == "mysql" { - assert!(!String::from_utf8_lossy(&output.stdout).contains("Starting Laravel HTTP server")); - } - if ignore { - assert!(!project.0.join("received.signal").exists()); - assert!(started.elapsed() >= Duration::from_secs(5)); - assert!(started.elapsed() < Duration::from_millis(6500)); - } else { + for flags in [ + vec!["--port=65536"], + vec!["--port=-1"], + vec!["--port=8080.5"], + vec!["--port"], + vec!["--db=mysql"], + vec!["--db=pgsql"], + vec!["--db"], + vec!["--host=0.0.0.0"], + vec!["--host=example.test"], + vec!["--host"], + vec!["--token="], + vec!["--data-dir="], + ] { + let mut args = vec!["server:start-dev"]; + args.extend(flags); + let output = project.run(&args); assert_eq!( - fs::read_to_string(project.0.join("received.signal")).unwrap(), - signal.to_string() + output.status.code(), + Some(2), + "{args:?}: {}", + String::from_utf8_lossy(&output.stderr) ); + assert!(!project.0.join("config/dev-server").exists()); } } #[test] -fn sigint_and_sigterm_are_forwarded_during_compose_and_http() { - for db in ["sqlite", "mysql"] { - for signal in [libc::SIGINT, libc::SIGTERM] { - interrupt(db, signal, false, false); - } - } -} - -#[test] -fn an_ignored_signal_cannot_keep_children_alive_past_five_seconds() { - interrupt("sqlite", libc::SIGTERM, true, false); -} - -#[test] -fn a_second_interrupt_preserves_the_first_signal_and_cleanup_deadline() { - interrupt("sqlite", libc::SIGINT, true, true); +fn custom_token_is_enforced_and_is_not_printed() { + let project = Project::new(); + let mut server = DevServer::start(&project, &["--token=synthetic-custom-token"]); + assert_eq!(server.get("/api/cluster/info", Some("dev-token")).0, 401); + assert_eq!( + server + .get("/api/cluster/info", Some("synthetic-custom-token")) + .0, + 200 + ); + server.stop(); + assert!(!server + .text + .lock() + .unwrap() + .contains("synthetic-custom-token")); } +#[cfg(unix)] #[test] -fn interruption_still_stops_descendants_while_the_output_pipe_drains() { +fn sigint_closes_http_and_allows_immediate_restart() { let project = Project::new(); - project.tool( - "php", - r#" -printf '%s' "$$" > child.pid -/bin/sh -c 'trap "exit 0" TERM; printf "%s" "$$" > descendant.pid; while :; do /bin/sleep 1; done' & -exit 0 -"#, - ); - let mut child = project - .command(&[]) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .unwrap(); - let deadline = Instant::now() + Duration::from_secs(3); - while recorded_pid(&project, "descendant.pid").is_none() { - if Instant::now() >= deadline { - let _ = child.kill(); - let _ = child.wait(); - panic!("descendant did not start"); + let mut server = DevServer::start(&project, &[]); + unsafe { + libc::kill(server.child.id() as i32, libc::SIGINT); + } + let deadline = Instant::now() + Duration::from_secs(8); + loop { + if let Some(status) = server.child.try_wait().unwrap() { + assert_eq!(status.code(), Some(130)); + break; } + assert!(Instant::now() < deadline); std::thread::sleep(Duration::from_millis(10)); } - assert!(child.try_wait().unwrap().is_none()); - assert_eq!(unsafe { libc::kill(child.id() as i32, libc::SIGTERM) }, 0); - let output = wait(child, Duration::from_secs(7)); - assert_eq!(output.status.code(), Some(143)); - let pid: i32 = fs::read_to_string(project.0.join("descendant.pid")) - .unwrap() - .trim() - .parse() + let mut restarted = DevServer::start(&project, &[]); + assert_eq!(restarted.get("/api/ready", None).0, 200); + restarted.stop(); +} + +#[test] +fn actual_sdk_worker_completes_a_workflow_after_a_durable_timer() { + use durable_workflow::{Client, Worker, WorkflowResultOptions}; + let project = Project::new(); + let mut server = DevServer::start(&project, &[]); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() .unwrap(); - let deadline = Instant::now() + Duration::from_secs(1); - while unsafe { libc::kill(pid, 0) } == 0 && Instant::now() < deadline { - std::thread::sleep(Duration::from_millis(10)); - } - assert_ne!( - unsafe { libc::kill(pid, 0) }, - 0, - "descendant survived interruption" + runtime.block_on(async { + let client = Client::builder(server.url.clone()) + .token(Some("dev-token".into())) + .build() + .unwrap(); + let mut worker = Worker::new(client.clone(), "cli-dev-test") + .worker_id("cli-dev-worker") + .poll_timeout(Duration::ZERO); + worker.register_typed_workflow("cli.dev.timer", |ctx, value: i64| async move { + ctx.sleep(Duration::from_secs(1)).await?; + Ok(value + 1) + }); + worker.register().await.unwrap(); + let handle = client + .start_workflow("cli.dev.timer", "cli-dev-test", "sdk-timer", 41) + .await + .unwrap(); + tokio::time::timeout(Duration::from_secs(15), async { + loop { + worker.run_once().await.unwrap(); + if handle.describe().await.unwrap().is_terminal() { + break; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .expect("embedded runtime did not fire its timer and complete the workflow"); + let result: i64 = handle + .result_typed(WorkflowResultOptions { + poll_interval: Duration::from_millis(20), + timeout: Duration::from_secs(2), + }) + .await + .unwrap(); + assert_eq!(result, 42); + worker.run_until(async {}).await.unwrap(); + }); + let description = project.client(&server, &["workflow:describe", "sdk-timer"]); + assert_eq!(description["status"], "completed"); + let history = project.client( + &server, + &[ + "workflow:history", + "sdk-timer", + description["run_id"].as_str().unwrap(), + ], ); + assert!(history.to_string().contains("TimerFired")); + server.stop(); } diff --git a/scripts/ci/smoke-native-dev.py b/scripts/ci/smoke-native-dev.py new file mode 100644 index 0000000..20cb649 --- /dev/null +++ b/scripts/ci/smoke-native-dev.py @@ -0,0 +1,142 @@ +"""Exercise the development runtime in a staged standalone dw executable.""" +import argparse +import http.client +import json +import os +from pathlib import Path +import queue +import re +import signal +import subprocess +import tempfile +import threading +import time + + +def start(binary, root, environment): + process = subprocess.Popen( + [str(binary), 'server:start-dev', '--port=0', '--data-dir', str(root/'data')], + cwd=root, env=environment, stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + ) + address = queue.Queue() + output = [] + + def read(): + for line in process.stdout: + output.append(line) + match = re.fullmatch(r'Durable Workflow development Server listening on http://127\.0\.0\.1:([0-9]{1,5})\n', line) + if match and 0 < int(match[1]) <= 65535: + address.put(int(match[1])) + + reader = threading.Thread(target=read, daemon=True) + reader.start() + try: + port = address.get(timeout=20) + except queue.Empty as error: + process.kill() + process.wait(timeout=5) + reader.join(timeout=5) + raise RuntimeError('embedded runtime did not start: '+''.join(output)+process.stderr.read()) from error + return process, reader, port + + +def stop(process, reader): + if process.poll() is None: + if os.name == 'posix': + process.send_signal(signal.SIGINT) + else: + process.terminate() + try: + process.wait(timeout=10) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + raise RuntimeError('development Server did not stop') from None + reader.join(timeout=5) + if os.name == 'posix' and process.returncode != 130: + raise RuntimeError(f'development Server shutdown returned {process.returncode}') + + +def readiness(port, expected_status=200): + # Fixed numeric loopback destination. Proxy/environment settings and a + # runtime-provided URL cannot redirect the acceptance requests elsewhere. + connection = http.client.HTTPConnection('127.0.0.1', port, timeout=5) + try: + connection.request('GET', '/api/ready') + response = connection.getresponse() + body = response.read(65536) + document = json.loads(body) + if response.status != expected_status or document.get('development') is not True: + raise RuntimeError('unexpected development Server readiness') + if expected_status == 503 and b'storage_admission_unavailable' not in body: + raise RuntimeError('unsupported file admission did not fail closed') + finally: + connection.close() + + +def cli(binary, root, environment, port, arguments): + result = subprocess.run( + [str(binary), *arguments, f'--server=http://127.0.0.1:{port}', + '--token=dev-token', '--output=json'], + cwd=root, env=environment, stdin=subprocess.DEVNULL, + capture_output=True, text=True, timeout=15, check=False, + ) + if result.returncode: + raise RuntimeError('CLI request failed: '+result.stdout+result.stderr) + return json.loads(result.stdout) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--binary', type=Path, required=True) + binary = parser.parse_args().binary.resolve(strict=True) + with tempfile.TemporaryDirectory(prefix='dw-native-asset-') as temporary: + root = Path(temporary) + (root/'empty-bin').mkdir() + environment = {'HOME':str(root), 'DW_CONFIG_HOME':str(root/'config'), + 'PATH':str(root/'empty-bin'), 'TMPDIR':str(root), + 'TEMP':str(root), 'TMP':str(root)} + if os.name == 'nt' and 'SystemRoot' in os.environ: + environment['SystemRoot'] = os.environ['SystemRoot'] + process, reader, port = start(binary, root, environment) + try: + readiness(port) + cli(binary, root, environment, port, ['workflow:start', '--type=cli.asset-smoke', + '--workflow-id=cli-asset-smoke', '--input={}']) + before = cli(binary, root, environment, port, ['workflow:describe', 'cli-asset-smoke']) + if before.get('workflow_id') != 'cli-asset-smoke': + raise RuntimeError('new workflow was not visible') + for name in ['run_id', 'workflow_type', 'status']: + if not isinstance(before.get(name), str) or not before[name]: + raise RuntimeError('missing workflow identity/state: '+name) + finally: + stop(process, reader) + process, reader, port = start(binary, root, environment) + try: + readiness(port) + after = cli(binary, root, environment, port, ['workflow:describe', 'cli-asset-smoke']) + for name in ['workflow_id', 'run_id', 'workflow_type', 'status']: + if before.get(name) != after.get(name): + raise RuntimeError('workflow identity/state changed over restart: '+name) + if 'cli-asset-smoke' not in json.dumps(after): + raise RuntimeError('workflow did not survive restart') + finally: + stop(process, reader) + observation = root/'observation.json' + observation.write_text(json.dumps({'schema':'durable-workflow.storage-pressure.v1', + 'source':'cli-asset-test', 'state':'normal', 'observed_at':int(time.time())})) + observation.chmod(0o600) + configured = {**environment, 'DW_STORAGE_ADMISSION_FILE':str(observation), + 'DW_STORAGE_ADMISSION_SOURCE':'cli-asset-test', + 'DW_STORAGE_ADMISSION_MAX_AGE_SECONDS':'300'} + process, reader, port = start(binary, root, configured) + try: + readiness(port, 200 if os.name == 'posix' else 503) + finally: + stop(process, reader) + print('Built-in native Server: empty PATH, readiness, workflow admission, restart and platform admission PASS') + + +if __name__ == '__main__': + main() diff --git a/tests/fixtures/php-reference-workflow.yml b/tests/fixtures/php-reference-workflow.yml index 21babd1..528c3e6 100644 --- a/tests/fixtures/php-reference-workflow.yml +++ b/tests/fixtures/php-reference-workflow.yml @@ -20,10 +20,10 @@ jobs: uses: durable-workflow/.github/.github/actions/bootstrap-patched-pip@75587fc33ff40c34f32ef777293dad56bb6f48c7 # clean-trust with: clients: pip,python,composer,cargo,curl,git - - name: Install Rust 1.86 + - name: Install Rust 1.99 uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable with: - toolchain: 1.86.0 + toolchain: 1.99.0 components: rustfmt, clippy - run: cargo fmt --check - run: cargo clippy --locked --all-targets -- -D warnings