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
31 changes: 30 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,33 @@ SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null

AUTH_MODEL=App\User
AUTH_MODEL=App\User

# ---------------------------------------------------------------------------
# Support AI copilot (Phase 1: AI triage + frontend code PRs)
# ---------------------------------------------------------------------------
SUPPORT_AI_ENABLED=false
# One Cursor key for both the headless CLI (triage) and Cloud Agents API (PRs).
CURSOR_API_KEY=

# Triage brain (Cursor headless CLI: `agent -p --output-format json`)
SUPPORT_AI_TRIAGE_ENABLED=true
SUPPORT_AI_CLI_BIN=agent
SUPPORT_AI_CLI_MODEL=gpt-5.4-mini-medium
SUPPORT_AI_CLI_TIMEOUT=120

# Frontend code changes (Cursor Cloud Agents API -> PR into dev)
SUPPORT_AI_CODE_CHANGE_ENABLED=false
SUPPORT_AI_CURSOR_API_BASE=https://api.cursor.com
SUPPORT_AI_CLOUD_MODEL=composer-2.5
SUPPORT_AI_REPO_URL=https://github.com/codeeu/codeweek
SUPPORT_AI_DEV_BRANCH=dev
SUPPORT_AI_AUTO_CREATE_PR=true
SUPPORT_AI_MAX_POLL_MINUTES=30

# Dev -> Live promotion: pr_only | none (never auto-merges to live)
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=
171 changes: 171 additions & 0 deletions app/Console/Commands/Support/AiPollAgentsCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
<?php

namespace App\Console\Commands\Support;

use App\Models\Support\SupportApproval;
use App\Models\Support\SupportCase;
use App\Services\Support\Cursor\CursorAgentService;
use App\Services\Support\Cursor\GitHubPullRequestService;
use App\Services\Support\SupportActionLogger;
use App\Services\Support\SupportApprovalEmailService;
use App\Services\Support\SupportJson;
use Illuminate\Console\Command;

