main: 扩展 Agent Run 调度与队列功能

- 增加 Agent Run MVP-0,包括 RunDispatcher 和 AgentRunJob
- 优化队列配置,支持 Redis 队列驱动,添加 Horizon 容器
- 更新 Docker 配置,细化角色分工,新增 Horizon 配置
- 增加测试任务 `TestJob`,扩展队列使用示例
- 更新 OpenAPI 规范,添加 Agent Run 相关接口及示例
- 编写文档,详细描述 Agent Run 流程与 MVP-0 功能
- 优化相关服务与文档,支持队列与异步运行
This commit is contained in:
2025-12-17 02:39:31 +08:00
parent dafa8f6b06
commit c55534ad20
42 changed files with 2596 additions and 217 deletions

View File

@@ -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

2
.gitignore vendored
View File

@@ -26,3 +26,5 @@ Thumbs.db
**/caddy
frankenphp
frankenphp-worker.php
rr
.rr.yaml

228
.junie/guidelines.md Normal file
View File

@@ -0,0 +1,228 @@
<laravel-boost-guidelines>
=== 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()`.
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
- 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.
<code-snippet name="Explicit Return Types and Method Params" lang="php">
protected function isAccessible(User $user, ?string $path = null): bool
{
...
}
</code-snippet>
## 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).
</laravel-boost-guidelines>

11
.junie/mcp/mcp.json Normal file
View File

@@ -0,0 +1,11 @@
{
"mcpServers": {
"laravel-boost": {
"command": "vendor/bin/sail",
"args": [
"artisan",
"boost:mcp"
]
}
}
}

12
.mcp.json Normal file
View File

@@ -0,0 +1,12 @@
{
"mcpServers": {
"laravel-boost": {
"command": "docker compose run app",
"args": [
"vendor/bin/sail",
"artisan",
"boost:mcp"
]
}
}
}

225
AGENTS.md Normal file
View File

@@ -0,0 +1,225 @@
<laravel-boost-guidelines>
=== 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()`.
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
- 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.
<code-snippet name="Explicit Return Types and Method Params" lang="php">
protected function isAccessible(User $user, ?string $path = null): bool
{
...
}
</code-snippet>
## 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).
</laravel-boost-guidelines>

228
CLAUDE.md Normal file
View File

@@ -0,0 +1,228 @@
<laravel-boost-guidelines>
=== 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()`.
- <code-snippet>public function __construct(public GitHub $github) { }</code-snippet>
- 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.
<code-snippet name="Explicit Return Types and Method Params" lang="php">
protected function isAccessible(User $user, ?string $path = null): bool
{
...
}
</code-snippet>
## 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).
</laravel-boost-guidelines>

View File

@@ -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` → AgentRunJobDummyProvider 返回一次性回复)→ `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
# Horizondocker compose up -d horizon需 composer install 安装 laravel/horizonQUEUE_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_keyCLOSED/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 Runuser.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`

View File

@@ -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);
}

View File

@@ -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();
}
});

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Http\Controllers;
use App\Http\Requests\DispatchRunRequest;
use App\Jobs\TestJob;
use App\Services\RunDispatcher;
use Illuminate\Http\JsonResponse;
class RunController extends Controller
{
public function __construct(private readonly RunDispatcher $dispatcher)
{
}
public function store(string $sessionId, DispatchRunRequest $request): JsonResponse
{
$runId = $this->dispatcher->dispatchForPrompt($sessionId, $request->validated()['trigger_message_id']);
return response()->json(['run_id' => $runId], 201);
}
public function test()
{
$job = TestJob::dispatch();
unset($job);
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class DispatchRunRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'trigger_message_id' => ['required', 'uuid'],
];
}
}

35
app/Jobs/AgentRunJob.php Normal file
View File

@@ -0,0 +1,35 @@
<?php
namespace App\Jobs;
use App\Services\OutputSink;
use App\Services\RunLoop;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class AgentRunJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(public string $sessionId, public string $runId)
{
}
public function handle(RunLoop $loop, OutputSink $sink): void
{
try {
$loop->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;
}
}
}

