diff --git a/php/Actions/Sync/PushDispatchHistory.php b/php/Actions/Sync/PushDispatchHistory.php index 2a11665..20a26ce 100644 --- a/php/Actions/Sync/PushDispatchHistory.php +++ b/php/Actions/Sync/PushDispatchHistory.php @@ -1,18 +1,24 @@ > $dispatches * @return array{synced: int} @@ -37,6 +43,7 @@ class PushDispatchHistory ); $synced = 0; + $planUpdates = []; foreach ($dispatches as $dispatch) { $repo = (string) ($dispatch['repo'] ?? ''); @@ -63,6 +70,11 @@ class PushDispatchHistory 'source' => 'sync.push', ]); + $planUpdate = $this->resolvePlanUpdate($dispatch, $status); + if ($planUpdate !== null) { + $planUpdates[$planUpdate['plan_id']] = $planUpdate; + } + $synced++; } @@ -74,6 +86,88 @@ class PushDispatchHistory 'synced_at' => now(), ]); + $dispatchAt = now()->toIso8601String(); + + foreach ($planUpdates as $planUpdate) { + $this->writeSyncState( + $planUpdate['plan_id'], + 'sync.last_dispatch_at', + $dispatchAt, + 'Most recent dispatch sync timestamp.', + ); + $this->writeSyncState( + $planUpdate['plan_id'], + 'sync.last_agent_type', + $planUpdate['agent_type'], + 'Most recent synced agent type.', + ); + $this->writeSyncState( + $planUpdate['plan_id'], + 'sync.last_findings_count', + $planUpdate['findings_count'], + 'Most recent synced findings count.', + ); + $this->writeSyncState( + $planUpdate['plan_id'], + 'sync.last_status', + $planUpdate['status'], + 'Most recent synced dispatch status.', + ); + } + + // TODO: subscriber notification — no notifier interface yet, out of scope for this ticket + return ['synced' => $synced]; } + + /** + * @param array $dispatch + * @return array{plan_id: int, agent_type: string, findings_count: int, status: string}|null + */ + private function resolvePlanUpdate(array $dispatch, string $status): ?array + { + $plan = $this->resolvePlan($dispatch); + if (! $plan instanceof AgentPlan) { + return null; + } + + $findings = $dispatch['findings'] ?? []; + + return [ + 'plan_id' => $plan->id, + 'agent_type' => (string) ($dispatch['agent_type'] ?? ''), + 'findings_count' => is_array($findings) ? count($findings) : 0, + 'status' => $status, + ]; + } + + /** + * @param array $dispatch + */ + private function resolvePlan(array $dispatch): ?AgentPlan + { + $planId = (int) ($dispatch['agent_plan_id'] ?? 0); + if ($planId > 0) { + $plan = AgentPlan::find($planId); + if ($plan instanceof AgentPlan) { + return $plan; + } + } + + $planSlug = trim((string) ($dispatch['plan_slug'] ?? '')); + if ($planSlug === '') { + return null; + } + + return AgentPlan::where('slug', $planSlug)->first(); + } + + private function writeSyncState(int $planId, string $key, mixed $value, string $description): void + { + $state = WorkspaceState::set($planId, $key, $value, WorkspaceState::TYPE_JSON); + $state->forceFill([ + 'category' => self::SYNC_CATEGORY, + 'description' => $description, + ])->save(); + } } diff --git a/php/tests/Feature/Sync/PushDispatchHistoryTest.php b/php/tests/Feature/Sync/PushDispatchHistoryTest.php new file mode 100644 index 0000000..7604f02 --- /dev/null +++ b/php/tests/Feature/Sync/PushDispatchHistoryTest.php @@ -0,0 +1,134 @@ +workspace = Workspace::factory()->create(); + $this->plan = AgentPlan::factory()->create([ + 'workspace_id' => $this->workspace->id, + ]); + } + + protected function tearDown(): void + { + Carbon::setTestNow(); + + parent::tearDown(); + } + + public function test_PushDispatchHistory_handle_Good_updatesWorkspaceStateForAgentPlanId(): void + { + Carbon::setTestNow(Carbon::parse('2026-04-23T12:34:56+00:00')); + + $result = PushDispatchHistory::run($this->workspace->id, 'codex-agent', [[ + 'repo' => 'dappco.re/go/agent', + 'workspace' => 'core-agent', + 'task' => 'Update workflow state after sync', + 'status' => 'completed', + 'agent_type' => 'codex', + 'agent_plan_id' => $this->plan->id, + 'findings' => [ + ['severity' => 'high'], + ['severity' => 'medium'], + ], + ]]); + + $this->assertSame(['synced' => 1], $result); + + $states = WorkspaceState::forPlan($this->plan->id)->get()->keyBy('key'); + + $this->assertCount(4, $states); + $this->assertSyncState($states, 'sync.last_dispatch_at', '2026-04-23T12:34:56+00:00'); + $this->assertSyncState($states, 'sync.last_agent_type', 'codex'); + $this->assertSyncState($states, 'sync.last_findings_count', 2); + $this->assertSyncState($states, 'sync.last_status', 'completed'); + } + + public function test_PushDispatchHistory_handle_Bad_resolvesPlanSlugForWorkspaceState(): void + { + Carbon::setTestNow(Carbon::parse('2026-04-23T13:45:00+00:00')); + + $result = PushDispatchHistory::run($this->workspace->id, 'claude-agent', [[ + 'repo' => 'dappco.re/go/agent', + 'workspace' => 'core-agent', + 'task' => 'Resolve plan from slug', + 'status' => 'blocked', + 'agent_type' => 'claude', + 'plan_slug' => $this->plan->slug, + 'findings' => [ + ['severity' => 'low'], + ], + ]]); + + $this->assertSame(['synced' => 1], $result); + + $states = WorkspaceState::forPlan($this->plan)->get()->keyBy('key'); + + $this->assertCount(4, $states); + $this->assertSyncState($states, 'sync.last_dispatch_at', '2026-04-23T13:45:00+00:00'); + $this->assertSyncState($states, 'sync.last_agent_type', 'claude'); + $this->assertSyncState($states, 'sync.last_findings_count', 1); + $this->assertSyncState($states, 'sync.last_status', 'blocked'); + } + + public function test_PushDispatchHistory_handle_Ugly_skipsWorkspaceStateWhenPlanIsMissing(): void + { + Carbon::setTestNow(Carbon::parse('2026-04-23T14:00:00+00:00')); + + $result = PushDispatchHistory::run($this->workspace->id, 'gemini-agent', [[ + 'repo' => 'dappco.re/go/agent', + 'workspace' => 'core-agent', + 'task' => 'Dispatch without a matching plan', + 'status' => 'failed', + 'agent_type' => 'gemini', + 'plan_slug' => 'missing-plan', + 'findings' => [ + ['severity' => 'high'], + ], + ]]); + + $this->assertSame(['synced' => 1], $result); + $this->assertSame(0, WorkspaceState::count()); + } + + /** + * @param Collection $states + */ + private function assertSyncState(Collection $states, string $key, mixed $expectedValue): void + { + $state = $states->get($key); + + $this->assertInstanceOf(WorkspaceState::class, $state); + $this->assertSame($expectedValue, $state->value); + $this->assertSame(WorkspaceState::TYPE_JSON, $state->type); + $this->assertSame('sync', $state->category); + $this->assertNotNull($state->description); + } +}