class AiPollAgentsCommand extends Command
{
protected $signature = 'support:ai:poll-agents {--json}';

protected $description = 'Poll in-flight Cursor code-change agents, capture PR links, report results, open dev->live PR.';

public function handle(
CursorAgentService $cursorAgent,
GitHubPullRequestService $github,
SupportApprovalEmailService $approvalEmail,
SupportActionLogger $logger,
): int {
if (!$cursorAgent->enabled()) {
$this->maybeJson(['ok' => true, 'skipped' => 'code_change_disabled']);

return self::SUCCESS;
}

$cases = SupportCase::query()
->where('status', 'action_executed')
->whereNotNull('cursor_agent_id')
->where('case_type', 'code_change')
->limit(25)
->get();

$checked = 0;
$finished = 0;

foreach ($cases as $case) {
$checked++;
$status = $cursorAgent->getAgent((string) $case->cursor_agent_id);
$inner = is_array($status['result'] ?? null) ? $status['result'] : [];

if (!($status['ok'] ?? false)) {
if ($this->timedOut($case)) {
$this->closeOut($case, $approvalEmail, $logger, false, ['errors' => ['agent_poll_timeout']]);
$finished++;
}
continue;
}

$agentStatus = $inner['status'] ?? null;
$prUrl = $inner['pr_url'] ?? $case->cursor_pr_url;

$case->update([
'cursor_agent_status' => $agentStatus,
'cursor_pr_url' => $prUrl,
]);

if (!$cursorAgent->isFinished($agentStatus)) {
if ($this->timedOut($case)) {
$this->closeOut($case, $approvalEmail, $logger, false, ['errors' => ['agent_poll_timeout'], 'result' => $inner]);
$finished++;
}
continue;
}

$succeeded = $cursorAgent->isSuccessful($agentStatus);
$resultInner = $inner;

if ($succeeded && $prUrl && (string) config('support_ai.live_promotion', 'pr_only') === 'pr_only') {
$promotion = $github->openDevToLivePr(
title: 'Promote dev → '.config('support_ai.live_branch', 'master').' (support copilot)',
body: "Automated release PR opened by the support copilot.\n\nIncludes the fix from case #{$case->id} once merged into dev.\nA developer must review and merge to deploy.",
);
if (($promotion['ok'] ?? false)) {
$promoUrl = $promotion['result']['pr_url'] ?? null;
$case->update(['live_promotion_pr_url' => $promoUrl]);
$resultInner['promotion_pr_url'] = $promoUrl;
}
}

$this->closeOut($case, $approvalEmail, $logger, $succeeded, [
'ok' => $succeeded,
'result' => $resultInner,
'errors' => $succeeded ? [] : ['agent_failed'],
]);
$finished++;
}

$this->maybeJson(['ok' => true, 'checked' => $checked, 'finished' => $finished]);

return self::SUCCESS;
}

private function timedOut(SupportCase $case): bool
{
$maxMinutes = (int) config('support_ai.code_change.max_poll_minutes', 30);

return $case->updated_at !== null && $case->updated_at->diffInMinutes(now()) > $maxMinutes;
}

/**
* @param array<string, mixed> $result
*/
private function closeOut(
SupportCase $case,
SupportApprovalEmailService $approvalEmail,
SupportActionLogger $logger,
bool $succeeded,
array $result,
): void {
$case->update(['status' => $succeeded ? 'verified' : 'escalated']);

$approval = SupportApproval::query()
->where('support_case_id', $case->id)
->where('requested_action', 'code_change')
->where('status', 'approved')
->latest('id')
->first();

$envelope = SupportJson::ok('code_change', ['case_id' => $case->id], (array) ($result['result'] ?? []));
if (!$succeeded) {
$envelope = SupportJson::fail('code_change', ['case_id' => $case->id], (array) ($result['errors'] ?? ['agent_failed']));
$envelope['result'] = (array) ($result['result'] ?? []);
}

$logger->log(
case: $case,
actionName: 'code_change_completed',
actionType: 'write',
input: ['case_id' => $case->id, 'agent_id' => $case->cursor_agent_id],
output: $envelope,
succeeded: $succeeded,
executedBy: 'system',
correlationId: $case->correlation_id,
errorMessage: $succeeded ? null : implode(';', (array) ($result['errors'] ?? [])),
);

if ($approval !== null) {
try {
$approvalEmail->sendActionCompletion($case, $approval, 'code_change', $envelope, $succeeded);
} catch (\Throwable $e) {
$logger->log(
case: $case,
actionName: 'support_completion_email',
actionType: 'notify',
input: ['case_id' => $case->id],
output: ['ok' => false, 'error' => $e->getMessage()],
succeeded: false,
executedBy: 'system',
correlationId: $case->correlation_id,
errorMessage: $e->getMessage(),
);
}
}
}

/**
* @param array<string, mixed> $payload
*/
private function maybeJson(array $payload): void
{
if ($this->option('json')) {
$this->line(json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
}
}
}
27 changes: 27 additions & 0 deletions app/Console/Commands/Support/AiPromoteDevToLiveCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

namespace App\Console\Commands\Support;

use App\Services\Support\Cursor\GitHubPullRequestService;
use Illuminate\Console\Command;

class AiPromoteDevToLiveCommand extends Command
{
protected $signature = 'support:ai:promote-dev-to-live {--json}';

protected $description = 'Open (or reuse) a dev -> live release PR for a human to review and merge. Never merges.';

public function handle(GitHubPullRequestService $github): int
{
$live = (string) config('support_ai.live_branch', 'master');

$payload = $github->openDevToLivePr(
title: 'Promote dev → '.$live.' (support copilot)',
body: "Release PR opened by the support copilot.\n\nReview the accumulated changes on dev and merge to deploy to live.\nNothing is merged automatically.",
);

$this->line(json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));

return ($payload['ok'] ?? false) ? self::SUCCESS : self::FAILURE;
}
}
108 changes: 108 additions & 0 deletions app/Console/Commands/Support/AiSetupCheckCommand.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
<?php

namespace App\Console\Commands\Support;

use App\Services\Support\Cursor\CursorAgentService;
use App\Services\Support\Cursor\GitHubPullRequestService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Schema;

