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
56 changes: 56 additions & 0 deletions app/Console/Commands/CheckMemoryDrift.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

namespace App\Console\Commands;

use App\Domain\Memory\Services\MemoryDriftDetector;
use App\Domain\Shared\Models\Team;
use App\Domain\Shared\Models\UserNotification;
use Illuminate\Console\Command;

class CheckMemoryDrift extends Command
{
protected $signature = 'memory:check-drift
{--notify : Dispatch UserNotification to team owners when threshold count is exceeded}
{--min-drifted=5 : Minimum drifted facts in 24h to trigger a notification}';

protected $description = 'Compute memory drift across all teams. Optionally notify owners when many facts drift in 24h.';

public function handle(MemoryDriftDetector $detector): int
{
$minDrifted = max(1, (int) $this->option('min-drifted'));
$notify = (bool) $this->option('notify');

$threshold = $detector->threshold();
$this->info("Drift threshold: cosine > {$threshold}.");

$totalChecked = 0;
$totalDrifted = 0;

Team::query()->withoutGlobalScopes()->cursor()->each(function (Team $team) use ($detector, $minDrifted, $notify, &$totalChecked, &$totalDrifted) {
$totalChecked++;
$drifted = $detector->detectForTeam($team->id);
if ($drifted === []) {
return;
}
$totalDrifted += count($drifted);
$this->line(" {$team->name}: ".count($drifted).' drifted facts');

if ($notify && count($drifted) >= $minDrifted) {
UserNotification::create([
'user_id' => $team->owner_id,
'team_id' => $team->id,
'type' => 'memory.drift_warning',
'data' => [
'count' => count($drifted),
'threshold' => $detector->threshold(),
'top_memory_ids' => array_slice(array_column($drifted, 'memory_id'), 0, 5),
],
]);
}
});

$this->info("Scanned {$totalChecked} teams; {$totalDrifted} drifted facts above threshold.");

return self::SUCCESS;
}
}
2 changes: 2 additions & 0 deletions app/Domain/Agent/Models/Agent.php
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ class Agent extends Model
'chat_protocol_slug',
'chat_protocol_config',
'chat_protocol_secret',
'tool_deny_list',
];

protected function casts(): array
Expand Down Expand Up @@ -138,6 +139,7 @@ protected function casts(): array
'system_prompt_template' => 'array',
'scope' => AgentScope::class,
'environment' => AgentEnvironment::class,
'tool_deny_list' => 'array',
];
}

Expand Down
1 change: 1 addition & 0 deletions app/Domain/Memory/Models/Memory.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ class Memory extends Model
'project_id',
'content',
'embedding',
'embedding_at_creation',
'metadata',
'source_type',
'source_id',
Expand Down
86 changes: 86 additions & 0 deletions app/Domain/Memory/Services/MemoryDriftDetector.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<?php

namespace App\Domain\Memory\Services;

use App\Domain\Memory\Models\Memory;
use Illuminate\Support\Facades\DB;

/**
* Detect memory drift — when a fact's current `embedding` has diverged
* from its `embedding_at_creation` by more than a configured cosine
* distance threshold.
*
* Drift is a signal that the meaning of a stored fact has changed since
* the agent first wrote it. High drift on a high-importance fact suggests
* that downstream reasoning may be working from a stale version of truth.
*
* Threshold is a heuristic: 0.30 cosine distance (=70% similarity) is
* outside typical intra-cluster variance for OpenAI ada-002 / text-embedding-3.
* Operators can override via config('memory.drift_threshold').
*/
class MemoryDriftDetector
{
public function threshold(): float
{
$value = config('memory.drift_threshold');

return is_numeric($value) ? (float) $value : 0.30;
}

/**
* Memories above the drift threshold for the given team.
*
* @return array<int, array{memory_id: string, drift_score: float, last_updated_at: string|null}>
*/
public function detectForTeam(string $teamId): array
{
if (DB::connection()->getDriverName() !== 'pgsql') {
return [];
}

$threshold = $this->threshold();

// Cosine distance via pgvector <=> operator. Memories where embedding_at_creation
// is null are skipped (legacy rows; drift not measurable).
$rows = DB::table('memories')
->select('id as memory_id', 'updated_at')
->selectRaw('(embedding <=> embedding_at_creation) AS drift_score')
->where('team_id', $teamId)
->whereNotNull('embedding')
->whereNotNull('embedding_at_creation')
->whereRaw('(embedding <=> embedding_at_creation) > ?', [$threshold])
->orderByDesc('drift_score')
->limit(500)
->get();

return $rows->map(fn ($r) => [
'memory_id' => (string) $r->memory_id,
'drift_score' => (float) $r->drift_score,
'last_updated_at' => $r->updated_at instanceof \DateTimeInterface
? $r->updated_at->format(DATE_ATOM)
: (is_string($r->updated_at) ? $r->updated_at : null),
])->all();
}

/**
* Snapshot the current embedding into embedding_at_creation when missing.
* Called by listeners on Memory creation; a no-op when the column is
* already populated.
*/
public function snapshotIfMissing(Memory $memory): void
{
if ($memory->getAttribute('embedding_at_creation') !== null) {
return;
}
if ($memory->getAttribute('embedding') === null) {
return;
}
if (DB::connection()->getDriverName() !== 'pgsql') {
return;
}

DB::table('memories')
->where('id', $memory->id)
->update(['embedding_at_creation' => DB::raw('embedding')]);
}
}
12 changes: 12 additions & 0 deletions app/Domain/Project/Actions/UpdateProjectAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,18 @@ public function execute(
$updatePayload['allowed_credential_ids'] = $data['allowed_credential_ids'];
}

