From 840277104b165d24ce9a6b523009ecaf5ec5c8a6 Mon Sep 17 00:00:00 2001
From: Richard McDaniel <1130888+rmcdaniel@users.noreply.github.com>
Date: Sat, 14 Nov 2026 11:31:16 +0000
Subject: [PATCH 1/3] Embed the native Server in the CLI development command
---
.github/workflows/build.yml | 8 +-
.github/workflows/rust-release-build.yml | 4 +-
README.md | 22 +
docs/cli-reference.md | 54 +-
rust/Cargo.toml | 23 +-
rust/README.md | 2 +-
rust/parity/catalog-updates.json | 242 +++++++++
rust/parity/parity.py | 8 +
rust/src/catalog.json | 62 ++-
rust/src/commands/start_dev.rs | 648 +++++++----------------
rust/src/completion.json | 7 +-
rust/tests/start_dev.rs | 575 ++++++++++----------
12 files changed, 827 insertions(+), 828 deletions(-)
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..7c56a8f 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
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..0d12abe 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 exact embedded source is printed
+at startup. 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.toml b/rust/Cargo.toml
index 50b774b..1d80a27 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,6 +13,10 @@ name = "dw"
path = "src/main.rs"
[dependencies]
+# 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 = "b6e04ce7e679464e91f6ceae83310d3bb3204092" }
+axum = { version = "=0.8.9", default-features = false, features = ["http1", "tokio"] }
# Pinned to the latest qualified published SDK release. Move only after
# sdk-rust's published-package acceptance.
durable-workflow = "=4.12.0"
@@ -21,13 +25,11 @@ 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 = "b6e04ce7e679464e91f6ceae83310d3bb3204092" }
+sqlx-mysql = { git = "https://github.com/durable-workflow/server", rev = "b6e04ce7e679464e91f6ceae83310d3bb3204092" }
+sqlx-postgres = { git = "https://github.com/durable-workflow/server", rev = "b6e04ce7e679464e91f6ceae83310d3bb3204092" }
+
+[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..f58e94d 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, with its embedded source shown at startup.\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..810e3e5 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, with its embedded source shown at startup.\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/start_dev.rs b/rust/src/commands/start_dev.rs
index 248e017..6bbf9eb 100644
--- a/rust/src/commands/start_dev.rs
+++ b/rust/src/commands/start_dev.rs
@@ -1,490 +1,196 @@
-//! 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 SERVER_SOURCE: &str = "b6e04ce7e679464e91f6ceae83310d3bb3204092";
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(&format!(
+ "Embedded native source: {SERVER_SOURCE} (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