class AiSetupCheckCommand extends Command
{
protected $signature = 'support:ai:setup-check';

protected $description = 'Verify Support AI copilot config (Cursor key, CLI binary, models, DB columns, GitHub token).';

public function handle(CursorAgentService $cursorAgent, GitHubPullRequestService $github): int
{
$checks = [];
$warnings = [];

$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['gmail_dry_run'] = (bool) config('support_gmail.dry_run', true);

$apiKey = trim((string) config('support_ai.cursor_api_key', ''));
$checks['cursor_api_key_present'] = $apiKey !== '';
if ($apiKey === '') {
$warnings[] = 'CURSOR_API_KEY is empty — set it in .env then run config:clear.';
}

// CLI binary
$cliBin = (string) config('support_ai.triage.cli_bin', 'agent');
$resolved = $this->resolveBinary($cliBin);
$checks['cli_bin_config'] = $cliBin;
$checks['cli_bin_resolved'] = $resolved;
$checks['cli_bin_executable'] = $resolved !== null && is_executable($resolved);
if (!$checks['cli_bin_executable']) {
$warnings[] = "Cursor CLI not found/executable at '{$cliBin}'. Set SUPPORT_AI_CLI_BIN to the absolute path (e.g. /home/forge/.local/bin/agent).";
}

$checks['triage_model'] = (string) config('support_ai.triage.model');
$checks['cloud_model'] = (string) config('support_ai.code_change.model');

// Cloud API key validity + model availability (cheap GET; no token cost).
if ($apiKey !== '') {
$models = $cursorAgent->listModels();
$checks['cloud_api_reachable'] = (bool) ($models['ok'] ?? false);
if ($models['ok'] ?? false) {
$ids = (array) ($models['result']['models'] ?? []);
$checks['cloud_model_available'] = in_array($checks['cloud_model'], $ids, true);
if (!$checks['cloud_model_available']) {
$warnings[] = "Cloud model '{$checks['cloud_model']}' not in /v1/models — pick a valid id for SUPPORT_AI_CLOUD_MODEL.";
}
} else {
$warnings[] = 'Could not reach Cursor /v1/models with the key: '.implode(';', (array) ($models['errors'] ?? []));
}
}

// DB columns from the Phase 1 migration.
$checks['db_columns'] = [
'cursor_agent_id' => Schema::hasColumn('support_cases', 'cursor_agent_id'),
'cursor_agent_status' => Schema::hasColumn('support_cases', 'cursor_agent_status'),
'cursor_pr_url' => Schema::hasColumn('support_cases', 'cursor_pr_url'),
'live_promotion_pr_url' => Schema::hasColumn('support_cases', 'live_promotion_pr_url'),
];
if (in_array(false, $checks['db_columns'], true)) {
$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);

// Dev -> Live promotion.
$checks['live_promotion'] = (string) config('support_ai.live_promotion', 'pr_only');
$checks['live_branch'] = (string) config('support_ai.live_branch', 'master');
$checks['dev_branch'] = (string) config('support_ai.code_change.dev_branch', 'dev');
$checks['github_token_present'] = trim((string) config('support_ai.github_token', '')) !== '';
$checks['github_promotion_ready'] = $github->enabled() && $checks['live_promotion'] === 'pr_only';
if ($checks['live_promotion'] === 'pr_only' && !$checks['github_token_present']) {
$warnings[] = 'SUPPORT_GITHUB_TOKEN empty — dev→live release PR will be skipped until set.';
}

$ok = $checks['cursor_api_key_present']
&& $checks['cli_bin_executable']
&& !in_array(false, $checks['db_columns'], true)
&& $checks['code_change_in_allowed_actions'];

$this->line(json_encode([
'ok' => $ok,
'tool' => 'support:ai:setup-check',
'checks' => $checks,
'warnings' => $warnings,
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));

return $ok ? self::SUCCESS : self::FAILURE;
}

private function resolveBinary(string $bin): ?string
{
if (str_contains($bin, '/')) {
return is_file($bin) ? $bin : null;
}

$path = trim((string) shell_exec('command -v '.escapeshellarg($bin).' 2>/dev/null'));

return $path !== '' ? $path : null;
}
}
21 changes: 20 additions & 1 deletion 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\Cursor\CursorAgentService;
use App\Services\Support\SupportActionLogger;
use App\Services\Support\SupportApprovalEmailService;
use App\Services\Support\UserProfileUpdateService;
Expand All @@ -27,6 +28,7 @@ public function handle(
UserProfileUpdateService $userProfileUpdate,
SupportApprovalEmailService $approvalEmail,
SupportActionLogger $logger,
CursorAgentService $cursorAgent,
): void
{
$approval = SupportApproval::findOrFail($this->supportApprovalId);
Expand Down Expand Up @@ -81,6 +83,18 @@ public function handle(
} elseif ($action === 'user_profile_update') {
// Re-read names from the case email (approval payload may be from an older parser).
$result = $userProfileUpdate->updateFromCase($case, dryRun: false, viaEmailApproval: true);
} elseif ($action === 'code_change') {
$result = $cursorAgent->launchCodeAgent(
prompt: (string) ($payload['cursor_prompt'] ?? ''),
startingRef: isset($payload['starting_ref']) ? (string) $payload['starting_ref'] : null,
);

$inner = is_array($result['result'] ?? null) ? $result['result'] : [];
$case->update([
'cursor_agent_id' => $inner['agent_id'] ?? null,
'cursor_agent_status' => $inner['status'] ?? null,
'cursor_pr_url' => $inner['pr_url'] ?? null,
]);
} else {
$result = [
'ok' => false,
Expand All @@ -93,7 +107,12 @@ public function handle(
}

$ok = (bool) ($result['ok'] ?? false);
$case->update(['status' => $ok ? 'verified' : 'escalated']);
if ($action === 'code_change') {
// Agent launched asynchronously; poll command captures the PR + closes out.
$case->update(['status' => $ok ? 'action_executed' : 'escalated']);
} else {
$case->update(['status' => $ok ? 'verified' : 'escalated']);
}

$logger->log(
case: $case,
Expand Down
Loading
Loading