// Settings: deep-merge into existing settings JSONB so callers can patch
// individual keys (done_gate_enabled, etc.) without wiping unrelated keys.
if (array_key_exists('settings', $data) && is_array($data['settings'])) {
$existing = is_array($project->settings) ? $project->settings : [];
$updatePayload['settings'] = array_replace($existing, $data['settings']);
}

// email_template_id can be null (clear) or string
if (array_key_exists('email_template_id', $data)) {
$updatePayload['email_template_id'] = $data['email_template_id'] ?: null;
}

$project->update($updatePayload);

// Update schedule if provided
Expand Down
2 changes: 2 additions & 0 deletions app/Domain/Shared/Models/Team.php
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,12 @@ protected static function booted(): void
'allowed_models',
'widget_public_key',
'dashboard_config',
'git_webhook_secret',
];

protected $hidden = [
'credential_key',
'git_webhook_secret',
];

protected function casts(): array
Expand Down
12 changes: 12 additions & 0 deletions app/Domain/Tool/Actions/ResolveAgentToolsAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,18 @@ public function execute(Agent $agent, ?Project $project = null, ?string $executi
return ($mode ?? 'auto') !== ToolApprovalMode::Deny->value;
});

// Per-agent tool deny list — operator-managed allowlist of tool IDs
// the agent is explicitly forbidden from using regardless of pivot.
// The cast on Agent.tool_deny_list returns array|null; phpstan can't
// see the cast through the magic property and infers string|null,
// hence the explicit array narrowing here.
/** @var array<int, string>|null $denyListRaw */
$denyListRaw = $agent->getAttribute('tool_deny_list');
$denyList = is_array($denyListRaw) ? $denyListRaw : [];
if ($denyList !== []) {
$agentTools = $agentTools->filter(fn (Tool $tool) => ! in_array($tool->id, $denyList, true));
}

