Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -75,4 +75,10 @@ SUPPORT_AI_LIVE_PROMOTION=pr_only
SUPPORT_AI_LIVE_BRANCH=master
# Token-gated; promotion PR is skipped if absent. owner/repo form.
SUPPORT_GITHUB_REPO=codeeu/codeweek
SUPPORT_GITHUB_TOKEN=
SUPPORT_GITHUB_TOKEN=

# Phase 2 — AI artisan changes (allowlist-first, dry-run + APPROVE)
SUPPORT_AI_ARTISAN_ENABLED=false
SUPPORT_AI_ARTISAN_ALLOW_RAW=true
SUPPORT_AI_ARTISAN_TIMEOUT=120
SUPPORT_AI_ARTISAN_OUTPUT_LIMIT=8000
12 changes: 11 additions & 1 deletion app/Console/Commands/Support/AiSetupCheckCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ public function handle(CursorAgentService $cursorAgent, GitHubPullRequestService
$checks['ai_enabled'] = (bool) config('support_ai.enabled');
$checks['triage_enabled'] = (bool) config('support_ai.triage.enabled');
$checks['code_change_enabled'] = (bool) config('support_ai.code_change.enabled');
$checks['artisan_enabled'] = (bool) config('support_ai.artisan.enabled');
$checks['artisan_allow_raw'] = (bool) config('support_ai.artisan.allow_raw_fallback', true);
$checks['gmail_dry_run'] = (bool) config('support_gmail.dry_run', true);

$apiKey = trim((string) config('support_ai.cursor_api_key', ''));
Expand Down Expand Up @@ -68,7 +70,15 @@ public function handle(CursorAgentService $cursorAgent, GitHubPullRequestService
$warnings[] = 'Missing support_cases columns — run: php artisan migrate.';
}

$checks['code_change_in_allowed_actions'] = in_array('code_change', (array) config('support_gmail.allowed_write_actions', []), true);
$allowedActions = (array) config('support_gmail.allowed_write_actions', []);
$checks['code_change_in_allowed_actions'] = in_array('code_change', $allowedActions, true);
$checks['artisan_command_in_allowed_actions'] = in_array('artisan_command', $allowedActions, true);
if ($checks['artisan_enabled'] && !$checks['artisan_command_in_allowed_actions']) {
$warnings[] = "artisan_command missing from support_gmail.allowed_write_actions — approvals can't execute it.";
}
if ($checks['artisan_enabled'] && $checks['gmail_dry_run'] === false) {
$warnings[] = 'Artisan actions enabled with SUPPORT_GMAIL_DRY_RUN=false — commands run live after APPROVE.';
}

// Dev -> Live promotion.
$checks['live_promotion'] = (string) config('support_ai.live_promotion', 'pr_only');
Expand Down
10 changes: 10 additions & 0 deletions app/Jobs/Support/ExecuteApprovedSupportActionJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use App\Models\Support\SupportApproval;
use App\Models\Support\SupportCase;
use App\Services\Support\Artisan\ArtisanCommandRunner;
use App\Services\Support\Cursor\CursorAgentService;
use App\Services\Support\SupportActionLogger;
use App\Services\Support\SupportApprovalEmailService;
Expand All @@ -29,6 +30,7 @@ public function handle(
SupportApprovalEmailService $approvalEmail,
SupportActionLogger $logger,
CursorAgentService $cursorAgent,
ArtisanCommandRunner $artisanRunner,
): void
{
$approval = SupportApproval::findOrFail($this->supportApprovalId);
Expand Down Expand Up @@ -95,6 +97,14 @@ public function handle(
'cursor_agent_status' => $inner['status'] ?? null,
'cursor_pr_url' => $inner['pr_url'] ?? null,
]);
} elseif ($action === 'artisan_command') {
// Re-plan from triage (re-validates against the allowlist/deny-list at
// execution time) rather than trusting the stored approval payload.
$triage = (array) ($case->actions()->where('action_name', 'triage')->latest()->first()?->output_json ?? []);
$plan = $artisanRunner->planFromTriage($triage);
$result = ($plan['ok'] ?? false)
? $artisanRunner->execute((array) $plan['result'])
: $plan;
} else {
$result = [
'ok' => false,
Expand Down
21 changes: 21 additions & 0 deletions app/Jobs/Support/ProcessSupportCaseDiagnosticsJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use App\Models\Support\SupportCase;
use App\Models\Support\SupportCaseMessage;
use App\Services\Support\Agents\DiagnosticsAgentService;
use App\Services\Support\Artisan\ArtisanCommandRunner;
use App\Services\Support\SupportActionLogger;
use App\Services\Support\SupportJson;
use App\Services\Support\UserProfileUpdateService;
Expand All @@ -28,6 +29,7 @@ public function handle(
SupportActionLogger $logger,
UserRestoreService $userRestore,
UserProfileUpdateService $userProfileUpdate,
ArtisanCommandRunner $artisanRunner,
): void {
$case = SupportCase::findOrFail($this->supportCaseId);
$case->update(['status' => 'investigating']);
Expand Down Expand Up @@ -87,6 +89,25 @@ public function handle(
);
}

if ($case->case_type === 'artisan_command') {
$triage = (array) ($case->actions()->where('action_name', 'triage')->latest()->first()?->output_json ?? []);
$plan = $artisanRunner->planFromTriage($triage);
$dryRun = ($plan['ok'] ?? false)
? $artisanRunner->dryRun((array) $plan['result'])
: $plan;

$logger->log(
case: $case,
actionName: 'artisan_command',
actionType: 'write',
input: ['dry_run' => true],
output: ['plan' => $plan, 'dry_run' => $dryRun],
succeeded: (bool) ($dryRun['ok'] ?? false) && (bool) ($plan['ok'] ?? false),
executedBy: 'agent',
correlationId: $case->correlation_id,
);
}

// Persist diagnostics snapshot as a message for UI/debugging (stable storage for later external orchestrator).
SupportCaseMessage::create([
'support_case_id' => $case->id,
Expand Down
64 changes: 61 additions & 3 deletions app/Services/Support/Agents/CursorCliTriageProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace App\Services\Support\Agents;

use App\Models\Support\SupportCase;
use App\Services\Support\Artisan\ArtisanActionRegistry;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;

Expand All @@ -26,16 +27,35 @@ class CursorCliTriageProvider implements TriageProvider
'certificate_issue',
'role_issue',
'code_change',
'artisan_command',
'unknown',
];

public function __construct(private readonly ArtisanActionRegistry $registry)
{
}

public function available(): bool
{
return (bool) config('support_ai.enabled')
&& (bool) config('support_ai.triage.enabled')
&& trim((string) config('support_ai.cursor_api_key', '')) !== '';
}

private function artisanEnabled(): bool
{
return (bool) config('support_ai.artisan.enabled');
}

/** @return list<string> case types offered to the model this run. */
private function offeredCaseTypes(): array
{
return array_values(array_filter(
self::CASE_TYPES,
fn (string $type) => $type !== 'artisan_command' || $this->artisanEnabled()
));
}

public function triage(SupportCase $case): ?array
{
if (!$this->available()) {
Expand Down Expand Up @@ -88,9 +108,10 @@ public function triage(SupportCase $case): ?array

private function buildPrompt(SupportCase $case, string $rawText): string
{
$types = implode(', ', self::CASE_TYPES);
$types = implode(', ', $this->offeredCaseTypes());
// Keep the ticket text bounded so the CLI invocation stays small.
$ticket = mb_substr($rawText, 0, 6000);
$artisanBlock = $this->artisanEnabled() ? $this->artisanPromptBlock() : '';

return <<<PROMPT
You are the triage brain for the CodeWeek support copilot. Classify ONE support ticket.
Expand All @@ -99,7 +120,7 @@ private function buildPrompt(SupportCase $case, string $rawText): string
Allowed case_type values: {$types}
Use "code_change" only when the request is about a bug or change in the website/application code
(frontend or template/markup/styling/behaviour) that a developer would fix in the repository.

{$artisanBlock}
JSON schema to return:
{
"case_type": "<one allowed value>",
Expand All @@ -114,7 +135,10 @@ private function buildPrompt(SupportCase $case, string $rawText): string
"profile_lastname": "<requested last name or null>",
"change_summary": "<for code_change: one sentence describing the fix, else null>",
"change_area": "<for code_change: e.g. frontend/blade/css/js, else null>",
"cursor_prompt": "<for code_change: a precise instruction for a coding agent to implement the fix and open a PR, else null>"
"cursor_prompt": "<for code_change: a precise instruction for a coding agent to implement the fix and open a PR, else null>",
"artisan_command_name": "<for artisan_command: an allowlisted command name, else null>",
"artisan_args": {},
"artisan_raw_command": "<for artisan_command: a raw artisan command WITHOUT 'php artisan' prefix if no allowlisted command fits, else null>"
}

Ticket subject: {$case->subject}
Expand All @@ -125,6 +149,29 @@ private function buildPrompt(SupportCase $case, string $rawText): string
PROMPT;
}

private function artisanPromptBlock(): string
{
$lines = [];
foreach ($this->registry->all() as $name => $spec) {
$args = array_keys((array) ($spec['arguments'] ?? []));
$argList = $args === [] ? '' : ' (args: '.implode(', ', $args).')';
$lines[] = "- {$name}{$argList} — {$spec['description']}";
}
$allow = implode("\n", $lines);

return <<<BLOCK

Use "artisan_command" only when the fix requires running a server maintenance command.
Prefer an allowlisted command and put its name in "artisan_command_name" with values in "artisan_args"
(keys = argument/option names, e.g. {"email":"user@example.com","--firstname":"Ada"}).
Allowlisted commands:
{$allow}
If none fits, set "artisan_command_name" to null and put the bare artisan command in "artisan_raw_command"
(no "php artisan" prefix, no shell operators). Destructive commands will be rejected.

BLOCK;
}

/**
* The CLI's --output-format json wraps the agent result; the model's JSON
* may be the whole payload, a "result"/"text" field, or embedded in prose.
Expand Down Expand Up @@ -209,9 +256,17 @@ private function normalize(array $data): array
'profile_update' => 'user_profile_update',
'account_restore' => 'user_restore',
'code_change' => 'code_change',
'artisan_command' => 'artisan_command',
default => null,
};

$artisanArgs = [];
foreach ((array) ($data['artisan_args'] ?? []) as $key => $value) {
if (is_string($key) && (is_string($value) || is_numeric($value) || is_bool($value))) {
$artisanArgs[$key] = is_bool($value) ? $value : (string) $value;
}
}

return [
'case_type' => $caseType,
'confidence' => $confidence,
Expand All @@ -228,6 +283,9 @@ private function normalize(array $data): array
'change_summary' => $this->stringOrNull($data['change_summary'] ?? null),
'change_area' => $this->stringOrNull($data['change_area'] ?? null),
'cursor_prompt' => $this->stringOrNull($data['cursor_prompt'] ?? null),
'artisan_command_name' => $this->stringOrNull($data['artisan_command_name'] ?? null),
'artisan_args' => $artisanArgs,
'artisan_raw_command' => $this->stringOrNull($data['artisan_raw_command'] ?? null),
'triage_source' => 'cursor_cli',
];
}
Expand Down
103 changes: 103 additions & 0 deletions app/Services/Support/Artisan/ArtisanActionRegistry.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<?php

namespace App\Services\Support\Artisan;

/**
* Allowlist of artisan commands the support AI may run on the server, with
* per-argument validation. The AI can only select from these; anything else
* must go through the guarded raw-command fallback (ArtisanCommandRunner).
*/
class ArtisanActionRegistry
{
/**
* @return array<string, array{
* description: string,
* is_write: bool,
* supports_dry_run: bool,
* arguments: array<string, array{type: string, required: bool}>,
* options: array<string, array{type: string}>
* }>
*/
public function all(): array
{
return [
'support:user-audit' => [
'description' => 'Audit a user by email (read-only).',
'is_write' => false,
'supports_dry_run' => false,
'arguments' => ['email' => ['type' => 'email', 'required' => true]],
'options' => ['--json' => ['type' => 'flag']],
],
'support:event-audit' => [
'description' => 'Audit an event by identifier/code (read-only).',
'is_write' => false,
'supports_dry_run' => false,
'arguments' => ['identifier' => ['type' => 'token', 'required' => true]],
'options' => ['--json' => ['type' => 'flag']],
],
'support:user-restore' => [
'description' => 'Restore a soft-deleted user.',
'is_write' => true,
'supports_dry_run' => true,
'arguments' => ['email' => ['type' => 'email', 'required' => true]],
'options' => ['--json' => ['type' => 'flag']],
],
'support:user-update-profile' => [
'description' => 'Update a user first/last name.',
'is_write' => true,
'supports_dry_run' => true,
'arguments' => ['email' => ['type' => 'email', 'required' => true]],
'options' => [
'--firstname' => ['type' => 'name'],
'--lastname' => ['type' => 'name'],
'--json' => ['type' => 'flag'],
],
],
];
}

public function has(string $command): bool
{
return array_key_exists($command, $this->all());
}

/**
* @return array<string, mixed>|null
*/
public function get(string $command): ?array
{
return $this->all()[$command] ?? null;
}

public function isWrite(string $command): bool
{
return (bool) ($this->get($command)['is_write'] ?? false);
}

public function supportsDryRun(string $command): bool
{
return (bool) ($this->get($command)['supports_dry_run'] ?? false);
}

/**
* Validate a value against a named arg/option type.
*/
public function validateValue(string $type, mixed $value): bool
{
if ($type === 'flag') {
return true;
}
if (!is_string($value) || trim($value) === '') {
return false;
}

return match ($type) {
'email' => filter_var(trim($value), FILTER_VALIDATE_EMAIL) !== false,
// Event codes / identifiers: letters, digits, dot, dash, underscore.
'token' => (bool) preg_match('/^[A-Za-z0-9._-]{1,64}$/', trim($value)),
// Names: no shell metacharacters or control chars.
'name' => (bool) preg_match('/^[^\x00-\x1f;|&$`<>\\\\"\']{1,80}$/u', trim($value)),
default => false,
};
}
}
Loading
Loading