Skip to content
Open
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
12 changes: 11 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -75,4 +75,14 @@ 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

# Phase 3 — AI content edits on Nova-managed records (text fields only, dry-run + APPROVE)
SUPPORT_AI_CONTENT_ENABLED=false
SUPPORT_AI_CONTENT_MAX_FIELD_LENGTH=5000
17 changes: 16 additions & 1 deletion app/Console/Commands/Support/AiSetupCheckCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ 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['content_enabled'] = (bool) config('support_ai.content.enabled');
$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 +71,19 @@ 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);
$checks['content_update_in_allowed_actions'] = in_array('content_update', $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['content_enabled'] && !$checks['content_update_in_allowed_actions']) {
$warnings[] = "content_update 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
20 changes: 20 additions & 0 deletions app/Jobs/Support/ExecuteApprovedSupportActionJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

use App\Models\Support\SupportApproval;
use App\Models\Support\SupportCase;
use App\Services\Support\Artisan\ArtisanCommandRunner;
use App\Services\Support\Content\ContentUpdateService;
use App\Services\Support\Cursor\CursorAgentService;
use App\Services\Support\SupportActionLogger;
use App\Services\Support\SupportApprovalEmailService;
Expand All @@ -29,6 +31,8 @@ public function handle(
SupportApprovalEmailService $approvalEmail,
SupportActionLogger $logger,
CursorAgentService $cursorAgent,
ArtisanCommandRunner $artisanRunner,
ContentUpdateService $contentUpdate,
): void
{
$approval = SupportApproval::findOrFail($this->supportApprovalId);
Expand Down Expand Up @@ -95,6 +99,22 @@ 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;
} elseif ($action === 'content_update') {
// Re-read the proposed change from triage and re-validate at execution time.
$triage = (array) ($case->actions()->where('action_name', 'triage')->latest()->first()?->output_json ?? []);
$result = $contentUpdate->execute(
modelKey: (string) ($triage['content_model'] ?? ''),
identifier: isset($triage['content_identifier']) ? (string) $triage['content_identifier'] : null,
changes: (array) ($triage['content_changes'] ?? []),
);
} else {
$result = [
'ok' => false,
Expand Down
38 changes: 38 additions & 0 deletions app/Jobs/Support/ProcessSupportCaseDiagnosticsJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
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\Content\ContentUpdateService;
use App\Services\Support\SupportActionLogger;
use App\Services\Support\SupportJson;
use App\Services\Support\UserProfileUpdateService;
Expand All @@ -28,6 +30,8 @@ public function handle(
SupportActionLogger $logger,
UserRestoreService $userRestore,
UserProfileUpdateService $userProfileUpdate,
ArtisanCommandRunner $artisanRunner,
ContentUpdateService $contentUpdate,
): void {
$case = SupportCase::findOrFail($this->supportCaseId);
$case->update(['status' => 'investigating']);
Expand Down Expand Up @@ -87,6 +91,40 @@ 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,
);
}

if ($case->case_type === 'content_update') {
$triage = (array) ($case->actions()->where('action_name', 'triage')->latest()->first()?->output_json ?? []);
$plan = $contentUpdate->planFromTriage($triage);
$logger->log(
case: $case,
actionName: 'content_update',
actionType: 'write',
input: ['dry_run' => true],
output: $plan,
succeeded: (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
109 changes: 106 additions & 3 deletions app/Services/Support/Agents/CursorCliTriageProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
namespace App\Services\Support\Agents;

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

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

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

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

private function contentEnabled(): bool
{
return (bool) config('support_ai.content.enabled');
}

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

public function triage(SupportCase $case): ?array
{
if (!$this->available()) {
Expand Down Expand Up @@ -88,9 +120,11 @@ 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() : '';
$contentBlock = $this->contentEnabled() ? $this->contentPromptBlock() : '';

return <<<PROMPT
You are the triage brain for the CodeWeek support copilot. Classify ONE support ticket.
Expand All @@ -99,7 +133,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}{$contentBlock}
JSON schema to return:
{
"case_type": "<one allowed value>",
Expand All @@ -114,7 +148,14 @@ 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>",
"content_model": "<for content_update: an allowlisted content model key, else null>",
"content_identifier": "<for content_update: the record id or unique reference; null for single-row pages>",
"content_changes": {},
"content_summary": "<for content_update: one sentence describing the copy change, else null>"
}

Ticket subject: {$case->subject}
Expand All @@ -125,6 +166,45 @@ 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;
}

private function contentPromptBlock(): string
{
$keys = implode(', ', $this->contentRegistry->keys());

return <<<BLOCK

Use "content_update" only when the request is to change editorial text/copy on an existing
page or content record (e.g. fix a typo, reword a paragraph, update a heading). Put the
content model key in "content_model" (one of: {$keys}), the record reference in
"content_identifier" (an id or unique reference; null for single-row pages), and the
field→new-text pairs in "content_changes" (e.g. {"hero_title":"New heading"}).
Plain text only — no HTML, no links/URLs. Use "code_change" instead if it needs a developer.

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 +289,25 @@ private function normalize(array $data): array
'profile_update' => 'user_profile_update',
'account_restore' => 'user_restore',
'code_change' => 'code_change',
'artisan_command' => 'artisan_command',
'content_update' => 'content_update',
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;
}
}

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

return [
'case_type' => $caseType,
'confidence' => $confidence,
Expand All @@ -228,6 +324,13 @@ 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),
'content_model' => $this->stringOrNull($data['content_model'] ?? null),
'content_identifier' => $this->stringOrNull($data['content_identifier'] ?? null),
'content_changes' => $contentChanges,
'content_summary' => $this->stringOrNull($data['content_summary'] ?? null),
'triage_source' => 'cursor_cli',
];
}
Expand Down
Loading