// Apply project-level restrictions if set
if ($project && ! empty($project->allowed_tool_ids)) {
$agentTools = $agentTools->filter(
Expand Down
4 changes: 4 additions & 0 deletions app/Domain/Tool/Services/BuiltIn/BrowserHarnessHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ public function __construct(
*/
public function execute(array $params, string $teamId, ?SandboxedWorkspace $workspace = null): array
{
if (! config('browser.harness_enabled', false)) {
return ['ok' => false, 'error' => 'browser harness is disabled — set BROWSER_HARNESS_ENABLED=true and rebuild the sandbox image with chromium installed'];
}

$task = trim($params['task']);
if ($task === '') {
return ['ok' => false, 'error' => 'task required'];
Expand Down
167 changes: 167 additions & 0 deletions app/Http/Controllers/GitHubWorkflowYamlWebhookController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
<?php

namespace App\Http\Controllers;

use App\Domain\GitRepository\Models\GitRepository;
use App\Domain\Shared\Models\Team;
use App\Jobs\ImportWorkflowFromYamlJob;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

/**
* GitHub webhook handler for the reverse Workflow YAML git sync flow.
*
* Forward sync: Workflow → YAML → committed via GatedGitClient (existing).
* Reverse sync (this): PR-merged with workflows/*.yaml in diff → fetch YAML at HEAD → ImportWorkflowAction.
*
* The webhook payload comes from a GitHub repo configured to send `pull_request` events.
* Signing: HMAC-SHA256 with the secret stored on the GitRepository's webhook config.
*
* On-the-wire identification: the team is identified by a `team_id` in the webhook URL.
*/
class GitHubWorkflowYamlWebhookController extends Controller
{
public function __invoke(Request $request, string $teamId): JsonResponse
{
$team = Team::withoutGlobalScopes()->find($teamId);
if (! $team) {
return response()->json(['error' => 'team not found'], 404);
}

$rawBody = $request->getContent();
$signature = (string) $request->header('X-Hub-Signature-256', '');

$secret = $this->resolveSecret($team);
if ($secret === null) {
return response()->json(['error' => 'webhook secret not configured for this team'], 400);
}

if (! $this->verifySignature($rawBody, $signature, $secret)) {
Log::warning('GitHub workflow yaml webhook: invalid signature', ['team_id' => $teamId]);

return response()->json(['error' => 'invalid signature'], 401);
}

$event = (string) $request->header('X-GitHub-Event', '');
if ($event !== 'pull_request') {
return response()->json(['ok' => true, 'skipped' => 'non-pull_request event']);
}

$payload = $request->input();
if (($payload['action'] ?? null) !== 'closed' || ($payload['pull_request']['merged'] ?? false) !== true) {
return response()->json(['ok' => true, 'skipped' => 'not a merge']);
}

$repoFullName = (string) ($payload['repository']['full_name'] ?? '');
$headSha = (string) ($payload['pull_request']['merge_commit_sha'] ?? $payload['pull_request']['head']['sha'] ?? '');
if ($repoFullName === '' || $headSha === '') {
return response()->json(['error' => 'missing repository or head sha'], 422);
}

$changedFiles = $this->fetchChangedFiles($repoFullName, (int) $payload['pull_request']['number']);
$yamlFiles = array_values(array_filter(
$changedFiles,
fn (string $f) => preg_match('#^workflows/[^/]+\.ya?ml$#', $f) === 1,
));

if ($yamlFiles === []) {
return response()->json(['ok' => true, 'skipped' => 'no workflow yaml files in diff']);
}

$owner = $this->ownerIdFor($team);
$dispatched = [];
foreach ($yamlFiles as $path) {
$yaml = $this->fetchFileContent($repoFullName, $path, $headSha);
if ($yaml === null) {
continue;
}
ImportWorkflowFromYamlJob::dispatch(
teamId: $team->id,
userId: $owner,
yaml: $yaml,
sourceRef: "{$repoFullName}@{$headSha}:{$path}",
);
$dispatched[] = $path;
}

return response()->json([
'ok' => true,
'dispatched' => $dispatched,
]);
}

private function resolveSecret(Team $team): ?string
{
$secret = $team->git_webhook_secret ?? null;
if (is_string($secret) && $secret !== '') {
return $secret;
}

$fallback = config('github.workflow_webhook_secret');
if (is_string($fallback) && $fallback !== '') {
return $fallback;
}

return null;
}

private function verifySignature(string $rawBody, string $headerValue, string $secret): bool
{
if (! str_starts_with($headerValue, 'sha256=')) {
return false;
}
$expected = 'sha256='.hash_hmac('sha256', $rawBody, $secret);

return hash_equals($expected, $headerValue);
}

/**
* @return array<int, string>
*/
private function fetchChangedFiles(string $repoFullName, int $prNumber): array
{
$token = config('github.api_token');
$headers = is_string($token) && $token !== '' ? ['Authorization' => 'Bearer '.$token] : [];

try {
$response = Http::timeout(15)
->withHeaders(array_merge(['Accept' => 'application/vnd.github+json'], $headers))
->get("https://api.github.com/repos/{$repoFullName}/pulls/{$prNumber}/files", ['per_page' => 100]);
} catch (\Throwable $e) {
Log::warning('GitHub PR files fetch failed', ['repo' => $repoFullName, 'pr' => $prNumber, 'error' => $e->getMessage()]);

return [];
}

if (! $response->successful()) {
return [];
}

return array_map(fn ($f) => (string) ($f['filename'] ?? ''), (array) $response->json());
}

private function fetchFileContent(string $repoFullName, string $path, string $ref): ?string
{
$token = config('github.api_token');
$headers = is_string($token) && $token !== '' ? ['Authorization' => 'Bearer '.$token] : [];

try {
$response = Http::timeout(15)
->withHeaders(array_merge(['Accept' => 'application/vnd.github.raw'], $headers))
->get("https://api.github.com/repos/{$repoFullName}/contents/{$path}", ['ref' => $ref]);
} catch (\Throwable $e) {
Log::warning('GitHub file content fetch failed', ['path' => $path, 'error' => $e->getMessage()]);

return null;
}

return $response->successful() ? $response->body() : null;
}

private function ownerIdFor(Team $team): string
{
return (string) $team->owner_id;
}
}
Loading
Loading