feat(sync): update WorkspaceState workflow progress on dispatch push
Extend PushDispatchHistory so /v1/agent/sync writes four sync.* workflow-progress keys into WorkspaceState (last_dispatch_at, last_agent_type, last_findings_count, last_status) in addition to the existing BrainMemory + SyncRecord persistence. Plan resolves via agent_plan_id first, plan_slug fallback. Missing plan is treated as non-fatal — state writes are skipped, BrainMemory still persists. Adds a three-case feature test covering direct id, slug fallback, and the missing-plan safety branch. Closes tasks.lthn.sh/view.php?id=93 Co-authored-by: Codex <noreply@openai.com> Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
e83c3d811d
commit
5e2aecd68a
2 changed files with 228 additions and 0 deletions
|
|
@ -1,18 +1,24 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Actions\Sync;
|
||||
|
||||
use Core\Actions\Action;
|
||||
use Core\Mod\Agentic\Models\AgentPlan;
|
||||
use Core\Mod\Agentic\Models\BrainMemory;
|
||||
use Core\Mod\Agentic\Models\FleetNode;
|
||||
use Core\Mod\Agentic\Models\SyncRecord;
|
||||
use Core\Mod\Agentic\Models\WorkspaceState;
|
||||
|
||||
class PushDispatchHistory
|
||||
{
|
||||
use Action;
|
||||
|
||||
private const SYNC_CATEGORY = 'sync';
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $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<string, mixed> $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<string, mixed> $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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
134
php/tests/Feature/Sync/PushDispatchHistoryTest.php
Normal file
134
php/tests/Feature/Sync/PushDispatchHistoryTest.php
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
<?php
|
||||
|
||||
// SPDX-License-Identifier: EUPL-1.2
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Core\Mod\Agentic\Tests\Feature\Sync;
|
||||
|
||||
use Core\Mod\Agentic\Actions\Sync\PushDispatchHistory;
|
||||
use Core\Mod\Agentic\Models\AgentPlan;
|
||||
use Core\Mod\Agentic\Models\WorkspaceState;
|
||||
use Core\Tenant\Models\Workspace;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Tests\TestCase;
|
||||
|
||||
class PushDispatchHistoryTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private Workspace $workspace;
|
||||
|
||||
private AgentPlan $plan;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Http::fake();
|
||||
|
||||
$this->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<string, WorkspaceState> $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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue