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:
Snider 2026-04-23 18:06:55 +01:00
parent e83c3d811d
commit 5e2aecd68a
2 changed files with 228 additions and 0 deletions

View file

@ -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();
}
}

View 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);
}
}