23
app/Jobs/TestJob.php Normal file
View File

@@ -0,0 +1,23 @@
<?php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class TestJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct()
{
}
public function handle(): void
{
logger('TestJob');
}
}

View File

@@ -11,7 +11,9 @@ class AppServiceProvider extends ServiceProvider
*/
public function register(): void
{
//
$this->app->bind(\App\Services\Agent\AgentProviderInterface::class, function () {
return new \App\Services\Agent\DummyAgentProvider();
});
}
/**

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
use Laravel\Horizon\Horizon;
class HorizonServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
if (! class_exists(Horizon::class)) {
return;
}
Horizon::auth(function ($request) {
return app()->environment('local');
});
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Providers;
use Illuminate\Support\Facades\Gate;
use Laravel\Telescope\IncomingEntry;
use Laravel\Telescope\Telescope;
use Laravel\Telescope\TelescopeApplicationServiceProvider;
class TelescopeServiceProvider extends TelescopeApplicationServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
Telescope::night();
$this->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, [
//
]);
});
}
}

View File

@@ -0,0 +1,12 @@
<?php
namespace App\Services\Agent;
interface AgentProviderInterface
{
/**
* @param array<string, mixed> $context
* @param array<string, mixed> $options
*/
public function generate(array $context, array $options = []): string;
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Services\Agent;
class DummyAgentProvider implements AgentProviderInterface
{
/**
* @param array<string, mixed> $context
* @param array<string, mixed> $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);
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Services\Agent;
use Illuminate\Support\Facades\Http;
class HttpAgentProvider implements AgentProviderInterface
{
protected string $endpoint;
public function __construct(?string $endpoint = null)
{
$this->endpoint = $endpoint ?? config('services.agent_provider.endpoint', '');
}
/**
* @param array<string, mixed> $context
* @param array<string, mixed> $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'] ?? '');
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Services;
use App\Models\Message;
class CancelChecker
{
public function isCanceled(string $sessionId, string $runId): bool
{
return Message::query()
->where('session_id', $sessionId)
->where('type', 'run.cancel.request')
->whereIn('role', [Message::ROLE_USER, Message::ROLE_SYSTEM])
->where('payload->run_id', $runId)
->exists();
}
}

View File

@@ -0,0 +1,49 @@
<?php
namespace App\Services;
use App\Models\Message;
use Illuminate\Support\Collection;
class ContextBuilder
{
public function __construct(private readonly int $limit = 20)
{
}
/**
* @return array<string, mixed>
*/
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();
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Services;
use App\Models\Message;
class OutputSink
{
public function __construct(private readonly ChatService $chatService)
{
}
/**
* @param array<string, mixed> $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<string, mixed> $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<string, mixed> $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,
]),
]);
}
}

View File

@@ -0,0 +1,60 @@
<?php
namespace App\Services;
use App\Jobs\AgentRunJob;
use App\Models\Message;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Str;
class RunDispatcher
{
public function __construct(
private readonly ChatService $chatService,
private readonly OutputSink $outputSink,
) {
}
/**
* @throws ModelNotFoundException
*/
public function dispatchForPrompt(string $sessionId, string $triggerMessageId): string
{
$triggerMessage = $this->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;
}
}

52
app/Services/RunLoop.php Normal file
View File

@@ -0,0 +1,52 @@
<?php
namespace App\Services;
use App\Services\Agent\AgentProviderInterface;
use App\Services\Agent\DummyAgentProvider;
class RunLoop
{
public function __construct(
private readonly ContextBuilder $contextBuilder,
private readonly AgentProviderInterface $provider,
private readonly OutputSink $outputSink,
private readonly CancelChecker $cancelChecker,
) {
}
public function run(string $sessionId, string $runId): void
{
if ($this->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",
]);
}
}

14
boost.json Normal file
View File

@@ -0,0 +1,14 @@
{
"agents": [
"claude_code",
"codex",
"phpstorm"
],
"editors": [
"claude_code",
"codex",
"phpstorm"
],
"guidelines": [],
"sail": true
}

View File

@@ -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([

View File

@@ -2,4 +2,6 @@
return [
App\Providers\AppServiceProvider::class,
App\Providers\HorizonServiceProvider::class,
App\Providers\TelescopeServiceProvider::class,
];

View File

@@ -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",

402
composer.lock generated
View File

@@ -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"

186
config/horizon.php Normal file
View File

@@ -0,0 +1,186 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Horizon Domain
|--------------------------------------------------------------------------
|
| This is the subdomain where Horizon will be accessible from. If this
| setting is null, Horizon will reside under the same domain as the
| application. Otherwise, this value will serve as the subdomain.
|
*/
'domain' => 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,
],
],
],
];

View File

@@ -32,7 +32,7 @@ return [
'connections' => [
'sync' => [
'driver' => 'sync',
'driver' => 'redis',
],
'database' => [

212
config/telescope.php Normal file
View File

@@ -0,0 +1,212 @@
<?php
use Laravel\Telescope\Http\Middleware\Authorize;
use Laravel\Telescope\Watchers;
return [
/*
|--------------------------------------------------------------------------
| Telescope Master Switch
|--------------------------------------------------------------------------
|
| This option may be used to disable all Telescope watchers regardless
| of their individual configuration, which simply provides a single
| and convenient way to enable or disable Telescope data storage.
|
*/
'enabled' => 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),
],
];

View File

@@ -0,0 +1,70 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Get the migration connection name.
*/
public function getConnection(): ?string
{
return config('telescope.storage.database.connection');
}
/**
* Run the migrations.
*/
public function up(): void
{
$schema = Schema::connection($this->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');
}
};

View File

@@ -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:

View File

@@ -1,4 +1,4 @@
FROM dunglas/frankenphp:1-php8.3
FROM dunglas/frankenphp:1-php8.2
WORKDIR /app

View File

@@ -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

View File

@@ -1,4 +1,4 @@
# ChatSession & Message APIMVP-1
# ChatSession & Message APIMVP-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-14MVP-1.1 增加会话列表、会话更新(重命名/状态变更),列表附带最后一条消息摘要。
- 2025-02-15Agent 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) | 会话名称 |
- 响应 201JSON
| 字段 | 类型 | 说明 |
| --- | --- | --- |
| 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-0RunDispatcher + AgentRunJob
### 流程概述
1. 用户追加 `role=USER && type=user.prompt`Controller 自动调用 `RunDispatcher->dispatchForPrompt`
2. 并发保护:同会话只允许一个 RUNNING同一个 `trigger_message_id` 幂等复用已有 `run_id`
3. 立即写入 `run.status`SYSTEM/run.statuspayload `{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.messageseq 升序提供给 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'"}'
```

View File

@@ -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]

View File

@@ -23,8 +23,7 @@
<env name="BCRYPT_ROUNDS" value="4"/>
<env name="BROADCAST_CONNECTION" value="null"/>
<env name="CACHE_STORE" value="array"/>
<env name="DB_CONNECTION" value="sqlite"/>
<env name="DB_DATABASE" value=":memory:"/>
<env name="DB_DATABASE" value="testing"/>
<env name="MAIL_MAILER" value="array"/>
<env name="QUEUE_CONNECTION" value="sync"/>
<env name="SESSION_DRIVER" value="array"/>

View File

@@ -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']);
});

View File

@@ -0,0 +1,126 @@
<?php
namespace Tests\Feature;
use App\Jobs\AgentRunJob;
use App\Models\Message;
use App\Services\ChatService;
use App\Services\RunDispatcher;
use App\Services\RunLoop;
use App\Services\OutputSink;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Queue;
use Tests\TestCase;
class AgentRunTest extends TestCase
{
use RefreshDatabase;
public function test_dispatch_and_run_creates_agent_reply_and_statuses(): void
{
Queue::fake();
$service = app(ChatService::class);
$dispatcher = app(RunDispatcher::class);
$session = $service->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'));
}
}