diff --git a/.env.example b/.env.example
index 1e96ec5..beee24b 100644
--- a/.env.example
+++ b/.env.example
@@ -37,9 +37,9 @@ SESSION_DOMAIN=null
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
-QUEUE_CONNECTION=database
+QUEUE_CONNECTION=redis
-CACHE_STORE=database
+CACHE_STORE=redis
# CACHE_PREFIX=
MEMCACHED_HOST=127.0.0.1
diff --git a/.gitignore b/.gitignore
index c866d82..fc82c6e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,3 +26,5 @@ Thumbs.db
**/caddy
frankenphp
frankenphp-worker.php
+rr
+.rr.yaml
diff --git a/.junie/guidelines.md b/.junie/guidelines.md
new file mode 100644
index 0000000..41905cf
--- /dev/null
+++ b/.junie/guidelines.md
@@ -0,0 +1,228 @@
+
+=== foundation rules ===
+
+# Laravel Boost Guidelines
+
+The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications.
+
+## Foundational Context
+This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
+
+- php - 8.3.28
+- laravel/framework (LARAVEL) - v12
+- laravel/horizon (HORIZON) - v5
+- laravel/octane (OCTANE) - v2
+- laravel/prompts (PROMPTS) - v0
+- laravel/telescope (TELESCOPE) - v5
+- laravel/mcp (MCP) - v0
+- laravel/pint (PINT) - v1
+- laravel/sail (SAIL) - v1
+- phpunit/phpunit (PHPUNIT) - v11
+
+## Conventions
+- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming.
+- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
+- Check for existing components to reuse before writing a new one.
+
+## Verification Scripts
+- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
+
+## Application Structure & Architecture
+- Stick to existing directory structure - don't create new base folders without approval.
+- Do not change the application's dependencies without approval.
+
+## Frontend Bundling
+- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `vendor/bin/sail npm run build`, `vendor/bin/sail npm run dev`, or `vendor/bin/sail composer run dev`. Ask them.
+
+## Replies
+- Be concise in your explanations - focus on what's important rather than explaining obvious details.
+
+## Documentation Files
+- You must only create documentation files if explicitly requested by the user.
+
+
+=== boost rules ===
+
+## Laravel Boost
+- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
+
+## Artisan
+- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters.
+
+## URLs
+- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port.
+
+## Tinker / Debugging
+- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
+- Use the `database-query` tool when you only need to read from the database.
+
+## Reading Browser Logs With the `browser-logs` Tool
+- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
+- Only recent browser logs will be useful - ignore old logs.
+
+## Searching Documentation (Critically Important)
+- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
+- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
+- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches.
+- Search the documentation before making code changes to ensure we are taking the correct approach.
+- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
+- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
+
+### Available Search Syntax
+- You can and should pass multiple queries at once. The most relevant results will be returned first.
+
+1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'
+2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit"
+3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order
+4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit"
+5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms
+
+
+=== php rules ===
+
+## PHP
+
+- Always use curly braces for control structures, even if it has one line.
+
+### Constructors
+- Use PHP 8 constructor property promotion in `__construct()`.
+ - public function __construct(public GitHub $github) { }
+- Do not allow empty `__construct()` methods with zero parameters.
+
+### Type Declarations
+- Always use explicit return type declarations for methods and functions.
+- Use appropriate PHP type hints for method parameters.
+
+
+protected function isAccessible(User $user, ?string $path = null): bool
+{
+ ...
+}
+
+
+## Comments
+- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on.
+
+## PHPDoc Blocks
+- Add useful array shape type definitions for arrays when appropriate.
+
+## Enums
+- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
+
+
+=== sail rules ===
+
+## Laravel Sail
+
+- This project runs inside Laravel Sail's Docker containers. You MUST execute all commands through Sail.
+- Start services using `vendor/bin/sail up -d` and stop them with `vendor/bin/sail stop`.
+- Open the application in the browser by running `vendor/bin/sail open`.
+- Always prefix PHP, Artisan, Composer, and Node commands** with `vendor/bin/sail`. Examples:
+- Run Artisan Commands: `vendor/bin/sail artisan migrate`
+- Install Composer packages: `vendor/bin/sail composer install`
+- Execute node commands: `vendor/bin/sail npm run dev`
+- Execute PHP scripts: `vendor/bin/sail php [script]`
+- View all available Sail commands by running `vendor/bin/sail` without arguments.
+
+
+=== tests rules ===
+
+## Test Enforcement
+
+- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
+- Run the minimum number of tests needed to ensure code quality and speed. Use `vendor/bin/sail artisan test` with a specific filename or filter.
+
+
+=== laravel/core rules ===
+
+## Do Things the Laravel Way
+
+- Use `vendor/bin/sail artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
+- If you're creating a generic PHP class, use `vendor/bin/sail artisan make:class`.
+- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
+
+### Database
+- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
+- Use Eloquent models and relationships before suggesting raw database queries
+- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
+- Generate code that prevents N+1 query problems by using eager loading.
+- Use Laravel's query builder for very complex database operations.
+
+### Model Creation
+- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `vendor/bin/sail artisan make:model`.
+
+### APIs & Eloquent Resources
+- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
+
+### Controllers & Validation
+- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
+- Check sibling Form Requests to see if the application uses array or string based validation rules.
+
+### Queues
+- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
+
+### Authentication & Authorization
+- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
+
+### URL Generation
+- When generating links to other pages, prefer named routes and the `route()` function.
+
+### Configuration
+- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
+
+### Testing
+- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
+- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
+- When creating tests, make use of `vendor/bin/sail artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
+
+### Vite Error
+- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `vendor/bin/sail npm run build` or ask the user to run `vendor/bin/sail npm run dev` or `vendor/bin/sail composer run dev`.
+
+
+=== laravel/v12 rules ===
+
+## Laravel 12
+
+- Use the `search-docs` tool to get version specific documentation.
+- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
+
+### Laravel 12 Structure
+- No middleware files in `app/Http/Middleware/`.
+- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
+- `bootstrap/providers.php` contains application specific service providers.
+- **No app\Console\Kernel.php** - use `bootstrap/app.php` or `routes/console.php` for console configuration.
+- **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration.
+
+### Database
+- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
+- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
+
+### Models
+- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
+
+
+=== pint/core rules ===
+
+## Laravel Pint Code Formatter
+
+- You must run `vendor/bin/sail bin pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
+- Do not run `vendor/bin/sail bin pint --test`, simply run `vendor/bin/sail bin pint` to fix any formatting issues.
+
+
+=== phpunit/core rules ===
+
+## PHPUnit Core
+
+- This application uses PHPUnit for testing. All tests must be written as PHPUnit classes. Use `vendor/bin/sail artisan make:test --phpunit {name}` to create a new test.
+- If you see a test using "Pest", convert it to PHPUnit.
+- Every time a test has been updated, run that singular test.
+- When the tests relating to your feature are passing, ask the user if they would like to also run the entire test suite to make sure everything is still passing.
+- Tests should test all of the happy paths, failure paths, and weird paths.
+- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files, these are core to the application.
+
+### Running Tests
+- Run the minimal number of tests, using an appropriate filter, before finalizing.
+- To run all tests: `vendor/bin/sail artisan test`.
+- To run all tests in a file: `vendor/bin/sail artisan test tests/Feature/ExampleTest.php`.
+- To filter on a particular test name: `vendor/bin/sail artisan test --filter=testName` (recommended after making a change to a related file).
+
diff --git a/.junie/mcp/mcp.json b/.junie/mcp/mcp.json
new file mode 100644
index 0000000..04eba29
--- /dev/null
+++ b/.junie/mcp/mcp.json
@@ -0,0 +1,11 @@
+{
+ "mcpServers": {
+ "laravel-boost": {
+ "command": "vendor/bin/sail",
+ "args": [
+ "artisan",
+ "boost:mcp"
+ ]
+ }
+ }
+}
\ No newline at end of file
diff --git a/.mcp.json b/.mcp.json
new file mode 100644
index 0000000..a36b3bf
--- /dev/null
+++ b/.mcp.json
@@ -0,0 +1,12 @@
+{
+ "mcpServers": {
+ "laravel-boost": {
+ "command": "docker compose run app",
+ "args": [
+ "vendor/bin/sail",
+ "artisan",
+ "boost:mcp"
+ ]
+ }
+ }
+}
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..dce5628
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,225 @@
+
+=== foundation rules ===
+
+# Laravel Boost Guidelines
+
+The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications.
+
+## Foundational Context
+This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
+
+- php - 8.3.28
+- laravel/framework (LARAVEL) - v12
+- laravel/horizon (HORIZON) - v5
+- laravel/octane (OCTANE) - v2
+- laravel/prompts (PROMPTS) - v0
+- laravel/telescope (TELESCOPE) - v5
+- laravel/mcp (MCP) - v0
+- laravel/pint (PINT) - v1
+- laravel/sail (SAIL) - v1
+- phpunit/phpunit (PHPUNIT) - v11
+
+## Conventions
+- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming.
+- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
+- Check for existing components to reuse before writing a new one.
+
+## Verification Scripts
+- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
+
+## Application Structure & Architecture
+- Stick to existing directory structure - don't create new base folders without approval.
+- Do not change the application's dependencies without approval.
+
+## Frontend Bundling
+- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `docker compose run --rm app npm run build`, `docker compose run --rm app npm run dev`, or `docker compose run --rm app composer run dev`. Ask them.
+
+## Replies
+- Be concise in your explanations - focus on what's important rather than explaining obvious details.
+- Use Chinese for all natural language communication.
+
+## Documentation Files
+- You must only create documentation files if explicitly requested by the user.
+
+
+=== boost rules ===
+
+## Laravel Boost
+- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
+
+## Artisan
+- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters.
+
+## URLs
+- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port.
+
+## Tinker / Debugging
+- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
+- Use the `database-query` tool when you only need to read from the database.
+
+## Reading Browser Logs With the `browser-logs` Tool
+- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
+- Only recent browser logs will be useful - ignore old logs.
+
+## Searching Documentation (Critically Important)
+- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
+- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
+- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches.
+- Search the documentation before making code changes to ensure we are taking the correct approach.
+- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
+- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
+
+### Available Search Syntax
+- You can and should pass multiple queries at once. The most relevant results will be returned first.
+
+1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'
+2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit"
+3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order
+4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit"
+5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms
+
+
+=== php rules ===
+
+## PHP
+
+- Always use curly braces for control structures, even if it has one line.
+
+### Constructors
+- Use PHP 8 constructor property promotion in `__construct()`.
+ - public function __construct(public GitHub $github) { }
+- Do not allow empty `__construct()` methods with zero parameters.
+
+### Type Declarations
+- Always use explicit return type declarations for methods and functions.
+- Use appropriate PHP type hints for method parameters.
+
+
+protected function isAccessible(User $user, ?string $path = null): bool
+{
+ ...
+}
+
+
+## Comments
+- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on.
+
+## PHPDoc Blocks
+- Add useful array shape type definitions for arrays when appropriate.
+
+## Enums
+- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
+
+
+=== docker-compose rules ===
+
+## Docker Compose
+
+- This project runs with Docker Compose (Sail is not used). Always run commands through Docker Compose.
+- Start services using `docker compose up -d` and stop them with `docker compose down`.
+- Run one-off commands with `docker compose run --rm app ...` to ensure dependencies are available.
+- Common commands: `docker compose run --rm app php artisan migrate`, `docker compose run --rm app composer install`, `docker compose run --rm app npm run dev`, `docker compose run --rm app php [script]`.
+- The application is available at `http://localhost:8000` when the stack is running.
+
+
+=== tests rules ===
+
+## Test Enforcement
+
+- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
+- Run the minimum number of tests needed to ensure code quality and speed. Use `docker compose run --rm app php artisan test` with a specific filename or filter.
+
+
+=== laravel/core rules ===
+
+## Do Things the Laravel Way
+
+- Use `docker compose run --rm app php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
+- If you're creating a generic PHP class, use `docker compose run --rm app php artisan make:class`.
+- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
+
+### Database
+- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
+- Use Eloquent models and relationships before suggesting raw database queries
+- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
+- Generate code that prevents N+1 query problems by using eager loading.
+- Use Laravel's query builder for very complex database operations.
+
+### Model Creation
+- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `vendor/bin/sail artisan make:model`.
+
+### APIs & Eloquent Resources
+- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
+
+### Controllers & Validation
+- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
+- Check sibling Form Requests to see if the application uses array or string based validation rules.
+
+### Queues
+- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
+
+### Authentication & Authorization
+- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
+
+### URL Generation
+- When generating links to other pages, prefer named routes and the `route()` function.
+
+### Configuration
+- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
+
+### Testing
+- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
+- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
+- When creating tests, make use of `vendor/bin/sail artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
+
+### Vite Error
+- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `vendor/bin/sail npm run build` or ask the user to run `vendor/bin/sail npm run dev` or `vendor/bin/sail composer run dev`.
+
+
+=== laravel/v12 rules ===
+
+## Laravel 12
+
+- Use the `search-docs` tool to get version specific documentation.
+- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
+
+### Laravel 12 Structure
+- No middleware files in `app/Http/Middleware/`.
+- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
+- `bootstrap/providers.php` contains application specific service providers.
+- **No app\Console\Kernel.php** - use `bootstrap/app.php` or `routes/console.php` for console configuration.
+- **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration.
+
+### Database
+- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
+- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
+
+### Models
+- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
+
+
+=== pint/core rules ===
+
+## Laravel Pint Code Formatter
+
+- You must run `docker compose run --rm app ./vendor/bin/pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
+- Do not run `docker compose run --rm app ./vendor/bin/pint --test`; just run Pint to fix any formatting issues.
+
+
+=== phpunit/core rules ===
+
+## PHPUnit Core
+
+- This application uses PHPUnit for testing. All tests must be written as PHPUnit classes. Use `vendor/bin/sail artisan make:test --phpunit {name}` to create a new test.
+- If you see a test using "Pest", convert it to PHPUnit.
+- Every time a test has been updated, run that singular test.
+- When the tests relating to your feature are passing, ask the user if they would like to also run the entire test suite to make sure everything is still passing.
+- Tests should test all of the happy paths, failure paths, and weird paths.
+- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files, these are core to the application.
+
+### Running Tests
+- Run the minimal number of tests, using an appropriate filter, before finalizing.
+- To run all tests: `docker compose run --rm app php artisan test`.
+- To run all tests in a file: `docker compose run --rm app php artisan test tests/Feature/ExampleTest.php`.
+- To filter on a particular test name: `docker compose run --rm app php artisan test --filter=testName` (recommended after making a change to a related file).
+
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..41905cf
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,228 @@
+
+=== foundation rules ===
+
+# Laravel Boost Guidelines
+
+The Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to enhance the user's satisfaction building Laravel applications.
+
+## Foundational Context
+This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
+
+- php - 8.3.28
+- laravel/framework (LARAVEL) - v12
+- laravel/horizon (HORIZON) - v5
+- laravel/octane (OCTANE) - v2
+- laravel/prompts (PROMPTS) - v0
+- laravel/telescope (TELESCOPE) - v5
+- laravel/mcp (MCP) - v0
+- laravel/pint (PINT) - v1
+- laravel/sail (SAIL) - v1
+- phpunit/phpunit (PHPUNIT) - v11
+
+## Conventions
+- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, naming.
+- Use descriptive names for variables and methods. For example, `isRegisteredForDiscounts`, not `discount()`.
+- Check for existing components to reuse before writing a new one.
+
+## Verification Scripts
+- Do not create verification scripts or tinker when tests cover that functionality and prove it works. Unit and feature tests are more important.
+
+## Application Structure & Architecture
+- Stick to existing directory structure - don't create new base folders without approval.
+- Do not change the application's dependencies without approval.
+
+## Frontend Bundling
+- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `vendor/bin/sail npm run build`, `vendor/bin/sail npm run dev`, or `vendor/bin/sail composer run dev`. Ask them.
+
+## Replies
+- Be concise in your explanations - focus on what's important rather than explaining obvious details.
+
+## Documentation Files
+- You must only create documentation files if explicitly requested by the user.
+
+
+=== boost rules ===
+
+## Laravel Boost
+- Laravel Boost is an MCP server that comes with powerful tools designed specifically for this application. Use them.
+
+## Artisan
+- Use the `list-artisan-commands` tool when you need to call an Artisan command to double check the available parameters.
+
+## URLs
+- Whenever you share a project URL with the user you should use the `get-absolute-url` tool to ensure you're using the correct scheme, domain / IP, and port.
+
+## Tinker / Debugging
+- You should use the `tinker` tool when you need to execute PHP to debug code or query Eloquent models directly.
+- Use the `database-query` tool when you only need to read from the database.
+
+## Reading Browser Logs With the `browser-logs` Tool
+- You can read browser logs, errors, and exceptions using the `browser-logs` tool from Boost.
+- Only recent browser logs will be useful - ignore old logs.
+
+## Searching Documentation (Critically Important)
+- Boost comes with a powerful `search-docs` tool you should use before any other approaches. This tool automatically passes a list of installed packages and their versions to the remote Boost API, so it returns only version-specific documentation specific for the user's circumstance. You should pass an array of packages to filter on if you know you need docs for particular packages.
+- The 'search-docs' tool is perfect for all Laravel related packages, including Laravel, Inertia, Livewire, Filament, Tailwind, Pest, Nova, Nightwatch, etc.
+- You must use this tool to search for Laravel-ecosystem documentation before falling back to other approaches.
+- Search the documentation before making code changes to ensure we are taking the correct approach.
+- Use multiple, broad, simple, topic based queries to start. For example: `['rate limiting', 'routing rate limiting', 'routing']`.
+- Do not add package names to queries - package information is already shared. For example, use `test resource table`, not `filament 4 test resource table`.
+
+### Available Search Syntax
+- You can and should pass multiple queries at once. The most relevant results will be returned first.
+
+1. Simple Word Searches with auto-stemming - query=authentication - finds 'authenticate' and 'auth'
+2. Multiple Words (AND Logic) - query=rate limit - finds knowledge containing both "rate" AND "limit"
+3. Quoted Phrases (Exact Position) - query="infinite scroll" - Words must be adjacent and in that order
+4. Mixed Queries - query=middleware "rate limit" - "middleware" AND exact phrase "rate limit"
+5. Multiple Queries - queries=["authentication", "middleware"] - ANY of these terms
+
+
+=== php rules ===
+
+## PHP
+
+- Always use curly braces for control structures, even if it has one line.
+
+### Constructors
+- Use PHP 8 constructor property promotion in `__construct()`.
+ - public function __construct(public GitHub $github) { }
+- Do not allow empty `__construct()` methods with zero parameters.
+
+### Type Declarations
+- Always use explicit return type declarations for methods and functions.
+- Use appropriate PHP type hints for method parameters.
+
+
+protected function isAccessible(User $user, ?string $path = null): bool
+{
+ ...
+}
+
+
+## Comments
+- Prefer PHPDoc blocks over comments. Never use comments within the code itself unless there is something _very_ complex going on.
+
+## PHPDoc Blocks
+- Add useful array shape type definitions for arrays when appropriate.
+
+## Enums
+- Typically, keys in an Enum should be TitleCase. For example: `FavoritePerson`, `BestLake`, `Monthly`.
+
+
+=== sail rules ===
+
+## Laravel Sail
+
+- This project runs inside Laravel Sail's Docker containers. You MUST execute all commands through Sail.
+- Start services using `vendor/bin/sail up -d` and stop them with `vendor/bin/sail stop`.
+- Open the application in the browser by running `vendor/bin/sail open`.
+- Always prefix PHP, Artisan, Composer, and Node commands** with `vendor/bin/sail`. Examples:
+- Run Artisan Commands: `vendor/bin/sail artisan migrate`
+- Install Composer packages: `vendor/bin/sail composer install`
+- Execute node commands: `vendor/bin/sail npm run dev`
+- Execute PHP scripts: `vendor/bin/sail php [script]`
+- View all available Sail commands by running `vendor/bin/sail` without arguments.
+
+
+=== tests rules ===
+
+## Test Enforcement
+
+- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
+- Run the minimum number of tests needed to ensure code quality and speed. Use `vendor/bin/sail artisan test` with a specific filename or filter.
+
+
+=== laravel/core rules ===
+
+## Do Things the Laravel Way
+
+- Use `vendor/bin/sail artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using the `list-artisan-commands` tool.
+- If you're creating a generic PHP class, use `vendor/bin/sail artisan make:class`.
+- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
+
+### Database
+- Always use proper Eloquent relationship methods with return type hints. Prefer relationship methods over raw queries or manual joins.
+- Use Eloquent models and relationships before suggesting raw database queries
+- Avoid `DB::`; prefer `Model::query()`. Generate code that leverages Laravel's ORM capabilities rather than bypassing them.
+- Generate code that prevents N+1 query problems by using eager loading.
+- Use Laravel's query builder for very complex database operations.
+
+### Model Creation
+- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `list-artisan-commands` to check the available options to `vendor/bin/sail artisan make:model`.
+
+### APIs & Eloquent Resources
+- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
+
+### Controllers & Validation
+- Always create Form Request classes for validation rather than inline validation in controllers. Include both validation rules and custom error messages.
+- Check sibling Form Requests to see if the application uses array or string based validation rules.
+
+### Queues
+- Use queued jobs for time-consuming operations with the `ShouldQueue` interface.
+
+### Authentication & Authorization
+- Use Laravel's built-in authentication and authorization features (gates, policies, Sanctum, etc.).
+
+### URL Generation
+- When generating links to other pages, prefer named routes and the `route()` function.
+
+### Configuration
+- Use environment variables only in configuration files - never use the `env()` function directly outside of config files. Always use `config('app.name')`, not `env('APP_NAME')`.
+
+### Testing
+- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
+- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
+- When creating tests, make use of `vendor/bin/sail artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
+
+### Vite Error
+- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `vendor/bin/sail npm run build` or ask the user to run `vendor/bin/sail npm run dev` or `vendor/bin/sail composer run dev`.
+
+
+=== laravel/v12 rules ===
+
+## Laravel 12
+
+- Use the `search-docs` tool to get version specific documentation.
+- Since Laravel 11, Laravel has a new streamlined file structure which this project uses.
+
+### Laravel 12 Structure
+- No middleware files in `app/Http/Middleware/`.
+- `bootstrap/app.php` is the file to register middleware, exceptions, and routing files.
+- `bootstrap/providers.php` contains application specific service providers.
+- **No app\Console\Kernel.php** - use `bootstrap/app.php` or `routes/console.php` for console configuration.
+- **Commands auto-register** - files in `app/Console/Commands/` are automatically available and do not require manual registration.
+
+### Database
+- When modifying a column, the migration must include all of the attributes that were previously defined on the column. Otherwise, they will be dropped and lost.
+- Laravel 11 allows limiting eagerly loaded records natively, without external packages: `$query->latest()->limit(10);`.
+
+### Models
+- Casts can and likely should be set in a `casts()` method on a model rather than the `$casts` property. Follow existing conventions from other models.
+
+
+=== pint/core rules ===
+
+## Laravel Pint Code Formatter
+
+- You must run `vendor/bin/sail bin pint --dirty` before finalizing changes to ensure your code matches the project's expected style.
+- Do not run `vendor/bin/sail bin pint --test`, simply run `vendor/bin/sail bin pint` to fix any formatting issues.
+
+
+=== phpunit/core rules ===
+
+## PHPUnit Core
+
+- This application uses PHPUnit for testing. All tests must be written as PHPUnit classes. Use `vendor/bin/sail artisan make:test --phpunit {name}` to create a new test.
+- If you see a test using "Pest", convert it to PHPUnit.
+- Every time a test has been updated, run that singular test.
+- When the tests relating to your feature are passing, ask the user if they would like to also run the entire test suite to make sure everything is still passing.
+- Tests should test all of the happy paths, failure paths, and weird paths.
+- You must not remove any tests or test files from the tests directory without approval. These are not temporary or helper files, these are core to the application.
+
+### Running Tests
+- Run the minimal number of tests, using an appropriate filter, before finalizing.
+- To run all tests: `vendor/bin/sail artisan test`.
+- To run all tests in a file: `vendor/bin/sail artisan test tests/Feature/ExampleTest.php`.
+- To filter on a particular test name: `vendor/bin/sail artisan test --filter=testName` (recommended after making a change to a related file).
+
diff --git a/README.md b/README.md
index ed1571d..45f5987 100644
--- a/README.md
+++ b/README.md
@@ -18,24 +18,31 @@
- `messages(message_id UUID, session_id, role USER|AGENT|TOOL|SYSTEM, type, content, payload jsonb, seq, reply_to, dedupe_key)`
- 约束:`unique(session_id, seq)`、`unique(session_id, dedupe_key)`;append 行锁 + 事务,seq 单调递增。
- **实时**:SSE `/api/sessions/{id}/sse`,backlog 先补历史(按 seq),再监听 Redis `session:{id}:messages` 渠道。
+- **Agent Run 编排(MVP-0)**:user.prompt 后自动触发 RunDispatcher → `run.status=RUNNING` → AgentRunJob(DummyProvider 返回一次性回复)→ `agent.message` + `run.status=DONE/FAILED/CANCELED` 落库;同 trigger_message 幂等、同会话只允许一个 RUNNING。
+- **队列监控**:Horizon(本地默认开放 `/horizon`,非 local 环境默认拒绝访问)。
## 🚀 快速启动
```bash
# 构建并启动
docker compose build
-docker compose up -d app pgsql redis
+docker compose up -d app horizon pgsql redis
# 首次迁移(仅需一次)
docker compose exec app php artisan migrate
# 运行 Feature 测试
docker compose exec app php artisan test --testsuite=Feature
+
+# 队列(AgentRunJob):开发可用同步队列,或用 Horizon
+# 同步:.env / phpunit.xml 中 QUEUE_CONNECTION=sync
+# Horizon:docker compose up -d horizon(需 composer install 安装 laravel/horizon,QUEUE_CONNECTION=redis)
```
## 🔑 API 能力一览(MVP-1.1 + Archive/GetMessage/SSE)
- 会话:`POST /api/sessions`,`GET /api/sessions`(分页/状态/关键词),`GET /api/sessions/{id}`,`PATCH /api/sessions/{id}`(重命名/状态,CLOSED 不可重开),`POST /api/sessions/{id}/archive`(幂等归档→CLOSED)。
- 消息:`POST /api/sessions/{id}/messages`(幂等 dedupe_key,CLOSED/LOCKED 门禁),`GET /api/sessions/{id}/messages`(after_seq 增量),`GET /api/sessions/{id}/messages/{message_id}`(校验 session_id)。
- 实时:`GET /api/sessions/{id}/sse?after_seq=123`,SSE 事件 id 为 seq;`Last-Event-ID` 优先于 query。
+- Agent Run:user.prompt 自动触发;或 `POST /api/sessions/{id}/runs {trigger_message_id}` 手动触发,写入 `run.status/agent.message`。
详细字段/示例:`docs/ChatSession/chat-session-api.md`,OpenAPI:`docs/ChatSession/chat-session-openapi.yaml`。用户管理/鉴权文档:`docs/User/user-api.md`。
diff --git a/app/Http/Controllers/ChatSessionController.php b/app/Http/Controllers/ChatSessionController.php
index c5542ae..bd9012e 100644
--- a/app/Http/Controllers/ChatSessionController.php
+++ b/app/Http/Controllers/ChatSessionController.php
@@ -9,12 +9,16 @@ use App\Http\Requests\UpdateSessionRequest;
use App\Http\Resources\ChatSessionResource;
use App\Http\Resources\MessageResource;
use App\Services\ChatService;
+use App\Services\RunDispatcher;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ChatSessionController extends Controller
{
- public function __construct(private readonly ChatService $service)
+ public function __construct(
+ private readonly ChatService $service,
+ private readonly RunDispatcher $runDispatcher,
+ )
{
}
@@ -45,6 +49,10 @@ class ChatSessionController extends Controller
'session_id' => $sessionId,
...$request->validated(),
]);
+
+ if ($message->role === 'USER' && $message->type === 'user.prompt') {
+ $this->runDispatcher->dispatchForPrompt($sessionId, $message->message_id);
+ }
} catch (ChatSessionStatusException $e) {
return response()->json(['message' => $e->getMessage()], 403);
}
diff --git a/app/Http/Controllers/ChatSessionSseController.php b/app/Http/Controllers/ChatSessionSseController.php
index 8948734..0cdf6cb 100644
--- a/app/Http/Controllers/ChatSessionSseController.php
+++ b/app/Http/Controllers/ChatSessionSseController.php
@@ -24,7 +24,12 @@ class ChatSessionSseController extends Controller
$limit = (int) $request->query('limit', 200);
$limit = $limit > 0 && $limit <= 500 ? $limit : 200;
- if (app()->runningUnitTests() || app()->environment('testing') || ! class_exists(\Redis::class)) {
+ $useBacklogOnly = app()->runningUnitTests()
+ || app()->environment('testing')
+ || defined('PHPUNIT_COMPOSER_INSTALL')
+ || ! class_exists(\Redis::class);
+
+ if ($useBacklogOnly) {
$lastSentSeq = $afterSeq;
ob_start();
$this->sendBacklog($sessionId, $lastSentSeq, $limit, false);
@@ -42,41 +47,75 @@ class ChatSessionSseController extends Controller
$this->sendBacklog($sessionId, $lastSentSeq, $limit);
- $redis = Redis::connection()->client();
- if (method_exists($redis, 'setOption')) {
- $redis->setOption(\Redis::OPT_READ_TIMEOUT, 5);
- }
-
- $channel = "session:{$sessionId}:messages";
- $pubSub = $redis->pubSubLoop();
- $pubSub->subscribe($channel);
- $lastPing = time();
-
- foreach ($pubSub as $message) {
- if ($message->kind === 'subscribe') {
- continue;
+ try {
+ $redis = Redis::connection()->client();
+ if (method_exists($redis, 'setOption')) {
+ $redis->setOption(\Redis::OPT_READ_TIMEOUT, 5);
}
- if (connection_aborted()) {
- $pubSub->unsubscribe();
- break;
- }
+ $channel = "session:{$sessionId}:messages";
+ $lastPing = time();
+ logger()->info('sse open');
+ if (method_exists($redis, 'pubSubLoop')) {
+ $pubSub = $redis->pubSubLoop();
+ $pubSub->subscribe($channel);
- $payloadId = $message->payload ?? null;
- if ($payloadId) {
- $msg = $this->service->getMessage($sessionId, $payloadId);
- if ($msg && $msg->seq > $lastSentSeq) {
- $this->emitMessage($msg);
- $lastSentSeq = $msg->seq;
+ foreach ($pubSub as $message) {
+ if ($message->kind === 'subscribe') {
+ continue;
+ }
+
+ if (connection_aborted()) {
+ $pubSub->unsubscribe();
+ break;
+ }
+
+ $payloadId = $message->payload ?? null;
+ if ($payloadId) {
+ $msg = $this->service->getMessage($sessionId, $payloadId);
+ if ($msg && $msg->seq > $lastSentSeq) {
+ $this->emitMessage($msg);
+ $lastSentSeq = $msg->seq;
+ }
+ }
+
+ if (time() - $lastPing >= 180) {
+ logger()->info('ping: sent'.$sessionId);
+ echo ": ping\n\n";
+ @ob_flush();
+ @flush();
+ $lastPing = time();
+ }
}
- }
+ logger()->info('close: sent'.$sessionId);
+ unset($pubSub);
+ } else {
+ // Fallback for Redis drivers without pubSubLoop (older phpredis)
+ $redis->subscribe([$channel], function ($redisInstance, $chan, $payload) use (&$lastSentSeq, $sessionId) {
+ if (connection_aborted()) {
+ $redisInstance->unsubscribe([$chan]);
+ return;
+ }
- if (time() - $lastPing >= 20) {
- echo ": ping\n\n";
- @ob_flush();
- @flush();
- $lastPing = time();
+ if (! $payload) {
+ return;
+ }
+
+ $msg = $this->service->getMessage($sessionId, $payload);
+ if ($msg && $msg->seq > $lastSentSeq) {
+ $this->emitMessage($msg);
+ $lastSentSeq = $msg->seq;
+ }
+ });
}
+ } catch (\RedisException $exception) {
+ logger()->warning('SSE redis subscription failed', [
+ 'session_id' => $sessionId,
+ 'message' => $exception->getMessage(),
+ ]);
+ echo ": redis-error\n\n";
+ @ob_flush();
+ @flush();
}
});
diff --git a/app/Http/Controllers/RunController.php b/app/Http/Controllers/RunController.php
new file mode 100644
index 0000000..ba3f8d3
--- /dev/null
+++ b/app/Http/Controllers/RunController.php
@@ -0,0 +1,28 @@
+dispatcher->dispatchForPrompt($sessionId, $request->validated()['trigger_message_id']);
+
+ return response()->json(['run_id' => $runId], 201);
+ }
+
+ public function test()
+ {
+ $job = TestJob::dispatch();
+ unset($job);
+ }
+}
diff --git a/app/Http/Requests/DispatchRunRequest.php b/app/Http/Requests/DispatchRunRequest.php
new file mode 100644
index 0000000..d790837
--- /dev/null
+++ b/app/Http/Requests/DispatchRunRequest.php
@@ -0,0 +1,23 @@
+
+ */
+ public function rules(): array
+ {
+ return [
+ 'trigger_message_id' => ['required', 'uuid'],
+ ];
+ }
+}
diff --git a/app/Jobs/AgentRunJob.php b/app/Jobs/AgentRunJob.php
new file mode 100644
index 0000000..520e272
--- /dev/null
+++ b/app/Jobs/AgentRunJob.php
@@ -0,0 +1,35 @@
+run($this->sessionId, $this->runId);
+ } catch (\Throwable $e) {
+ $sink->appendError($this->sessionId, $this->runId, 'run.failed', $e->getMessage());
+ $sink->appendRunStatus($this->sessionId, $this->runId, 'FAILED', [
+ 'error' => $e->getMessage(),
+ 'dedupe_key' => "run:{$this->runId}:status:FAILED",
+ ]);
+
+ throw $e;
+ }
+ }
+}
diff --git a/app/Jobs/TestJob.php b/app/Jobs/TestJob.php
new file mode 100644
index 0000000..da57893
--- /dev/null
+++ b/app/Jobs/TestJob.php
@@ -0,0 +1,23 @@
+app->bind(\App\Services\Agent\AgentProviderInterface::class, function () {
+ return new \App\Services\Agent\DummyAgentProvider();
+ });
}
/**
diff --git a/app/Providers/HorizonServiceProvider.php b/app/Providers/HorizonServiceProvider.php
new file mode 100644
index 0000000..1151573
--- /dev/null
+++ b/app/Providers/HorizonServiceProvider.php
@@ -0,0 +1,31 @@
+environment('local');
+ });
+ }
+}
diff --git a/app/Providers/TelescopeServiceProvider.php b/app/Providers/TelescopeServiceProvider.php
new file mode 100644
index 0000000..22711b4
--- /dev/null
+++ b/app/Providers/TelescopeServiceProvider.php
@@ -0,0 +1,55 @@
+hideSensitiveRequestDetails();
+
+ $isLocal = $this->app->environment('local');
+ }
+
+ /**
+ * Prevent sensitive request details from being logged by Telescope.
+ */
+ protected function hideSensitiveRequestDetails(): void
+ {
+ if ($this->app->environment('local')) {
+ return;
+ }
+
+ Telescope::hideRequestParameters(['_token']);
+
+ Telescope::hideRequestHeaders([
+ 'cookie',
+ 'x-csrf-token',
+ 'x-xsrf-token',
+ ]);
+ }
+
+ /**
+ * Register the Telescope gate.
+ *
+ * This gate determines who can access Telescope in non-local environments.
+ */
+ protected function gate(): void
+ {
+ Gate::define('viewTelescope', function ($user) {
+ return in_array($user->email, [
+ //
+ ]);
+ });
+ }
+}
diff --git a/app/Services/Agent/AgentProviderInterface.php b/app/Services/Agent/AgentProviderInterface.php
new file mode 100644
index 0000000..3a3e5ef
--- /dev/null
+++ b/app/Services/Agent/AgentProviderInterface.php
@@ -0,0 +1,12 @@
+ $context
+ * @param array $options
+ */
+ public function generate(array $context, array $options = []): string;
+}
diff --git a/app/Services/Agent/DummyAgentProvider.php b/app/Services/Agent/DummyAgentProvider.php
new file mode 100644
index 0000000..a89fbe2
--- /dev/null
+++ b/app/Services/Agent/DummyAgentProvider.php
@@ -0,0 +1,26 @@
+ $context
+ * @param array $options
+ */
+ public function generate(array $context, array $options = []): string
+ {
+ $messages = $context['messages'] ?? [];
+ $lastUser = null;
+ foreach (array_reverse($messages) as $msg) {
+ if (($msg['role'] ?? '') === 'USER' && ($msg['type'] ?? '') === 'user.prompt') {
+ $lastUser = $msg['content'] ?? null;
+ break;
+ }
+ }
+
+ $summary = $lastUser ? mb_substr($lastUser, 0, 80) : 'no user prompt';
+
+ return sprintf('MVP reply: based on last user input -> %s', $summary);
+ }
+}
diff --git a/app/Services/Agent/HttpAgentProvider.php b/app/Services/Agent/HttpAgentProvider.php
new file mode 100644
index 0000000..35d21ee
--- /dev/null
+++ b/app/Services/Agent/HttpAgentProvider.php
@@ -0,0 +1,42 @@
+endpoint = $endpoint ?? config('services.agent_provider.endpoint', '');
+ }
+
+ /**
+ * @param array $context
+ * @param array $options
+ */
+ public function generate(array $context, array $options = []): string
+ {
+ if (empty($this->endpoint)) {
+ // placeholder to avoid accidental outbound calls when未配置
+ return (new DummyAgentProvider())->generate($context, $options);
+ }
+
+ $payload = [
+ 'context' => $context,
+ 'options' => $options,
+ ];
+
+ $response = Http::post($this->endpoint, $payload);
+
+ if (! $response->successful()) {
+ throw new \RuntimeException('Agent provider failed: '.$response->body());
+ }
+
+ $data = $response->json();
+
+ return is_string($data) ? $data : ($data['content'] ?? '');
+ }
+}
diff --git a/app/Services/CancelChecker.php b/app/Services/CancelChecker.php
new file mode 100644
index 0000000..08d6a7c
--- /dev/null
+++ b/app/Services/CancelChecker.php
@@ -0,0 +1,18 @@
+where('session_id', $sessionId)
+ ->where('type', 'run.cancel.request')
+ ->whereIn('role', [Message::ROLE_USER, Message::ROLE_SYSTEM])
+ ->where('payload->run_id', $runId)
+ ->exists();
+ }
+}
diff --git a/app/Services/ContextBuilder.php b/app/Services/ContextBuilder.php
new file mode 100644
index 0000000..42729b9
--- /dev/null
+++ b/app/Services/ContextBuilder.php
@@ -0,0 +1,49 @@
+
+ */
+ public function build(string $sessionId, string $runId): array
+ {
+ $messages = $this->loadRecentMessages($sessionId);
+
+ return [
+ 'run_id' => $runId,
+ 'session_id' => $sessionId,
+ 'system_prompt' => 'You are an agent inside ARS. Respond concisely in plain text.',
+ 'messages' => $messages->map(function (Message $message) {
+ return [
+ 'message_id' => $message->message_id,
+ 'role' => $message->role,
+ 'type' => $message->type,
+ 'content' => $message->content,
+ 'seq' => $message->seq,
+ ];
+ })->values()->all(),
+ ];
+ }
+
+ private function loadRecentMessages(string $sessionId): Collection
+ {
+ return Message::query()
+ ->where('session_id', $sessionId)
+ ->whereIn('role', [Message::ROLE_USER, Message::ROLE_AGENT])
+ ->whereIn('type', ['user.prompt', 'agent.message'])
+ ->orderByDesc('seq')
+ ->limit($this->limit)
+ ->get()
+ ->sortBy('seq')
+ ->values();
+ }
+}
diff --git a/app/Services/OutputSink.php b/app/Services/OutputSink.php
new file mode 100644
index 0000000..8302045
--- /dev/null
+++ b/app/Services/OutputSink.php
@@ -0,0 +1,63 @@
+ $meta
+ */
+ public function appendAgentMessage(string $sessionId, string $runId, string $content, array $meta = []): Message
+ {
+ return $this->chatService->appendMessage([
+ 'session_id' => $sessionId,
+ 'role' => Message::ROLE_AGENT,
+ 'type' => 'agent.message',
+ 'content' => $content,
+ 'payload' => array_merge($meta, ['run_id' => $runId]),
+ ]);
+ }
+
+ /**
+ * @param array $meta
+ */
+ public function appendRunStatus(string $sessionId, string $runId, string $status, array $meta = []): Message
+ {
+ $dedupeKey = $meta['dedupe_key'] ?? null;
+ unset($meta['dedupe_key']);
+
+ return $this->chatService->appendMessage([
+ 'session_id' => $sessionId,
+ 'role' => Message::ROLE_SYSTEM,
+ 'type' => 'run.status',
+ 'payload' => array_merge($meta, [
+ 'run_id' => $runId,
+ 'status' => $status,
+ ]),
+ 'dedupe_key' => $dedupeKey,
+ ]);
+ }
+
+ /**
+ * @param array $meta
+ */
+ public function appendError(string $sessionId, string $runId, string $code, string $message, array $meta = []): Message
+ {
+ return $this->chatService->appendMessage([
+ 'session_id' => $sessionId,
+ 'role' => Message::ROLE_SYSTEM,
+ 'type' => 'error',
+ 'content' => $code,
+ 'payload' => array_merge($meta, [
+ 'run_id' => $runId,
+ 'message' => $message,
+ ]),
+ ]);
+ }
+}
diff --git a/app/Services/RunDispatcher.php b/app/Services/RunDispatcher.php
new file mode 100644
index 0000000..0016389
--- /dev/null
+++ b/app/Services/RunDispatcher.php
@@ -0,0 +1,60 @@
+chatService->getMessage($sessionId, $triggerMessageId);
+ if (! $triggerMessage) {
+ throw (new ModelNotFoundException())->setModel(Message::class, [$triggerMessageId]);
+ }
+
+ $existingForTrigger = Message::query()
+ ->where('session_id', $sessionId)
+ ->where('type', 'run.status')
+ ->where('payload->trigger_message_id', $triggerMessageId)
+ ->orderByDesc('seq')
+ ->first();
+
+ if ($existingForTrigger && ($existingForTrigger->payload['run_id'] ?? null)) {
+ return $existingForTrigger->payload['run_id'];
+ }
+
+ $latestStatus = Message::query()
+ ->where('session_id', $sessionId)
+ ->where('type', 'run.status')
+ ->orderByDesc('seq')
+ ->first();
+
+ if ($latestStatus && ($latestStatus->payload['status'] ?? null) === 'RUNNING' && ($latestStatus->payload['run_id'] ?? null)) {
+ return $latestStatus->payload['run_id'];
+ }
+
+ $runId = (string) Str::uuid();
+
+ $this->outputSink->appendRunStatus($sessionId, $runId, 'RUNNING', [
+ 'trigger_message_id' => $triggerMessageId,
+ 'dedupe_key' => 'run:trigger:'.$triggerMessageId,
+ ]);
+
+ dispatch(new AgentRunJob($sessionId, $runId));
+
+ return $runId;
+ }
+}
diff --git a/app/Services/RunLoop.php b/app/Services/RunLoop.php
new file mode 100644
index 0000000..e8d2d26
--- /dev/null
+++ b/app/Services/RunLoop.php
@@ -0,0 +1,52 @@
+cancelChecker->isCanceled($sessionId, $runId)) {
+ $this->outputSink->appendRunStatus($sessionId, $runId, 'CANCELED', [
+ 'dedupe_key' => "run:{$runId}:status:CANCELED",
+ ]);
+ return;
+ }
+
+ $context = $this->contextBuilder->build($sessionId, $runId);
+
+ if ($this->cancelChecker->isCanceled($sessionId, $runId)) {
+ $this->outputSink->appendRunStatus($sessionId, $runId, 'CANCELED', [
+ 'dedupe_key' => "run:{$runId}:status:CANCELED",
+ ]);
+ return;
+ }
+
+ $reply = $this->provider->generate($context);
+
+ if ($this->cancelChecker->isCanceled($sessionId, $runId)) {
+ $this->outputSink->appendRunStatus($sessionId, $runId, 'CANCELED', [
+ 'dedupe_key' => "run:{$runId}:status:CANCELED",
+ ]);
+ return;
+ }
+
+ $this->outputSink->appendAgentMessage($sessionId, $runId, $reply, [
+ 'provider' => $this->provider instanceof DummyAgentProvider ? 'dummy' : get_class($this->provider),
+ ]);
+ $this->outputSink->appendRunStatus($sessionId, $runId, 'DONE', [
+ 'dedupe_key' => "run:{$runId}:status:DONE",
+ ]);
+ }
+}
diff --git a/boost.json b/boost.json
new file mode 100644
index 0000000..1d440df
--- /dev/null
+++ b/boost.json
@@ -0,0 +1,14 @@
+{
+ "agents": [
+ "claude_code",
+ "codex",
+ "phpstorm"
+ ],
+ "editors": [
+ "claude_code",
+ "codex",
+ "phpstorm"
+ ],
+ "guidelines": [],
+ "sail": true
+}
diff --git a/bootstrap/app.php b/bootstrap/app.php
index 2078913..4c45c55 100644
--- a/bootstrap/app.php
+++ b/bootstrap/app.php
@@ -14,6 +14,9 @@ return Application::configure(basePath: dirname(__DIR__))
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
+ ->withProviders([
+ App\Providers\HorizonServiceProvider::class,
+ ])
->withMiddleware(function (Middleware $middleware): void {
$middleware->append(HandleCors::class);
$middleware->alias([
diff --git a/bootstrap/providers.php b/bootstrap/providers.php
index 38b258d..a3a6d17 100644
--- a/bootstrap/providers.php
+++ b/bootstrap/providers.php
@@ -2,4 +2,6 @@
return [
App\Providers\AppServiceProvider::class,
+ App\Providers\HorizonServiceProvider::class,
+ App\Providers\TelescopeServiceProvider::class,
];
diff --git a/composer.json b/composer.json
index 4c789a1..43d4eb1 100644
--- a/composer.json
+++ b/composer.json
@@ -8,12 +8,16 @@
"require": {
"php": "^8.2",
"laravel/framework": "^12.0",
+ "laravel/horizon": "^5.40",
"laravel/octane": "^2.13",
+ "laravel/telescope": "^5.16",
"laravel/tinker": "^2.10.1",
- "php-open-source-saver/jwt-auth": "^2.8"
+ "php-open-source-saver/jwt-auth": "^2.8",
+ "ext-redis": "*"
},
"require-dev": {
"fakerphp/faker": "^1.23",
+ "laravel/boost": "^1.8",
"laravel/pail": "^1.2.2",
"laravel/pint": "^1.24",
"laravel/sail": "^1.41",
diff --git a/composer.lock b/composer.lock
index 7b49452..8b537d3 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "79f1e234537460fac440cd9aa68d3e6b",
+ "content-hash": "effce82b9c1c86b0542543b2f1034edc",
"packages": [
{
"name": "brick/math",
@@ -1142,16 +1142,16 @@
},
{
"name": "laravel/framework",
- "version": "v12.42.0",
+ "version": "v12.43.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/framework.git",
- "reference": "509b33095564c5165366d81bbaa0afaac28abe75"
+ "reference": "9f875fad08f5d409b4c33293eca34f7af36e8ecf"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/framework/zipball/509b33095564c5165366d81bbaa0afaac28abe75",
- "reference": "509b33095564c5165366d81bbaa0afaac28abe75",
+ "url": "https://api.github.com/repos/laravel/framework/zipball/9f875fad08f5d409b4c33293eca34f7af36e8ecf",
+ "reference": "9f875fad08f5d409b4c33293eca34f7af36e8ecf",
"shasum": ""
},
"require": {
@@ -1360,20 +1360,99 @@
"issues": "https://github.com/laravel/framework/issues",
"source": "https://github.com/laravel/framework"
},
- "time": "2025-12-09T15:51:23+00:00"
+ "time": "2025-12-16T15:27:26+00:00"
},
{
- "name": "laravel/octane",
- "version": "v2.13.2",
+ "name": "laravel/horizon",
+ "version": "v5.41.0",
"source": {
"type": "git",
- "url": "https://github.com/laravel/octane.git",
- "reference": "5b963d2da879f2cad3a84f22bafd3d8be7170988"
+ "url": "https://github.com/laravel/horizon.git",
+ "reference": "eb6738246ab9d3450b705126b9794dfb0ea371b3"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/octane/zipball/5b963d2da879f2cad3a84f22bafd3d8be7170988",
- "reference": "5b963d2da879f2cad3a84f22bafd3d8be7170988",
+ "url": "https://api.github.com/repos/laravel/horizon/zipball/eb6738246ab9d3450b705126b9794dfb0ea371b3",
+ "reference": "eb6738246ab9d3450b705126b9794dfb0ea371b3",
+ "shasum": ""
+ },
+ "require": {
+ "ext-json": "*",
+ "ext-pcntl": "*",
+ "ext-posix": "*",
+ "illuminate/contracts": "^9.21|^10.0|^11.0|^12.0",
+ "illuminate/queue": "^9.21|^10.0|^11.0|^12.0",
+ "illuminate/support": "^9.21|^10.0|^11.0|^12.0",
+ "nesbot/carbon": "^2.17|^3.0",
+ "php": "^8.0",
+ "ramsey/uuid": "^4.0",
+ "symfony/console": "^6.0|^7.0",
+ "symfony/error-handler": "^6.0|^7.0",
+ "symfony/polyfill-php83": "^1.28",
+ "symfony/process": "^6.0|^7.0"
+ },
+ "require-dev": {
+ "mockery/mockery": "^1.0",
+ "orchestra/testbench": "^7.55|^8.36|^9.15|^10.8",
+ "phpstan/phpstan": "^1.10|^2.0",
+ "predis/predis": "^1.1|^2.0|^3.0"
+ },
+ "suggest": {
+ "ext-redis": "Required to use the Redis PHP driver.",
+ "predis/predis": "Required when not using the Redis PHP driver (^1.1|^2.0|^3.0)."
+ },
+ "type": "library",
+ "extra": {
+ "laravel": {
+ "aliases": {
+ "Horizon": "Laravel\\Horizon\\Horizon"
+ },
+ "providers": [
+ "Laravel\\Horizon\\HorizonServiceProvider"
+ ]
+ },
+ "branch-alias": {
+ "dev-master": "6.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Laravel\\Horizon\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Taylor Otwell",
+ "email": "taylor@laravel.com"
+ }
+ ],
+ "description": "Dashboard and code-driven configuration for Laravel queues.",
+ "keywords": [
+ "laravel",
+ "queue"
+ ],
+ "support": {
+ "issues": "https://github.com/laravel/horizon/issues",
+ "source": "https://github.com/laravel/horizon/tree/v5.41.0"
+ },
+ "time": "2025-12-14T15:55:28+00:00"
+ },
+ {
+ "name": "laravel/octane",
+ "version": "v2.13.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/laravel/octane.git",
+ "reference": "aae775360fceae422651042d73137fff092ba800"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/laravel/octane/zipball/aae775360fceae422651042d73137fff092ba800",
+ "reference": "aae775360fceae422651042d73137fff092ba800",
"shasum": ""
},
"require": {
@@ -1450,7 +1529,7 @@
"issues": "https://github.com/laravel/octane/issues",
"source": "https://github.com/laravel/octane"
},
- "time": "2025-11-28T20:13:00+00:00"
+ "time": "2025-12-10T15:24:24+00:00"
},
{
"name": "laravel/prompts",
@@ -1572,6 +1651,74 @@
},
"time": "2025-11-21T20:52:36+00:00"
},
+ {
+ "name": "laravel/telescope",
+ "version": "v5.16.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/laravel/telescope.git",
+ "reference": "a868e91a0912d6a44363636f7467a8578db83026"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/laravel/telescope/zipball/a868e91a0912d6a44363636f7467a8578db83026",
+ "reference": "a868e91a0912d6a44363636f7467a8578db83026",
+ "shasum": ""
+ },
+ "require": {
+ "ext-json": "*",
+ "laravel/framework": "^8.37|^9.0|^10.0|^11.0|^12.0",
+ "php": "^8.0",
+ "symfony/console": "^5.3|^6.0|^7.0",
+ "symfony/var-dumper": "^5.0|^6.0|^7.0"
+ },
+ "require-dev": {
+ "ext-gd": "*",
+ "guzzlehttp/guzzle": "^6.0|^7.0",
+ "laravel/octane": "^1.4|^2.0",
+ "orchestra/testbench": "^6.47.1|^7.55|^8.36|^9.15|^10.8",
+ "phpstan/phpstan": "^1.10"
+ },
+ "type": "library",
+ "extra": {
+ "laravel": {
+ "providers": [
+ "Laravel\\Telescope\\TelescopeServiceProvider"
+ ]
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Laravel\\Telescope\\": "src/",
+ "Laravel\\Telescope\\Database\\Factories\\": "database/factories/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Taylor Otwell",
+ "email": "taylor@laravel.com"
+ },
+ {
+ "name": "Mohamed Said",
+ "email": "mohamed@laravel.com"
+ }
+ ],
+ "description": "An elegant debug assistant for the Laravel framework.",
+ "keywords": [
+ "debugging",
+ "laravel",
+ "monitoring"
+ ],
+ "support": {
+ "issues": "https://github.com/laravel/telescope/issues",
+ "source": "https://github.com/laravel/telescope/tree/v5.16.0"
+ },
+ "time": "2025-12-09T13:34:29+00:00"
+ },
{
"name": "laravel/tinker",
"version": "v2.10.2",
@@ -3429,16 +3576,16 @@
},
{
"name": "psy/psysh",
- "version": "v0.12.16",
+ "version": "v0.12.17",
"source": {
"type": "git",
"url": "https://github.com/bobthecow/psysh.git",
- "reference": "ee6d5028be4774f56c6c2c85ec4e6bc9acfe6b67"
+ "reference": "85fbbd9f3064e157fc21fe4362b2b5c19f2ea631"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/bobthecow/psysh/zipball/ee6d5028be4774f56c6c2c85ec4e6bc9acfe6b67",
- "reference": "ee6d5028be4774f56c6c2c85ec4e6bc9acfe6b67",
+ "url": "https://api.github.com/repos/bobthecow/psysh/zipball/85fbbd9f3064e157fc21fe4362b2b5c19f2ea631",
+ "reference": "85fbbd9f3064e157fc21fe4362b2b5c19f2ea631",
"shasum": ""
},
"require": {
@@ -3502,9 +3649,9 @@
],
"support": {
"issues": "https://github.com/bobthecow/psysh/issues",
- "source": "https://github.com/bobthecow/psysh/tree/v0.12.16"
+ "source": "https://github.com/bobthecow/psysh/tree/v0.12.17"
},
- "time": "2025-12-07T03:39:01+00:00"
+ "time": "2025-12-15T04:55:34+00:00"
},
{
"name": "ralouphie/getallheaders",
@@ -3628,20 +3775,20 @@
},
{
"name": "ramsey/uuid",
- "version": "4.9.1",
+ "version": "4.9.2",
"source": {
"type": "git",
"url": "https://github.com/ramsey/uuid.git",
- "reference": "81f941f6f729b1e3ceea61d9d014f8b6c6800440"
+ "reference": "8429c78ca35a09f27565311b98101e2826affde0"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/ramsey/uuid/zipball/81f941f6f729b1e3ceea61d9d014f8b6c6800440",
- "reference": "81f941f6f729b1e3ceea61d9d014f8b6c6800440",
+ "url": "https://api.github.com/repos/ramsey/uuid/zipball/8429c78ca35a09f27565311b98101e2826affde0",
+ "reference": "8429c78ca35a09f27565311b98101e2826affde0",
"shasum": ""
},
"require": {
- "brick/math": "^0.8.8 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14",
+ "brick/math": "^0.8.16 || ^0.9 || ^0.10 || ^0.11 || ^0.12 || ^0.13 || ^0.14",
"php": "^8.0",
"ramsey/collection": "^1.2 || ^2.0"
},
@@ -3700,9 +3847,9 @@
],
"support": {
"issues": "https://github.com/ramsey/uuid/issues",
- "source": "https://github.com/ramsey/uuid/tree/4.9.1"
+ "source": "https://github.com/ramsey/uuid/tree/4.9.2"
},
- "time": "2025-09-04T20:59:21+00:00"
+ "time": "2025-12-14T04:43:48+00:00"
},
{
"name": "symfony/clock",
@@ -6761,6 +6908,145 @@
},
"time": "2025-04-30T06:54:44+00:00"
},
+ {
+ "name": "laravel/boost",
+ "version": "v1.8.5",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/laravel/boost.git",
+ "reference": "99c15c392f3c6f049f0671dd5dc7b6e9a75cfe7e"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/laravel/boost/zipball/99c15c392f3c6f049f0671dd5dc7b6e9a75cfe7e",
+ "reference": "99c15c392f3c6f049f0671dd5dc7b6e9a75cfe7e",
+ "shasum": ""
+ },
+ "require": {
+ "guzzlehttp/guzzle": "^7.9",
+ "illuminate/console": "^10.49.0|^11.45.3|^12.28.1",
+ "illuminate/contracts": "^10.49.0|^11.45.3|^12.28.1",
+ "illuminate/routing": "^10.49.0|^11.45.3|^12.28.1",
+ "illuminate/support": "^10.49.0|^11.45.3|^12.28.1",
+ "laravel/mcp": "^0.4.1",
+ "laravel/prompts": "0.1.25|^0.3.6",
+ "laravel/roster": "^0.2.9",
+ "php": "^8.1"
+ },
+ "require-dev": {
+ "laravel/pint": "^1.20.0",
+ "mockery/mockery": "^1.6.12",
+ "orchestra/testbench": "^8.36.0|^9.15.0|^10.6",
+ "pestphp/pest": "^2.36.0|^3.8.4|^4.1.5",
+ "phpstan/phpstan": "^2.1.27",
+ "rector/rector": "^2.1"
+ },
+ "type": "library",
+ "extra": {
+ "laravel": {
+ "providers": [
+ "Laravel\\Boost\\BoostServiceProvider"
+ ]
+ },
+ "branch-alias": {
+ "dev-master": "1.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Laravel\\Boost\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "Laravel Boost accelerates AI-assisted development by providing the essential context and structure that AI needs to generate high-quality, Laravel-specific code.",
+ "homepage": "https://github.com/laravel/boost",
+ "keywords": [
+ "ai",
+ "dev",
+ "laravel"
+ ],
+ "support": {
+ "issues": "https://github.com/laravel/boost/issues",
+ "source": "https://github.com/laravel/boost"
+ },
+ "time": "2025-12-08T21:54:49+00:00"
+ },
+ {
+ "name": "laravel/mcp",
+ "version": "v0.4.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/laravel/mcp.git",
+ "reference": "1c7878be3931a19768f791ddf141af29f43fb4ef"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/laravel/mcp/zipball/1c7878be3931a19768f791ddf141af29f43fb4ef",
+ "reference": "1c7878be3931a19768f791ddf141af29f43fb4ef",
+ "shasum": ""
+ },
+ "require": {
+ "ext-json": "*",
+ "ext-mbstring": "*",
+ "illuminate/console": "^10.49.0|^11.45.3|^12.41.1",
+ "illuminate/container": "^10.49.0|^11.45.3|^12.41.1",
+ "illuminate/contracts": "^10.49.0|^11.45.3|^12.41.1",
+ "illuminate/http": "^10.49.0|^11.45.3|^12.41.1",
+ "illuminate/json-schema": "^12.41.1",
+ "illuminate/routing": "^10.49.0|^11.45.3|^12.41.1",
+ "illuminate/support": "^10.49.0|^11.45.3|^12.41.1",
+ "illuminate/validation": "^10.49.0|^11.45.3|^12.41.1",
+ "php": "^8.1"
+ },
+ "require-dev": {
+ "laravel/pint": "^1.20",
+ "orchestra/testbench": "^8.36|^9.15|^10.8",
+ "pestphp/pest": "^2.36.0|^3.8.4|^4.1.0",
+ "phpstan/phpstan": "^2.1.27",
+ "rector/rector": "^2.2.4"
+ },
+ "type": "library",
+ "extra": {
+ "laravel": {
+ "aliases": {
+ "Mcp": "Laravel\\Mcp\\Server\\Facades\\Mcp"
+ },
+ "providers": [
+ "Laravel\\Mcp\\Server\\McpServiceProvider"
+ ]
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Laravel\\Mcp\\": "src/",
+ "Laravel\\Mcp\\Server\\": "src/Server/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Taylor Otwell",
+ "email": "taylor@laravel.com"
+ }
+ ],
+ "description": "Rapidly build MCP servers for your Laravel applications.",
+ "homepage": "https://github.com/laravel/mcp",
+ "keywords": [
+ "laravel",
+ "mcp"
+ ],
+ "support": {
+ "issues": "https://github.com/laravel/mcp/issues",
+ "source": "https://github.com/laravel/mcp"
+ },
+ "time": "2025-12-07T15:49:15+00:00"
+ },
{
"name": "laravel/pail",
"version": "v1.2.4",
@@ -6907,6 +7193,67 @@
},
"time": "2025-11-25T21:15:52+00:00"
},
+ {
+ "name": "laravel/roster",
+ "version": "v0.2.9",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/laravel/roster.git",
+ "reference": "82bbd0e2de614906811aebdf16b4305956816fa6"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/laravel/roster/zipball/82bbd0e2de614906811aebdf16b4305956816fa6",
+ "reference": "82bbd0e2de614906811aebdf16b4305956816fa6",
+ "shasum": ""
+ },
+ "require": {
+ "illuminate/console": "^10.0|^11.0|^12.0",
+ "illuminate/contracts": "^10.0|^11.0|^12.0",
+ "illuminate/routing": "^10.0|^11.0|^12.0",
+ "illuminate/support": "^10.0|^11.0|^12.0",
+ "php": "^8.1|^8.2",
+ "symfony/yaml": "^6.4|^7.2"
+ },
+ "require-dev": {
+ "laravel/pint": "^1.14",
+ "mockery/mockery": "^1.6",
+ "orchestra/testbench": "^8.22.0|^9.0|^10.0",
+ "pestphp/pest": "^2.0|^3.0",
+ "phpstan/phpstan": "^2.0"
+ },
+ "type": "library",
+ "extra": {
+ "laravel": {
+ "providers": [
+ "Laravel\\Roster\\RosterServiceProvider"
+ ]
+ },
+ "branch-alias": {
+ "dev-master": "1.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Laravel\\Roster\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "description": "Detect packages & approaches in use within a Laravel project",
+ "homepage": "https://github.com/laravel/roster",
+ "keywords": [
+ "dev",
+ "laravel"
+ ],
+ "support": {
+ "issues": "https://github.com/laravel/roster/issues",
+ "source": "https://github.com/laravel/roster"
+ },
+ "time": "2025-10-20T09:56:46+00:00"
+ },
{
"name": "laravel/sail",
"version": "v1.51.0",
@@ -8945,7 +9292,8 @@
"prefer-stable": true,
"prefer-lowest": false,
"platform": {
- "php": "^8.2"
+ "php": "^8.2",
+ "ext-redis": "*"
},
"platform-dev": {},
"plugin-api-version": "2.9.0"
diff --git a/config/horizon.php b/config/horizon.php
new file mode 100644
index 0000000..a011f32
--- /dev/null
+++ b/config/horizon.php
@@ -0,0 +1,186 @@
+ env('HORIZON_DOMAIN'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Horizon Path
+ |--------------------------------------------------------------------------
+ |
+ | This is the URI path where Horizon will be accessible from. Feel free
+ | to change this path to anything you like. Note that the URI will not
+ | affect the paths of its internal API that aren't exposed to users.
+ |
+ */
+
+ 'path' => env('HORIZON_PATH', 'horizon'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Horizon Redis Connection
+ |--------------------------------------------------------------------------
+ |
+ | This is the name of the Redis connection where Horizon will store the
+ | meta information required for it to function. It includes the list
+ | of supervisors, failed and completed jobs, job metrics, and more.
+ |
+ */
+
+ 'use' => 'default',
+
+ /*
+ |--------------------------------------------------------------------------
+ | Horizon Redis Prefix
+ |--------------------------------------------------------------------------
+ |
+ | This prefix will be used when storing all Horizon data in Redis. You
+ | may modify the prefix when you are running multiple installations
+ | of Horizon on the same server so that they don't have problems.
+ |
+ */
+
+ 'prefix' => env('HORIZON_PREFIX', 'horizon:{single}'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Horizon Route Middleware
+ |--------------------------------------------------------------------------
+ |
+ | These middleware will be assigned to every Horizon route, giving you the
+ | chance to add your own middleware to this list or change any of the
+ | existing middleware. Or, you can simply stick with this default list.
+ |
+ */
+
+ 'middleware' => ['web'],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Queue Wait Time Thresholds
+ |--------------------------------------------------------------------------
+ |
+ | This option allows you to configure when Horizon will notify you the
+ | queue is waiting too long to process jobs. Each connection / queue
+ | combination may have its own, unique threshold (in seconds).
+ |
+ */
+
+ 'waits' => [
+ 'redis:default' => env('HORIZON_WAIT_TIME', 60),
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Job Trimming Times
+ |--------------------------------------------------------------------------
+ |
+ | Here you can configure for how long (in minutes) you desire Horizon to
+ | persist the recent and failed jobs. Typically, recent jobs are kept
+ | for one hour while all failed jobs are stored for an entire week.
+ |
+ */
+
+ 'trim' => [
+ 'recent' => 60,
+ 'pending' => 60,
+ 'completed' => 60,
+ 'recent_failed' => 10080,
+ 'failed' => 10080,
+ 'monitored' => 1440,
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Silenced Jobs
+ |--------------------------------------------------------------------------
+ |
+ | Silent jobs are not displayed in the Horizon UI. This allows you to
+ | fully avoid showing them or incrementing the failed jobs count
+ | or displaying them on the monitoring charts. They're silenced.
+ |
+ */
+
+ 'silenced' => [
+ // App\Jobs\QuietJob::class,
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Fast Termination
+ |--------------------------------------------------------------------------
+ |
+ | When this option is enabled, Horizon's "terminate" command will not
+ | wait on all of the workers to terminate unless the --wait option
+ | is provided. Fast termination can shorten deployment delay by
+ | allowing a new instance of Horizon to start immediately.
+ |
+ */
+
+ 'fast_termination' => false,
+
+ /*
+ |--------------------------------------------------------------------------
+ | Memory Limit (MB)
+ |--------------------------------------------------------------------------
+ |
+ | This value describes the maximum amount of memory the Horizon worker
+ | may consume before it is terminated and restarted. You should set
+ | this value according to the resources available to your server.
+ |
+ */
+
+ 'memory_limit' => env('HORIZON_MEMORY_LIMIT', 128),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Queue Worker Configuration
+ |--------------------------------------------------------------------------
+ |
+ | Here you may define the queue worker settings used by your application
+ | in all environments. These supervisors and settings handle all your
+ | queued jobs and will be provisioned by Horizon during deployment.
+ |
+ */
+
+ 'environments' => [
+ 'production' => [
+ 'supervisor-default' => [
+ 'connection' => 'redis',
+ 'queue' => ['default'],
+ 'balance' => 'auto',
+ 'maxProcesses' => 10,
+ 'maxTime' => 60,
+ 'maxJobs' => 1000,
+ 'memory' => 256,
+ 'tries' => 3,
+ ],
+ ],
+
+ 'local' => [
+ 'supervisor-default' => [
+ 'connection' => 'redis',
+ 'queue' => ['default'],
+ 'balance' => 'simple',
+ 'maxProcesses' => env('HORIZON_PROCESSES', 3),
+ 'maxTime' => 60,
+ 'maxJobs' => 500,
+ 'memory' => 256,
+ 'tries' => 3,
+ ],
+ ],
+ ],
+
+];
diff --git a/config/queue.php b/config/queue.php
index 79c2c0a..086af4d 100644
--- a/config/queue.php
+++ b/config/queue.php
@@ -32,7 +32,7 @@ return [
'connections' => [
'sync' => [
- 'driver' => 'sync',
+ 'driver' => 'redis',
],
'database' => [
diff --git a/config/telescope.php b/config/telescope.php
new file mode 100644
index 0000000..a31216b
--- /dev/null
+++ b/config/telescope.php
@@ -0,0 +1,212 @@
+ env('TELESCOPE_ENABLED', false),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Telescope Domain
+ |--------------------------------------------------------------------------
+ |
+ | This is the subdomain where Telescope will be accessible from. If the
+ | setting is null, Telescope will reside under the same domain as the
+ | application. Otherwise, this value will be used as the subdomain.
+ |
+ */
+
+ 'domain' => env('TELESCOPE_DOMAIN'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Telescope Path
+ |--------------------------------------------------------------------------
+ |
+ | This is the URI path where Telescope will be accessible from. Feel free
+ | to change this path to anything you like. Note that the URI will not
+ | affect the paths of its internal API that aren't exposed to users.
+ |
+ */
+
+ 'path' => env('TELESCOPE_PATH', 'telescope'),
+
+ /*
+ |--------------------------------------------------------------------------
+ | Telescope Storage Driver
+ |--------------------------------------------------------------------------
+ |
+ | This configuration options determines the storage driver that will
+ | be used to store Telescope's data. In addition, you may set any
+ | custom options as needed by the particular driver you choose.
+ |
+ */
+
+ 'driver' => env('TELESCOPE_DRIVER', 'database'),
+
+ 'storage' => [
+ 'database' => [
+ 'connection' => env('DB_CONNECTION', 'mysql'),
+ 'chunk' => 1000,
+ ],
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Telescope Queue
+ |--------------------------------------------------------------------------
+ |
+ | This configuration options determines the queue connection and queue
+ | which will be used to process ProcessPendingUpdate jobs. This can
+ | be changed if you would prefer to use a non-default connection.
+ |
+ */
+
+ 'queue' => [
+ 'connection' => env('TELESCOPE_QUEUE_CONNECTION'),
+ 'queue' => env('TELESCOPE_QUEUE'),
+ 'delay' => env('TELESCOPE_QUEUE_DELAY', 10),
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Telescope Route Middleware
+ |--------------------------------------------------------------------------
+ |
+ | These middleware will be assigned to every Telescope route, giving you
+ | the chance to add your own middleware to this list or change any of
+ | the existing middleware. Or, you can simply stick with this list.
+ |
+ */
+
+ 'middleware' => [
+ 'web',
+ Authorize::class,
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Allowed / Ignored Paths & Commands
+ |--------------------------------------------------------------------------
+ |
+ | The following array lists the URI paths and Artisan commands that will
+ | not be watched by Telescope. In addition to this list, some Laravel
+ | commands, like migrations and queue commands, are always ignored.
+ |
+ */
+
+ 'only_paths' => [
+ // 'api/*'
+ ],
+
+ 'ignore_paths' => [
+ 'livewire*',
+ 'nova-api*',
+ 'pulse*',
+ '_boost*',
+ 'telescope/*'
+ ],
+
+ 'ignore_commands' => [
+ //
+ ],
+
+ /*
+ |--------------------------------------------------------------------------
+ | Telescope Watchers
+ |--------------------------------------------------------------------------
+ |
+ | The following array lists the "watchers" that will be registered with
+ | Telescope. The watchers gather the application's profile data when
+ | a request or task is executed. Feel free to customize this list.
+ |
+ */
+
+ 'watchers' => [
+ Watchers\BatchWatcher::class => env('TELESCOPE_BATCH_WATCHER', true),
+
+ Watchers\CacheWatcher::class => [
+ 'enabled' => env('TELESCOPE_CACHE_WATCHER', true),
+ 'hidden' => [],
+ 'ignore' => [],
+ ],
+
+ Watchers\ClientRequestWatcher::class => [
+ 'enabled' => env('TELESCOPE_CLIENT_REQUEST_WATCHER', true),
+ 'ignore_hosts' => [],
+ ],
+
+ Watchers\CommandWatcher::class => [
+ 'enabled' => env('TELESCOPE_COMMAND_WATCHER', true),
+ 'ignore' => [],
+ ],
+
+ Watchers\DumpWatcher::class => [
+ 'enabled' => env('TELESCOPE_DUMP_WATCHER', true),
+ 'always' => env('TELESCOPE_DUMP_WATCHER_ALWAYS', false),
+ ],
+
+ Watchers\EventWatcher::class => [
+ 'enabled' => env('TELESCOPE_EVENT_WATCHER', true),
+ 'ignore' => [],
+ ],
+
+ Watchers\ExceptionWatcher::class => env('TELESCOPE_EXCEPTION_WATCHER', true),
+
+ Watchers\GateWatcher::class => [
+ 'enabled' => env('TELESCOPE_GATE_WATCHER', true),
+ 'ignore_abilities' => [],
+ 'ignore_packages' => true,
+ 'ignore_paths' => [],
+ ],
+
+ Watchers\JobWatcher::class => env('TELESCOPE_JOB_WATCHER', true),
+
+ Watchers\LogWatcher::class => [
+ 'enabled' => env('TELESCOPE_LOG_WATCHER', true),
+ 'level' => 'error',
+ ],
+
+ Watchers\MailWatcher::class => env('TELESCOPE_MAIL_WATCHER', true),
+
+ Watchers\ModelWatcher::class => [
+ 'enabled' => env('TELESCOPE_MODEL_WATCHER', true),
+ 'events' => ['eloquent.*'],
+ 'hydrations' => true,
+ ],
+
+ Watchers\NotificationWatcher::class => env('TELESCOPE_NOTIFICATION_WATCHER', true),
+
+ Watchers\QueryWatcher::class => [
+ 'enabled' => env('TELESCOPE_QUERY_WATCHER', true),
+ 'ignore_packages' => true,
+ 'ignore_paths' => [],
+ 'slow' => 100,
+ ],
+
+ Watchers\RedisWatcher::class => env('TELESCOPE_REDIS_WATCHER', true),
+
+ Watchers\RequestWatcher::class => [
+ 'enabled' => env('TELESCOPE_REQUEST_WATCHER', true),
+ 'size_limit' => env('TELESCOPE_RESPONSE_SIZE_LIMIT', 64),
+ 'ignore_http_methods' => [],
+ 'ignore_status_codes' => [],
+ ],
+
+ Watchers\ScheduleWatcher::class => env('TELESCOPE_SCHEDULE_WATCHER', true),
+ Watchers\ViewWatcher::class => env('TELESCOPE_VIEW_WATCHER', true),
+ ],
+];
diff --git a/database/migrations/2025_12_16_140800_create_telescope_entries_table.php b/database/migrations/2025_12_16_140800_create_telescope_entries_table.php
new file mode 100644
index 0000000..700a83f
--- /dev/null
+++ b/database/migrations/2025_12_16_140800_create_telescope_entries_table.php
@@ -0,0 +1,70 @@
+getConnection());
+
+ $schema->create('telescope_entries', function (Blueprint $table) {
+ $table->bigIncrements('sequence');
+ $table->uuid('uuid');
+ $table->uuid('batch_id');
+ $table->string('family_hash')->nullable();
+ $table->boolean('should_display_on_index')->default(true);
+ $table->string('type', 20);
+ $table->longText('content');
+ $table->dateTime('created_at')->nullable();
+
+ $table->unique('uuid');
+ $table->index('batch_id');
+ $table->index('family_hash');
+ $table->index('created_at');
+ $table->index(['type', 'should_display_on_index']);
+ });
+
+ $schema->create('telescope_entries_tags', function (Blueprint $table) {
+ $table->uuid('entry_uuid');
+ $table->string('tag');
+
+ $table->primary(['entry_uuid', 'tag']);
+ $table->index('tag');
+
+ $table->foreign('entry_uuid')
+ ->references('uuid')
+ ->on('telescope_entries')
+ ->onDelete('cascade');
+ });
+
+ $schema->create('telescope_monitoring', function (Blueprint $table) {
+ $table->string('tag')->primary();
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ $schema = Schema::connection($this->getConnection());
+
+ $schema->dropIfExists('telescope_entries_tags');
+ $schema->dropIfExists('telescope_entries');
+ $schema->dropIfExists('telescope_monitoring');
+ }
+};
diff --git a/docker-compose.yml b/docker-compose-backup.yml
similarity index 60%
rename from docker-compose.yml
rename to docker-compose-backup.yml
index 9bc21bf..7e9472a 100644
--- a/docker-compose.yml
+++ b/docker-compose-backup.yml
@@ -3,6 +3,7 @@ services:
build:
context: .
dockerfile: docker/app/Dockerfile
+ image: ars_backend:latest
entrypoint: ["/app/docker/app/entrypoint.sh"]
volumes:
- ./:/app
@@ -20,6 +21,7 @@ services:
REDIS_HOST: redis
REDIS_PASSWORD: "null"
REDIS_PORT: 6379
+ QUEUE_CONNECTION: redis
ports:
- "8000:8000"
extra_hosts:
@@ -29,6 +31,35 @@ services:
- redis
tty: true
+ horizon:
+ build:
+ context: .
+ dockerfile: docker/app/Dockerfile
+ image: ars_backend:latest
+ entrypoint: ["/app/docker/app/entrypoint.sh"]
+ environment:
+ CONTAINER_ROLE: horizon
+ APP_ENV: local
+ APP_DEBUG: "true"
+ APP_URL: http://localhost:8000
+ OCTANE_SERVER: frankenphp
+ DB_CONNECTION: pgsql
+ DB_HOST: pgsql
+ DB_PORT: 5432
+ DB_DATABASE: ars_backend
+ DB_USERNAME: ars
+ DB_PASSWORD: secret
+ REDIS_HOST: redis
+ REDIS_PASSWORD: "null"
+ REDIS_PORT: 6379
+ QUEUE_CONNECTION: redis
+ volumes:
+ - ./:/app
+ depends_on:
+ - pgsql
+ - redis
+ tty: true
+
pgsql:
image: postgres:16-alpine
environment:
diff --git a/docker/app/Dockerfile b/docker/app/Dockerfile
index ae16326..46caf60 100644
--- a/docker/app/Dockerfile
+++ b/docker/app/Dockerfile
@@ -1,4 +1,4 @@
-FROM dunglas/frankenphp:1-php8.3
+FROM dunglas/frankenphp:1-php8.2
WORKDIR /app
diff --git a/docker/app/entrypoint.sh b/docker/app/entrypoint.sh
index 1c3c33d..b27881e 100755
--- a/docker/app/entrypoint.sh
+++ b/docker/app/entrypoint.sh
@@ -15,4 +15,15 @@ if ! grep -q "^APP_KEY=" .env 2>/dev/null || grep -q "^APP_KEY=$" .env 2>/dev/nu
php artisan key:generate --force
fi
-exec php artisan octane:start --server=frankenphp --host=0.0.0.0 --port="${PORT:-8000}" --watch
+ROLE="${CONTAINER_ROLE:-app}"
+
+if [ "$ROLE" = "queue" ]; then
+ exec php artisan queue:work --verbose --tries="${QUEUE_TRIES:-3}" --timeout="${QUEUE_TIMEOUT:-90}"
+fi
+
+if [ "$ROLE" = "horizon" ]; then
+ exec php artisan horizon
+fi
+
+exec php artisan octane:start --server=frankenphp --host=0.0.0.0 --port="${PORT:-8000}"
+#exec php -S 0.0.0.0:8000 -t public
diff --git a/docs/ChatSession/chat-session-api.md b/docs/ChatSession/chat-session-api.md
index 12748cc..74a0ece 100644
--- a/docs/ChatSession/chat-session-api.md
+++ b/docs/ChatSession/chat-session-api.md
@@ -1,4 +1,4 @@
-# ChatSession & Message API(MVP-1)
+# ChatSession & Message API(MVP-1 + Agent Run MVP-0)
基地址:`http://localhost:8000/api`(FrankenPHP 容器 8000 端口)
认证方式:JWT,`Authorization: Bearer {token}`
@@ -7,6 +7,7 @@
## 变更记录
- 2025-02-14:新增 ChatSession 创建、消息追加、增量查询接口;支持状态门禁与 dedupe 幂等。
- 2025-02-14:MVP-1.1 增加会话列表、会话更新(重命名/状态变更),列表附带最后一条消息摘要。
+- 2025-02-15:Agent Run MVP-0 —— RunDispatcher + AgentRunJob + DummyProvider;自动在 user.prompt 后触发一次 Run,落地 run.status / agent.message。
## 领域模型
- `ChatSession`:`session_id`(UUID)、`session_name`、`status`(`OPEN`/`LOCKED`/`CLOSED`)、`last_seq`
@@ -19,23 +20,26 @@
### 创建会话
- `POST /sessions`
- 请求体字段
+
| 字段 | 必填 | 类型 | 说明 |
| --- | --- | --- | --- |
| session_name | 否 | string(≤255) | 会话名称 |
- 响应 201(JSON)
- | 字段 | 类型 | 说明 |
- | --- | --- | --- |
- | session_id | uuid | 主键 |
- | session_name | string|null | 会话名 |
- | status | enum | `OPEN|LOCKED|CLOSED` |
- | last_seq | int | 当前最大 seq |
- | last_message_id | uuid|null | 最后一条消息 |
- | created_at, updated_at | datetime | 时间戳 |
+
+ | 字段 | 类型 | 说明 |
+ | --- | --- | --- |
+ | session_id | uuid | 主键 |
+ | session_name | string|null | 会话名 |
+ | status | enum | `OPEN|LOCKED|CLOSED` |
+ | last_seq | int | 当前最大 seq |
+ | last_message_id | uuid|null | 最后一条消息 |
+ | created_at, updated_at | datetime | 时间戳 |
- 错误:401 未授权
### 追加消息
- `POST /sessions/{session_id}/messages`
- 请求体字段
+
| 字段 | 必填 | 类型 | 说明 |
| --- | --- | --- | --- |
| role | 是 | enum | `USER|AGENT|TOOL|SYSTEM` |
@@ -52,6 +56,7 @@
### 按序增量查询
- `GET /sessions/{session_id}/messages?after_seq=0&limit=50`
- 查询参数
+
| 参数 | 默认 | 类型 | 说明 |
| --- | --- | --- | --- |
| after_seq | 0 | int | 仅返回 seq 大于该值 |
@@ -62,6 +67,7 @@
### 会话列表
- `GET /sessions?page=1&per_page=15&status=OPEN&q=keyword`
- 查询参数
+
| 参数 | 默认 | 类型 | 说明 |
| --- | --- | --- | --- |
| page | 1 | int | 分页页码 |
@@ -69,6 +75,7 @@
| status | - | enum | 过滤 `OPEN|LOCKED|CLOSED` |
| q | - | string | ILIKE 模糊匹配 session_name |
- 响应 200:分页结构(`data/links/meta`),`data` 每项字段:
+
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| session_id | uuid | 会话主键 |
@@ -86,7 +93,8 @@
### 会话更新
- `PATCH /sessions/{session_id}`
-- 请求体(至少一项,否则 422)
+ 请求体(至少一项,否则 422)
+
| 字段 | 必填 | 类型 | 说明 |
| --- | --- | --- | --- |
| session_name | 否 | string 1..255 | 自动 trim |
@@ -118,6 +126,7 @@
- `GET /sessions/{session_id}/sse?after_seq=123`
- 头部:`Accept: text/event-stream`,可带 `Last-Event-ID`(优先于 query)用于断线续传。
- 查询参数
+
| 参数 | 默认 | 类型 | 说明 |
| --- | --- | --- | --- |
| after_seq | 0 | int | backlog 起始 seq(若有 Last-Event-ID 则覆盖) |
@@ -135,6 +144,38 @@
- 心跳:周期输出 `: ping` 保活(生产环境)。
- 错误:401 未授权;404 session 不存在。
+## Agent Run MVP-0(RunDispatcher + AgentRunJob)
+### 流程概述
+1. 用户追加 `role=USER && type=user.prompt` 后,Controller 自动调用 `RunDispatcher->dispatchForPrompt`。
+2. 并发保护:同会话只允许一个 RUNNING;同一个 `trigger_message_id` 幂等复用已有 `run_id`。
+3. 立即写入 `run.status`(SYSTEM/run.status,payload `{run_id,status:'RUNNING',trigger_message_id}`,dedupe_key=`run:trigger:{message_id}`)。
+4. 推送 `AgentRunJob(session_id, run_id)` 到队列(测试环境 QUEUE=sync 会同步执行)。
+5. RunLoop(使用 DummyAgentProvider):
+ - Cancel 检查:存在 `run.cancel.request`(payload.run_id) 则写入 `run.status=CANCELED`,不产出 agent.message。
+ - ContextBuilder:提取最近 20 条 USER/AGENT 消息(type in user.prompt/agent.message),seq 升序提供给 Provider。
+ - Provider 返回一次性文本回复。
+ - OutputSink 依次写入:`agent.message`(payload 含 run_id, provider)、`run.status=DONE`(dedupe_key=`run:{run_id}:status:DONE`)。
+6. 异常:AgentRunJob 捕获异常后写入 `error` + `run.status=FAILED`(dedupe)。
+
+### Run 相关消息类型(落库即真相源)
+| type | role | payload 关键字段 | 说明 |
+| --- | --- | --- | --- |
+| run.status | SYSTEM | run_id, status(RUNNING/DONE/CANCELED/FAILED), trigger_message_id?, error? | Run 生命周期事件,CLOSED 状态下允许写入 |
+| agent.message | AGENT | run_id, provider | Provider 的一次性回复 |
+| run.cancel.request | USER/SYSTEM | run_id | CancelChecker 依据该事件判断是否中止 |
+| error | SYSTEM | run_id, message | 任务异常时落库 |
+
+### 触发 Run(调试入口)
+- `POST /sessions/{session_id}/runs`
+- 请求体字段
+
+ | 字段 | 必填 | 类型 | 说明 |
+ | --- | --- | --- | --- |
+ | trigger_message_id | 是 | uuid | 通常为 `user.prompt` 消息 ID |
+- 行为:同 `trigger_message_id` 幂等;若已有 RUNNING 则复用其 run_id。
+- 响应 201:`{ run_id }`
+- 错误:401 未授权;404 session 不存在或 trigger_message 不属于该 session。
+
## cURL 示例
```bash
# 创建会话
@@ -163,4 +204,9 @@ curl -s http://localhost:8000/api/sessions/$SESSION_ID/messages/{message_id} \
curl -N http://localhost:8000/api/sessions/$SESSION_ID/sse?after_seq=10 \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: text/event-stream"
+
+# 手动触发 Run(调试用,实际 user.prompt 会自动触发)
+curl -s -X POST http://localhost:8000/api/sessions/$SESSION_ID/runs \
+ -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
+ -d '{"trigger_message_id":"'$MESSAGE_ID'"}'
```
diff --git a/docs/ChatSession/chat-session-openapi.yaml b/docs/ChatSession/chat-session-openapi.yaml
index 0d36e8e..6411846 100644
--- a/docs/ChatSession/chat-session-openapi.yaml
+++ b/docs/ChatSession/chat-session-openapi.yaml
@@ -1,15 +1,19 @@
openapi: 3.0.3
info:
title: ChatSession & Message API
- version: 1.0.0
+ version: 1.1.0
description: |
- ChatSession & Message MVP-1,支持会话创建、消息追加、增量查询。自然语言:中文。
+ ChatSession & Message API(含 Archive/GetMessage/SSE 与 Run 调度)。自然语言:中文。
servers:
- url: http://localhost:8000/api
description: 本地开发(FrankenPHP / Docker)
tags:
- name: ChatSession
- description: 会话管理与消息
+ description: 会话管理
+ - name: Message
+ description: 消息追加与查询
+ - name: Run
+ description: Agent Run 调度
paths:
/sessions:
post:
@@ -77,9 +81,92 @@ paths:
$ref: '#/components/schemas/PaginationMeta'
"401":
description: 未授权
- /sessions/{session_id}/messages:
+ /sessions/{session_id}:
+ get:
+ tags: [ChatSession]
+ summary: 获取会话详情
+ security:
+ - bearerAuth: []
+ parameters:
+ - in: path
+ name: session_id
+ required: true
+ schema:
+ type: string
+ format: uuid
+ responses:
+ "200":
+ description: 会话详情
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ChatSession'
+ "401":
+ description: 未授权
+ "404":
+ description: 未找到
+ patch:
+ tags: [ChatSession]
+ summary: 更新会话(重命名/状态)
+ security:
+ - bearerAuth: []
+ parameters:
+ - in: path
+ name: session_id
+ required: true
+ schema:
+ type: string
+ format: uuid
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/UpdateSessionRequest'
+ responses:
+ "200":
+ description: 更新成功
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ChatSession'
+ "403":
+ description: 状态门禁禁止
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/Error'
+ "422":
+ description: 校验失败
+ "401":
+ description: 未授权
+ /sessions/{session_id}/archive:
post:
tags: [ChatSession]
+ summary: 归档会话(设为 CLOSED,幂等)
+ security:
+ - bearerAuth: []
+ parameters:
+ - in: path
+ name: session_id
+ required: true
+ schema:
+ type: string
+ format: uuid
+ responses:
+ "200":
+ description: 归档成功(或已归档)
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/ChatSession'
+ "401":
+ description: 未授权
+ "404":
+ description: 未找到
+ /sessions/{session_id}/messages:
+ post:
+ tags: [Message]
summary: 追加消息(含幂等与状态门禁)
security:
- bearerAuth: []
@@ -111,126 +198,8 @@ paths:
$ref: '#/components/schemas/Error'
"401":
description: 未授权
- /sessions/{session_id}/messages/{message_id}:
get:
- tags: [ChatSession]
- summary: 获取单条消息(校验 session_id)
- security:
- - bearerAuth: []
- parameters:
- - in: path
- name: session_id
- required: true
- schema:
- type: string
- format: uuid
- - in: path
- name: message_id
- required: true
- schema:
- type: string
- format: uuid
- responses:
- "200":
- description: 消息详情
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/MessageResource'
- "401":
- description: 未授权
- "404":
- description: 未找到或不属于该会话
- /sessions/{session_id}/archive:
- post:
- tags: [ChatSession]
- summary: 归档会话(设为 CLOSED,幂等)
- security:
- - bearerAuth: []
- parameters:
- - in: path
- name: session_id
- required: true
- schema:
- type: string
- format: uuid
- responses:
- "200":
- description: 归档成功(或已归档)
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ChatSession'
- "401":
- description: 未授权
- "404":
- description: 未找到
- /sessions/{session_id}/sse:
- get:
- tags: [ChatSession]
- summary: SSE 增量推送(backlog + Redis 实时)
- security:
- - bearerAuth: []
- parameters:
- - in: path
- name: session_id
- required: true
- schema:
- type: string
- format: uuid
- - in: query
- name: after_seq
- schema:
- type: integer
- default: 0
- description: backlog 起始 seq(若有 Last-Event-ID 以其为准)
- - in: query
- name: limit
- schema:
- type: integer
- default: 200
- maximum: 500
- responses:
- "200":
- description: text/event-stream SSE 流
- content:
- text/event-stream:
- schema:
- type: string
- example: |
- id: 1
- event: message
- data: {"message_id":"...","seq":1}
- "401":
- description: 未授权
- "404":
- description: 未找到
- /sessions/{session_id}:
- get:
- tags: [ChatSession]
- summary: 获取会话详情
- security:
- - bearerAuth: []
- parameters:
- - in: path
- name: session_id
- required: true
- schema:
- type: string
- format: uuid
- responses:
- "200":
- description: 会话详情
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/ChatSession'
- "401":
- description: 未授权
- "404":
- description: 未找到
- get:
- tags: [ChatSession]
+ tags: [Message]
summary: 按 seq 增量查询消息
security:
- bearerAuth: []
@@ -268,10 +237,80 @@ paths:
$ref: '#/components/schemas/MessageResource'
"401":
description: 未授权
- /sessions/{session_id}:
- patch:
- tags: [ChatSession]
- summary: 更新会话(重命名/状态)
+ /sessions/{session_id}/messages/{message_id}:
+ get:
+ tags: [Message]
+ summary: 获取单条消息(校验 session_id)
+ security:
+ - bearerAuth: []
+ parameters:
+ - in: path
+ name: session_id
+ required: true
+ schema:
+ type: string
+ format: uuid
+ - in: path
+ name: message_id
+ required: true
+ schema:
+ type: string
+ format: uuid
+ responses:
+ "200":
+ description: 消息详情
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/MessageResource'
+ "401":
+ description: 未授权
+ "404":
+ description: 未找到或不属于该会话
+ /sessions/{session_id}/sse:
+ get:
+ tags: [Message]
+ summary: SSE 增量推送(backlog + Redis 实时)
+ security:
+ - bearerAuth: []
+ parameters:
+ - in: path
+ name: session_id
+ required: true
+ schema:
+ type: string
+ format: uuid
+ - in: query
+ name: after_seq
+ schema:
+ type: integer
+ default: 0
+ description: backlog 起始 seq(若有 Last-Event-ID 以其为准)
+ - in: query
+ name: limit
+ schema:
+ type: integer
+ default: 200
+ maximum: 500
+ responses:
+ "200":
+ description: text/event-stream SSE 流
+ content:
+ text/event-stream:
+ schema:
+ type: string
+ example: |
+ id: 1
+ event: message
+ data: {"message_id":"...","seq":1}
+ "401":
+ description: 未授权
+ "404":
+ description: 未找到
+ /sessions/{session_id}/runs:
+ post:
+ tags: [Run]
+ summary: 触发一次 Agent Run(按 trigger_message_id 幂等)
security:
- bearerAuth: []
parameters:
@@ -286,24 +325,22 @@ paths:
content:
application/json:
schema:
- $ref: '#/components/schemas/UpdateSessionRequest'
+ $ref: '#/components/schemas/DispatchRunRequest'
responses:
- "200":
- description: 更新成功
+ "201":
+ description: 已触发(或复用进行中的 RUNNING)
content:
application/json:
schema:
- $ref: '#/components/schemas/ChatSession'
- "403":
- description: 状态门禁禁止
- content:
- application/json:
- schema:
- $ref: '#/components/schemas/Error'
- "422":
- description: 校验失败
+ type: object
+ properties:
+ run_id:
+ type: string
+ format: uuid
"401":
description: 未授权
+ "404":
+ description: session 或 trigger_message 不存在
components:
securitySchemes:
bearerAuth:
@@ -363,6 +400,13 @@ components:
status:
type: string
enum: [OPEN, LOCKED, CLOSED]
+ DispatchRunRequest:
+ type: object
+ required: [trigger_message_id]
+ properties:
+ trigger_message_id:
+ type: string
+ format: uuid
AppendMessageRequest:
type: object
required: [role, type]
diff --git a/phpunit.xml b/phpunit.xml
index d703241..bbfcf81 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -23,8 +23,7 @@
-
-
+
diff --git a/routes/api.php b/routes/api.php
index e5199b8..6913d6f 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -2,6 +2,7 @@
use App\Http\Controllers\AuthController;
use App\Http\Controllers\ChatSessionController;
+use App\Http\Controllers\RunController;
use App\Http\Controllers\UserController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
@@ -10,6 +11,8 @@ Route::get('/health', fn () => ['status' => 'ok']);
Route::post('/login', [AuthController::class, 'login']);
+Route::get('test',[RunController::class,'test']);
+
Route::middleware('auth.jwt')->group(function () {
Route::get('/me', function (Request $request) {
return $request->user();
@@ -30,4 +33,5 @@ Route::middleware('auth.jwt')->group(function () {
Route::patch('/sessions/{session_id}', [ChatSessionController::class, 'update']);
Route::post('/sessions/{session_id}/archive', [ChatSessionController::class, 'archive']);
Route::get('/sessions/{session_id}/sse', [\App\Http\Controllers\ChatSessionSseController::class, 'stream']);
+ Route::post('/sessions/{session_id}/runs', [RunController::class, 'store']);
});
diff --git a/tests/Feature/AgentRunTest.php b/tests/Feature/AgentRunTest.php
new file mode 100644
index 0000000..b2b5950
--- /dev/null
+++ b/tests/Feature/AgentRunTest.php
@@ -0,0 +1,126 @@
+createSession('Run Session');
+ $prompt = $service->appendMessage([
+ 'session_id' => $session->session_id,
+ 'role' => Message::ROLE_USER,
+ 'type' => 'user.prompt',
+ 'content' => 'hello agent',
+ ]);
+
+ $runId = $dispatcher->dispatchForPrompt($session->session_id, $prompt->message_id);
+
+ Queue::assertPushed(AgentRunJob::class, function ($job) use ($session, $runId) {
+ return $job->sessionId === $session->session_id && $job->runId === $runId;
+ });
+
+ // simulate worker execution
+ (new AgentRunJob($session->session_id, $runId))->handle(
+ app(RunLoop::class),
+ app(OutputSink::class)
+ );
+
+ $messages = Message::query()
+ ->where('session_id', $session->session_id)
+ ->orderBy('seq')
+ ->get();
+
+ $this->assertTrue($messages->contains(fn ($m) => $m->type === 'run.status' && ($m->payload['status'] ?? null) === 'RUNNING'));
+ $this->assertTrue($messages->contains(fn ($m) => $m->type === 'agent.message' && ($m->payload['run_id'] ?? null) === $runId));
+ $this->assertTrue($messages->contains(fn ($m) => $m->type === 'run.status' && ($m->payload['status'] ?? null) === 'DONE'));
+ }
+
+ public function test_second_prompt_dispatches_new_run_after_first_completes(): void
+ {
+ Queue::fake();
+ $service = app(ChatService::class);
+ $dispatcher = app(RunDispatcher::class);
+
+ $session = $service->createSession('Sequential Runs');
+ $firstPrompt = $service->appendMessage([
+ 'session_id' => $session->session_id,
+ 'role' => Message::ROLE_USER,
+ 'type' => 'user.prompt',
+ 'content' => 'first run',
+ ]);
+
+ $firstRunId = $dispatcher->dispatchForPrompt($session->session_id, $firstPrompt->message_id);
+
+ (new AgentRunJob($session->session_id, $firstRunId))->handle(
+ app(RunLoop::class),
+ app(OutputSink::class)
+ );
+
+ $secondPrompt = $service->appendMessage([
+ 'session_id' => $session->session_id,
+ 'role' => Message::ROLE_USER,
+ 'type' => 'user.prompt',
+ 'content' => 'second run',
+ ]);
+
+ $secondRunId = $dispatcher->dispatchForPrompt($session->session_id, $secondPrompt->message_id);
+
+ $this->assertNotSame($firstRunId, $secondRunId);
+
+ Queue::assertPushed(AgentRunJob::class, 2);
+ Queue::assertPushed(AgentRunJob::class, function ($job) use ($secondRunId, $session) {
+ return $job->runId === $secondRunId && $job->sessionId === $session->session_id;
+ });
+ }
+
+ public function test_cancel_prevents_agent_reply_and_marks_canceled(): void
+ {
+ Queue::fake();
+ $service = app(ChatService::class);
+ $dispatcher = app(RunDispatcher::class);
+ $loop = app(RunLoop::class);
+
+ $session = $service->createSession('Cancel Session');
+ $prompt = $service->appendMessage([
+ 'session_id' => $session->session_id,
+ 'role' => Message::ROLE_USER,
+ 'type' => 'user.prompt',
+ 'content' => 'please cancel',
+ ]);
+
+ $runId = $dispatcher->dispatchForPrompt($session->session_id, $prompt->message_id);
+
+ $service->appendMessage([
+ 'session_id' => $session->session_id,
+ 'role' => Message::ROLE_USER,
+ 'type' => 'run.cancel.request',
+ 'payload' => ['run_id' => $runId],
+ ]);
+
+ $loop->run($session->session_id, $runId);
+
+ $messages = Message::query()
+ ->where('session_id', $session->session_id)
+ ->get();
+
+ $this->assertFalse($messages->contains(fn ($m) => $m->type === 'agent.message' && ($m->payload['run_id'] ?? null) === $runId));
+ $this->assertTrue($messages->contains(fn ($m) => $m->type === 'run.status' && ($m->payload['status'] ?? null) === 'CANCELED'));
+ }
+}