Skip to content

Commit cebf9e0

Browse files
authored
Merge pull request #3579 from codeeu/dev
AI support copilot Phase 2: gated artisan changes over SSH
2 parents c2152a5 + ffc2088 commit cebf9e0

19 files changed

Lines changed: 1448 additions & 4 deletions

.env.example

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,33 @@ SESSION_ENCRYPT=false
4646
SESSION_PATH=/
4747
SESSION_DOMAIN=null
4848

49-
AUTH_MODEL=App\User
49+
AUTH_MODEL=App\User
50+
51+
# ---------------------------------------------------------------------------
52+
# Support AI copilot (Phase 1: AI triage + frontend code PRs)
53+
# ---------------------------------------------------------------------------
54+
SUPPORT_AI_ENABLED=false
55+
# One Cursor key for both the headless CLI (triage) and Cloud Agents API (PRs).
56+
CURSOR_API_KEY=
57+
58+
# Triage brain (Cursor headless CLI: `agent -p --output-format json`)
59+
SUPPORT_AI_TRIAGE_ENABLED=true
60+
SUPPORT_AI_CLI_BIN=agent
61+
SUPPORT_AI_CLI_MODEL=gpt-5.4-mini-medium
62+
SUPPORT_AI_CLI_TIMEOUT=120
63+
64+
# Frontend code changes (Cursor Cloud Agents API -> PR into dev)
65+
SUPPORT_AI_CODE_CHANGE_ENABLED=false
66+
SUPPORT_AI_CURSOR_API_BASE=https://api.cursor.com
67+
SUPPORT_AI_CLOUD_MODEL=composer-2.5
68+
SUPPORT_AI_REPO_URL=https://github.com/codeeu/codeweek
69+
SUPPORT_AI_DEV_BRANCH=dev
70+
SUPPORT_AI_AUTO_CREATE_PR=true
71+
SUPPORT_AI_MAX_POLL_MINUTES=30
72+
73+
# Dev -> Live promotion: pr_only | none (never auto-merges to live)
74+
SUPPORT_AI_LIVE_PROMOTION=pr_only
75+
SUPPORT_AI_LIVE_BRANCH=master
76+
# Token-gated; promotion PR is skipped if absent. owner/repo form.
77+
SUPPORT_GITHUB_REPO=codeeu/codeweek
78+
SUPPORT_GITHUB_TOKEN=
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
<?php
2+
3+
namespace App\Console\Commands\Support;
4+
5+
use App\Models\Support\SupportApproval;
6+
use App\Models\Support\SupportCase;
7+
use App\Services\Support\Cursor\CursorAgentService;
8+
use App\Services\Support\Cursor\GitHubPullRequestService;
9+
use App\Services\Support\SupportActionLogger;
10+
use App\Services\Support\SupportApprovalEmailService;
11+
use App\Services\Support\SupportJson;
12+
use Illuminate\Console\Command;
13+
14+
class AiPollAgentsCommand extends Command
15+
{
16+
protected $signature = 'support:ai:poll-agents {--json}';
17+
18+
protected $description = 'Poll in-flight Cursor code-change agents, capture PR links, report results, open dev->live PR.';
19+
20+
public function handle(
21+
CursorAgentService $cursorAgent,
22+
GitHubPullRequestService $github,
23+
SupportApprovalEmailService $approvalEmail,
24+
SupportActionLogger $logger,
25+
): int {
26+
if (!$cursorAgent->enabled()) {
27+
$this->maybeJson(['ok' => true, 'skipped' => 'code_change_disabled']);
28+
29+
return self::SUCCESS;
30+
}
31+
32+
$cases = SupportCase::query()
33+
->where('status', 'action_executed')
34+
->whereNotNull('cursor_agent_id')
35+
->where('case_type', 'code_change')
36+
->limit(25)
37+
->get();
38+
39+
$checked = 0;
40+
$finished = 0;
41+
42+
foreach ($cases as $case) {
43+
$checked++;
44+
$status = $cursorAgent->getAgent((string) $case->cursor_agent_id);
45+
$inner = is_array($status['result'] ?? null) ? $status['result'] : [];
46+
47+
if (!($status['ok'] ?? false)) {
48+
if ($this->timedOut($case)) {
49+
$this->closeOut($case, $approvalEmail, $logger, false, ['errors' => ['agent_poll_timeout']]);
50+
$finished++;
51+
}
52+
continue;
53+
}
54+
55+
$agentStatus = $inner['status'] ?? null;
56+
$prUrl = $inner['pr_url'] ?? $case->cursor_pr_url;
57+
58+
$case->update([
59+
'cursor_agent_status' => $agentStatus,
60+
'cursor_pr_url' => $prUrl,
61+
]);
62+
63+
if (!$cursorAgent->isFinished($agentStatus)) {
64+
if ($this->timedOut($case)) {
65+
$this->closeOut($case, $approvalEmail, $logger, false, ['errors' => ['agent_poll_timeout'], 'result' => $inner]);
66+
$finished++;
67+
}
68+
continue;
69+
}
70+
71+
$succeeded = $cursorAgent->isSuccessful($agentStatus);
72+
$resultInner = $inner;
73+
74+
if ($succeeded && $prUrl && (string) config('support_ai.live_promotion', 'pr_only') === 'pr_only') {
75+
$promotion = $github->openDevToLivePr(
76+
title: 'Promote dev → '.config('support_ai.live_branch', 'master').' (support copilot)',
77+
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.",
78+
);
79+
if (($promotion['ok'] ?? false)) {
80+
$promoUrl = $promotion['result']['pr_url'] ?? null;
81+
$case->update(['live_promotion_pr_url' => $promoUrl]);
82+
$resultInner['promotion_pr_url'] = $promoUrl;
83+
}
84+
}
85+
86+
$this->closeOut($case, $approvalEmail, $logger, $succeeded, [
87+
'ok' => $succeeded,
88+
'result' => $resultInner,
89+
'errors' => $succeeded ? [] : ['agent_failed'],
90+
]);
91+
$finished++;
92+
}
93+
94+
$this->maybeJson(['ok' => true, 'checked' => $checked, 'finished' => $finished]);
95+
96+
return self::SUCCESS;
97+
}
98+
99+
private function timedOut(SupportCase $case): bool
100+
{
101+
$maxMinutes = (int) config('support_ai.code_change.max_poll_minutes', 30);
102+
103+
return $case->updated_at !== null && $case->updated_at->diffInMinutes(now()) > $maxMinutes;
104+
}
105+
106+
/**
107+
* @param array<string, mixed> $result
108+
*/
109+
private function closeOut(
110+
SupportCase $case,
111+
SupportApprovalEmailService $approvalEmail,
112+
SupportActionLogger $logger,
113+
bool $succeeded,
114+
array $result,
115+
): void {
116+
$case->update(['status' => $succeeded ? 'verified' : 'escalated']);
117+
118+
$approval = SupportApproval::query()
119+
->where('support_case_id', $case->id)
120+
->where('requested_action', 'code_change')
121+
->where('status', 'approved')
122+
->latest('id')
123+
->first();
124+
125+
$envelope = SupportJson::ok('code_change', ['case_id' => $case->id], (array) ($result['result'] ?? []));
126+
if (!$succeeded) {
127+
$envelope = SupportJson::fail('code_change', ['case_id' => $case->id], (array) ($result['errors'] ?? ['agent_failed']));
128+
$envelope['result'] = (array) ($result['result'] ?? []);
129+
}
130+
131+
$logger->log(
132+
case: $case,
133+
actionName: 'code_change_completed',
134+
actionType: 'write',
135+
input: ['case_id' => $case->id, 'agent_id' => $case->cursor_agent_id],
136+
output: $envelope,
137+
succeeded: $succeeded,
138+
executedBy: 'system',
139+
correlationId: $case->correlation_id,
140+
errorMessage: $succeeded ? null : implode(';', (array) ($result['errors'] ?? [])),
141+
);
142+
143+
if ($approval !== null) {
144+
try {
145+
$approvalEmail->sendActionCompletion($case, $approval, 'code_change', $envelope, $succeeded);
146+
} catch (\Throwable $e) {
147+
$logger->log(
148+
case: $case,
149+
actionName: 'support_completion_email',
150+
actionType: 'notify',
151+
input: ['case_id' => $case->id],
152+
output: ['ok' => false, 'error' => $e->getMessage()],
153+
succeeded: false,
154+
executedBy: 'system',
155+
correlationId: $case->correlation_id,
156+
errorMessage: $e->getMessage(),
157+
);
158+
}
159+
}
160+
}
161+
162+
/**
163+
* @param array<string, mixed> $payload
164+
*/
165+
private function maybeJson(array $payload): void
166+
{
167+
if ($this->option('json')) {
168+
$this->line(json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
169+
}
170+
}
171+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
namespace App\Console\Commands\Support;
4+
5+
use App\Services\Support\Cursor\GitHubPullRequestService;
6+
use Illuminate\Console\Command;
7+
8+
class AiPromoteDevToLiveCommand extends Command
9+
{
10+
protected $signature = 'support:ai:promote-dev-to-live {--json}';
11+
12+
protected $description = 'Open (or reuse) a dev -> live release PR for a human to review and merge. Never merges.';
13+
14+
public function handle(GitHubPullRequestService $github): int
15+
{
16+
$live = (string) config('support_ai.live_branch', 'master');
17+
18+
$payload = $github->openDevToLivePr(
19+
title: 'Promote dev → '.$live.' (support copilot)',
20+
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.",
21+
);
22+
23+
$this->line(json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
24+
25+
return ($payload['ok'] ?? false) ? self::SUCCESS : self::FAILURE;
26+
}
27+
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
<?php
2+
3+
namespace App\Console\Commands\Support;
4+
5+
use App\Services\Support\Cursor\CursorAgentService;
6+
use App\Services\Support\Cursor\GitHubPullRequestService;
7+
use Illuminate\Console\Command;
8+
use Illuminate\Support\Facades\Schema;
9+
10+
class AiSetupCheckCommand extends Command
11+
{
12+
protected $signature = 'support:ai:setup-check';
13+
14+
protected $description = 'Verify Support AI copilot config (Cursor key, CLI binary, models, DB columns, GitHub token).';
15+
16+
public function handle(CursorAgentService $cursorAgent, GitHubPullRequestService $github): int
17+
{
18+
$checks = [];
19+
$warnings = [];
20+
21+
$checks['ai_enabled'] = (bool) config('support_ai.enabled');
22+
$checks['triage_enabled'] = (bool) config('support_ai.triage.enabled');
23+
$checks['code_change_enabled'] = (bool) config('support_ai.code_change.enabled');
24+
$checks['gmail_dry_run'] = (bool) config('support_gmail.dry_run', true);
25+
26+
$apiKey = trim((string) config('support_ai.cursor_api_key', ''));
27+
$checks['cursor_api_key_present'] = $apiKey !== '';
28+
if ($apiKey === '') {
29+
$warnings[] = 'CURSOR_API_KEY is empty — set it in .env then run config:clear.';
30+
}
31+
32+
// CLI binary
33+
$cliBin = (string) config('support_ai.triage.cli_bin', 'agent');
34+
$resolved = $this->resolveBinary($cliBin);
35+
$checks['cli_bin_config'] = $cliBin;
36+
$checks['cli_bin_resolved'] = $resolved;
37+
$checks['cli_bin_executable'] = $resolved !== null && is_executable($resolved);
38+
if (!$checks['cli_bin_executable']) {
39+
$warnings[] = "Cursor CLI not found/executable at '{$cliBin}'. Set SUPPORT_AI_CLI_BIN to the absolute path (e.g. /home/forge/.local/bin/agent).";
40+
}
41+
42+
$checks['triage_model'] = (string) config('support_ai.triage.model');
43+
$checks['cloud_model'] = (string) config('support_ai.code_change.model');
44+
45+
// Cloud API key validity + model availability (cheap GET; no token cost).
46+
if ($apiKey !== '') {
47+
$models = $cursorAgent->listModels();
48+
$checks['cloud_api_reachable'] = (bool) ($models['ok'] ?? false);
49+
if ($models['ok'] ?? false) {
50+
$ids = (array) ($models['result']['models'] ?? []);
51+
$checks['cloud_model_available'] = in_array($checks['cloud_model'], $ids, true);
52+
if (!$checks['cloud_model_available']) {
53+
$warnings[] = "Cloud model '{$checks['cloud_model']}' not in /v1/models — pick a valid id for SUPPORT_AI_CLOUD_MODEL.";
54+
}
55+
} else {
56+
$warnings[] = 'Could not reach Cursor /v1/models with the key: '.implode(';', (array) ($models['errors'] ?? []));
57+
}
58+
}
59+
60+
// DB columns from the Phase 1 migration.
61+
$checks['db_columns'] = [
62+
'cursor_agent_id' => Schema::hasColumn('support_cases', 'cursor_agent_id'),
63+
'cursor_agent_status' => Schema::hasColumn('support_cases', 'cursor_agent_status'),
64+
'cursor_pr_url' => Schema::hasColumn('support_cases', 'cursor_pr_url'),
65+
'live_promotion_pr_url' => Schema::hasColumn('support_cases', 'live_promotion_pr_url'),
66+
];
67+
if (in_array(false, $checks['db_columns'], true)) {
68+
$warnings[] = 'Missing support_cases columns — run: php artisan migrate.';
69+
}
70+
71+
$checks['code_change_in_allowed_actions'] = in_array('code_change', (array) config('support_gmail.allowed_write_actions', []), true);
72+
73+
// Dev -> Live promotion.
74+
$checks['live_promotion'] = (string) config('support_ai.live_promotion', 'pr_only');
75+
$checks['live_branch'] = (string) config('support_ai.live_branch', 'master');
76+
$checks['dev_branch'] = (string) config('support_ai.code_change.dev_branch', 'dev');
77+
$checks['github_token_present'] = trim((string) config('support_ai.github_token', '')) !== '';
78+
$checks['github_promotion_ready'] = $github->enabled() && $checks['live_promotion'] === 'pr_only';
79+
if ($checks['live_promotion'] === 'pr_only' && !$checks['github_token_present']) {
80+
$warnings[] = 'SUPPORT_GITHUB_TOKEN empty — dev→live release PR will be skipped until set.';
81+
}
82+
83+
$ok = $checks['cursor_api_key_present']
84+
&& $checks['cli_bin_executable']
85+
&& !in_array(false, $checks['db_columns'], true)
86+
&& $checks['code_change_in_allowed_actions'];
87+
88+
$this->line(json_encode([
89+
'ok' => $ok,
90+
'tool' => 'support:ai:setup-check',
91+
'checks' => $checks,
92+
'warnings' => $warnings,
93+
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
94+
95+
return $ok ? self::SUCCESS : self::FAILURE;
96+
}
97+
98+
private function resolveBinary(string $bin): ?string
99+
{
100+
if (str_contains($bin, '/')) {
101+
return is_file($bin) ? $bin : null;
102+
}
103+
104+
$path = trim((string) shell_exec('command -v '.escapeshellarg($bin).' 2>/dev/null'));
105+
106+
return $path !== '' ? $path : null;
107+
}
108+
}

app/Jobs/Support/ExecuteApprovedSupportActionJob.php

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use App\Models\Support\SupportApproval;
66
use App\Models\Support\SupportCase;
7+
use App\Services\Support\Cursor\CursorAgentService;
78
use App\Services\Support\SupportActionLogger;
89
use App\Services\Support\SupportApprovalEmailService;
910
use App\Services\Support\UserProfileUpdateService;
@@ -27,6 +28,7 @@ public function handle(
2728
UserProfileUpdateService $userProfileUpdate,
2829
SupportApprovalEmailService $approvalEmail,
2930
SupportActionLogger $logger,
31+
CursorAgentService $cursorAgent,
3032
): void
3133
{
3234
$approval = SupportApproval::findOrFail($this->supportApprovalId);
@@ -81,6 +83,18 @@ public function handle(
8183
} elseif ($action === 'user_profile_update') {
8284
// Re-read names from the case email (approval payload may be from an older parser).
8385
$result = $userProfileUpdate->updateFromCase($case, dryRun: false, viaEmailApproval: true);
86+
} elseif ($action === 'code_change') {
87+
$result = $cursorAgent->launchCodeAgent(
88+
prompt: (string) ($payload['cursor_prompt'] ?? ''),
89+
startingRef: isset($payload['starting_ref']) ? (string) $payload['starting_ref'] : null,
90+
);
91+
92+
$inner = is_array($result['result'] ?? null) ? $result['result'] : [];
93+
$case->update([
94+
'cursor_agent_id' => $inner['agent_id'] ?? null,
95+
'cursor_agent_status' => $inner['status'] ?? null,
96+
'cursor_pr_url' => $inner['pr_url'] ?? null,
97+
]);
8498
} else {
8599
$result = [
86100
'ok' => false,
@@ -93,7 +107,12 @@ public function handle(
93107
}
94108

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

98117
$logger->log(
99118
case: $case,

0 commit comments

Comments
 (0)