Skip to content

Commit 2af33e8

Browse files
durable-workflow-opsWorkspace HQ Merge Gate
authored andcommitted
Clarify Waterline compatibility claimability
Clarify Waterline compatibility claimability
1 parent 8bd5d3d commit 2af33e8

9 files changed

Lines changed: 251 additions & 7 deletions

File tree

app/Http/Controllers/WorkflowsController.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
use Waterline\Repositories\Workflow\Infrastructure\V2VisibilityFilterContext;
2323
use Waterline\Repositories\Workflow\Interfaces\WorkflowRepositoryInterface;
2424
use Waterline\Support\ActionabilityContract;
25+
use Waterline\Support\CompatibilitySemantics;
2526
use Waterline\Waterline;
2627

2728
class WorkflowsController extends Controller
@@ -625,6 +626,7 @@ private function listItemView(WorkflowRunSummary $summary): array
625626
{
626627
$item = RunListItemView::fromSummary($summary);
627628
$item['history_budget_indicator'] = $this->historyBudgetIndicator($item);
629+
$item = CompatibilitySemantics::annotateListItem($item);
628630
$item['actionability'] = ActionabilityContract::annotateRun($item)['actionability'];
629631

630632
return $item;

app/Http/Resources/V2StoredWorkflowResource.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use Workflow\V2\Contracts\OperatorObservabilityRepository;
77
use Workflow\V2\Models\WorkflowRun;
88
use Waterline\Support\ActionabilityContract;
9+
use Waterline\Support\CompatibilitySemantics;
910
use Waterline\Support\RunDiagnostics;
1011

1112
/**
@@ -27,6 +28,7 @@ public function toArray($request)
2728
);
2829
$detail = $this->withTimelineWindow($detail, $request);
2930
$detail['run_diagnostics'] = app(RunDiagnostics::class)->forRun($this->resource, $detail);
31+
$detail = CompatibilitySemantics::annotateRun($detail);
3032

3133
return ActionabilityContract::annotateRun($detail);
3234
}
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
<?php
2+
3+
namespace Waterline\Support;
4+
5+
use Workflow\V2\Support\WorkerCompatibility;
6+
use Workflow\V2\Support\WorkerCompatibilityFleet;
7+
8+
class CompatibilitySemantics
9+
{
10+
private const ACTIVE_HEARTBEAT_POLICY = 'Only active, unexpired worker heartbeats count as fleet support; stale or missing snapshots are not claimability evidence.';
11+
12+
/**
13+
* @param array<string, mixed> $payload
14+
* @return array<string, mixed>
15+
*/
16+
public static function annotateRun(array $payload): array
17+
{
18+
$payload = self::withCompatibilityFields($payload);
19+
$payload['compatibility_semantics'] = self::forPayload($payload);
20+
21+
if (is_array($payload['tasks'] ?? null)) {
22+
$payload['tasks'] = array_map(
23+
static fn (mixed $task): mixed => is_array($task)
24+
? self::annotateTask($task, $payload)
25+
: $task,
26+
$payload['tasks'],
27+
);
28+
}
29+
30+
return $payload;
31+
}
32+
33+
/**
34+
* @param array<string, mixed> $item
35+
* @return array<string, mixed>
36+
*/
37+
public static function annotateListItem(array $item): array
38+
{
39+
$item = self::withCompatibilityFields($item);
40+
$item['compatibility_semantics'] = self::forPayload($item);
41+
42+
return $item;
43+
}
44+
45+
/**
46+
* @param array<string, mixed> $task
47+
* @param array<string, mixed> $run
48+
* @return array<string, mixed>
49+
*/
50+
private static function annotateTask(array $task, array $run): array
51+
{
52+
$task = self::withCompatibilityFields($task, $run);
53+
$task['compatibility_semantics'] = self::forPayload($task);
54+
55+
return $task;
56+
}
57+
58+
/**
59+
* @param array<string, mixed> $payload
60+
* @param array<string, mixed>|null $fallback
61+
* @return array<string, mixed>
62+
*/
63+
private static function withCompatibilityFields(array $payload, ?array $fallback = null): array
64+
{
65+
$compatibility = self::stringValue($payload['compatibility'] ?? null)
66+
?? self::stringValue($fallback['compatibility'] ?? null);
67+
$connection = self::stringValue($payload['connection'] ?? null)
68+
?? self::stringValue($fallback['connection'] ?? null);
69+
$queue = self::stringValue($payload['queue'] ?? null)
70+
?? self::stringValue($fallback['queue'] ?? null);
71+
72+
$payload['compatibility'] = $compatibility;
73+
$payload['compatibility_supported'] = self::boolOr(
74+
$payload['compatibility_supported'] ?? null,
75+
static fn (): bool => WorkerCompatibility::supports($compatibility),
76+
);
77+
$payload['compatibility_reason'] = self::stringValue($payload['compatibility_reason'] ?? null)
78+
?? WorkerCompatibility::mismatchReason($compatibility);
79+
$payload['compatibility_supported_in_fleet'] = self::boolOr(
80+
$payload['compatibility_supported_in_fleet'] ?? null,
81+
static fn (): bool => WorkerCompatibilityFleet::supports($compatibility, $connection, $queue),
82+
);
83+
$payload['compatibility_fleet_reason'] = self::stringValue($payload['compatibility_fleet_reason'] ?? null)
84+
?? WorkerCompatibilityFleet::mismatchReason($compatibility, $connection, $queue);
85+
86+
if (! array_key_exists('compatibility_namespace', $payload)) {
87+
$payload['compatibility_namespace'] = self::stringValue($fallback['compatibility_namespace'] ?? null)
88+
?? WorkerCompatibilityFleet::scopeNamespace();
89+
}
90+
91+
return $payload;
92+
}
93+
94+
/**
95+
* @param array<string, mixed> $payload
96+
* @return array<string, mixed>
97+
*/
98+
private static function forPayload(array $payload): array
99+
{
100+
$compatibility = self::stringValue($payload['compatibility'] ?? null);
101+
$claimable = self::boolValue($payload['compatibility_supported'] ?? null);
102+
$fleetSupported = self::boolValue($payload['compatibility_supported_in_fleet'] ?? null);
103+
$state = self::state($compatibility, $claimable, $fleetSupported);
104+
105+
return [
106+
'state' => $state,
107+
'required_marker' => $compatibility,
108+
'claimable_by_this_build' => $claimable,
109+
'supported_in_active_fleet' => $fleetSupported,
110+
'compatibility_namespace' => self::stringValue($payload['compatibility_namespace'] ?? null),
111+
'current_build_reason' => self::stringValue($payload['compatibility_reason'] ?? null),
112+
'fleet_reason' => self::stringValue($payload['compatibility_fleet_reason'] ?? null),
113+
'active_heartbeat_policy' => self::ACTIVE_HEARTBEAT_POLICY,
114+
'operator_summary' => self::summary($state),
115+
];
116+
}
117+
118+
private static function state(?string $compatibility, ?bool $claimable, ?bool $fleetSupported): string
119+
{
120+
if ($compatibility === null) {
121+
return 'no_required_marker';
122+
}
123+
124+
if ($claimable === true) {
125+
return 'claimable_by_this_build';
126+
}
127+
128+
if ($fleetSupported === true) {
129+
return 'supported_elsewhere_in_active_fleet';
130+
}
131+
132+
return 'waiting_for_active_compatible_worker';
133+
}
134+
135+
private static function summary(string $state): string
136+
{
137+
return match ($state) {
138+
'claimable_by_this_build' => 'The current build can claim this required compatibility marker.',
139+
'supported_elsewhere_in_active_fleet' => 'Another active worker heartbeat can claim this marker, but this build cannot.',
140+
'waiting_for_active_compatible_worker' => 'No active worker heartbeat currently advertises this required compatibility marker.',
141+
default => 'No compatibility marker is required for this row.',
142+
};
143+
}
144+
145+
private static function boolValue(mixed $value): ?bool
146+
{
147+
return is_bool($value) ? $value : null;
148+
}
149+
150+
private static function boolOr(mixed $value, callable $fallback): bool
151+
{
152+
return is_bool($value) ? $value : (bool) $fallback();
153+
}
154+
155+
private static function stringValue(mixed $value): ?string
156+
{
157+
return is_string($value) && $value !== ''
158+
? $value
159+
: null;
160+
}
161+
}

public/app.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

public/mix-manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"/app.js": "/app.js?id=e4e1f998aff220bc630e6e1f38672549",
2+
"/app.js": "/app.js?id=2d4eaaf8177a464896f59083057d3810",
33
"/app-dark.css": "/app-dark.css?id=64d24ee96944ffdb4b26a9be1658c1e3",
44
"/app.css": "/app.css?id=d525454610dfd3c5a581fc49676f8a37",
55
"/img/favicon.png": "/img/favicon.png?id=7c006241b093796d6abfa3049df93a59",

resources/js/screens/flows/flow-row.vue

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,14 @@
3636
>
3737
Entry Review
3838
</span>
39+
<span
40+
v-if="showCompatibilitySemanticsBadge(flow)"
41+
:class="compatibilitySemanticsBadgeClass(flow)"
42+
class="badge ml-1"
43+
:title="compatibilitySemanticsBadgeTitle(flow)"
44+
>
45+
{{ compatibilitySemanticsBadgeLabel(flow) }}
46+
</span>
3947
<span
4048
v-if="showContractBackfillBadge(flow)"
4149
:class="contractBackfillBadgeClass(flow)"
@@ -220,6 +228,46 @@
220228
return 'This run was recorded with older entry-contract metadata and should be reviewed before relying on command targets.'
221229
},
222230
231+
showCompatibilitySemanticsBadge(flow) {
232+
const semantics = this.compatibilitySemantics(flow)
233+
234+
return semantics
235+
&& semantics.required_marker
236+
&& semantics.state !== 'claimable_by_this_build'
237+
},
238+
239+
compatibilitySemanticsBadgeLabel(flow) {
240+
const semantics = this.compatibilitySemantics(flow)
241+
242+
if (semantics && semantics.state === 'supported_elsewhere_in_active_fleet') {
243+
return 'Fleet Claimable'
244+
}
245+
246+
return 'Compatibility Wait'
247+
},
248+
249+
compatibilitySemanticsBadgeTitle(flow) {
250+
const semantics = this.compatibilitySemantics(flow)
251+
252+
return semantics && semantics.operator_summary
253+
? semantics.operator_summary
254+
: 'Compatibility claimability is not available for this build.'
255+
},
256+
257+
compatibilitySemanticsBadgeClass(flow) {
258+
const semantics = this.compatibilitySemantics(flow)
259+
260+
return semantics && semantics.state === 'supported_elsewhere_in_active_fleet'
261+
? 'badge-warning'
262+
: 'badge-dark'
263+
},
264+
265+
compatibilitySemantics(flow) {
266+
return flow && flow.compatibility_semantics
267+
? flow.compatibility_semantics
268+
: null
269+
},
270+
223271
showContractBackfillBadge(flow) {
224272
return flow && flow.declared_contract_backfill_needed === true
225273
},

resources/js/screens/flows/flow.vue

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -331,11 +331,14 @@
331331
<div class="small text-muted" v-if="hasDetailValue(flow.compatibility_namespace)">
332332
Namespace: {{ flow.compatibility_namespace }}
333333
</div>
334+
<div class="small text-muted" v-if="hasDetailValue(flow.compatibility_semantics && flow.compatibility_semantics.operator_summary)">
335+
{{ flow.compatibility_semantics.operator_summary }}
336+
</div>
334337
<div class="small text-muted" v-if="flow.compatibility_supported === false && hasDetailValue(flow.compatibility_reason)">
335-
This build: {{ flow.compatibility_reason }}
338+
Claimable by this build: {{ flow.compatibility_reason }}
336339
</div>
337340
<div class="small text-muted" v-if="hasDetailValue(flow.compatibility_supported_in_fleet)">
338-
Fleet: {{ compatibilityFleetSummary(flow.compatibility_supported_in_fleet) }}
341+
Supported in active fleet: {{ compatibilityFleetSummary(flow.compatibility_supported_in_fleet) }}
339342
</div>
340343
<div class="small text-muted" v-if="flow.compatibility_supported_in_fleet === false && hasDetailValue(flow.compatibility_fleet_reason)">
341344
{{ flow.compatibility_fleet_reason }}
@@ -828,11 +831,14 @@
828831
<td>{{ task.queue || '-' }}</td>
829832
<td>
830833
<div>{{ task.compatibility || '-' }}</div>
834+
<div class="small text-muted" v-if="hasDetailValue(task.compatibility_semantics && task.compatibility_semantics.operator_summary)">
835+
{{ task.compatibility_semantics.operator_summary }}
836+
</div>
831837
<div class="small text-muted" v-if="task.compatibility_supported === false && hasDetailValue(task.compatibility_reason)">
832-
This build: {{ task.compatibility_reason }}
838+
Claimable by this build: {{ task.compatibility_reason }}
833839
</div>
834840
<div class="small text-muted" v-if="hasDetailValue(task.compatibility_supported_in_fleet)">
835-
Fleet: {{ compatibilityFleetSummary(task.compatibility_supported_in_fleet) }}
841+
Supported in active fleet: {{ compatibilityFleetSummary(task.compatibility_supported_in_fleet) }}
836842
</div>
837843
<div class="small text-muted" v-if="task.compatibility_supported_in_fleet === false && hasDetailValue(task.compatibility_fleet_reason)">
838844
{{ task.compatibility_fleet_reason }}
@@ -2255,7 +2261,7 @@ export default {
22552261
22562262
compatibilityFleetSummary(supported) {
22572263
if (supported === true) {
2258-
return 'supported by an active worker heartbeat'
2264+
return 'yes'
22592265
}
22602266
22612267
if (supported === false) {

tests/Feature/V2CompatibilityDashboardWorkflowTest.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@ public function testShowIncludesRunAndTaskCompatibilityMetadata(): void
7171
->assertJsonPath('compatibility', 'build-a')
7272
->assertJsonPath('compatibility_supported', false)
7373
->assertJsonPath('compatibility_supported_in_fleet', false)
74+
->assertJsonPath('compatibility_semantics.state', 'waiting_for_active_compatible_worker')
75+
->assertJsonPath('compatibility_semantics.claimable_by_this_build', false)
76+
->assertJsonPath('compatibility_semantics.supported_in_active_fleet', false)
77+
->assertJsonPath('compatibility_semantics.operator_summary', 'No active worker heartbeat currently advertises this required compatibility marker.')
7478
->assertJsonPath('compatibility_reason', 'Requires compatibility [build-a]; this worker supports [build-b].')
7579
->assertJsonPath('compatibility_fleet_reason', 'No active worker heartbeat for connection [redis] queue [default] advertises compatibility [build-a].')
7680
->assertJsonPath('compatibility_fleet', [])
@@ -85,6 +89,9 @@ public function testShowIncludesRunAndTaskCompatibilityMetadata(): void
8589
->assertJsonPath('tasks.0.compatibility', 'build-a')
8690
->assertJsonPath('tasks.0.compatibility_supported', false)
8791
->assertJsonPath('tasks.0.compatibility_supported_in_fleet', false)
92+
->assertJsonPath('tasks.0.compatibility_semantics.state', 'waiting_for_active_compatible_worker')
93+
->assertJsonPath('tasks.0.compatibility_semantics.claimable_by_this_build', false)
94+
->assertJsonPath('tasks.0.compatibility_semantics.supported_in_active_fleet', false)
8895
->assertJsonPath('tasks.0.compatibility_reason', 'Requires compatibility [build-a]; this worker supports [build-b].')
8996
->assertJsonPath('tasks.0.compatibility_fleet_reason', 'No active worker heartbeat for connection [redis] queue [default] advertises compatibility [build-a].')
9097
->assertJsonPath('tasks.0.summary', 'Workflow task is waiting for a compatible worker.');
@@ -200,6 +207,10 @@ public function testShowDistinguishesFleetSupportFromLocalClaimability(): void
200207
->assertJsonPath('compatibility_namespace', 'waterline-app')
201208
->assertJsonPath('compatibility_supported', false)
202209
->assertJsonPath('compatibility_supported_in_fleet', true)
210+
->assertJsonPath('compatibility_semantics.state', 'supported_elsewhere_in_active_fleet')
211+
->assertJsonPath('compatibility_semantics.claimable_by_this_build', false)
212+
->assertJsonPath('compatibility_semantics.supported_in_active_fleet', true)
213+
->assertJsonPath('compatibility_semantics.operator_summary', 'Another active worker heartbeat can claim this marker, but this build cannot.')
203214
->assertJsonPath('compatibility_reason', 'Requires compatibility [build-a]; this worker supports [build-b].')
204215
->assertJsonPath('compatibility_fleet_reason', null)
205216
->assertJsonPath('compatibility_fleet.0.worker_id', 'worker-build-a')
@@ -213,6 +224,9 @@ public function testShowDistinguishesFleetSupportFromLocalClaimability(): void
213224
->assertJsonPath('wait_reason', 'Workflow task ready')
214225
->assertJsonPath('tasks.0.compatibility_supported', false)
215226
->assertJsonPath('tasks.0.compatibility_supported_in_fleet', true)
227+
->assertJsonPath('tasks.0.compatibility_semantics.state', 'supported_elsewhere_in_active_fleet')
228+
->assertJsonPath('tasks.0.compatibility_semantics.claimable_by_this_build', false)
229+
->assertJsonPath('tasks.0.compatibility_semantics.supported_in_active_fleet', true)
216230
->assertJsonPath('tasks.0.compatibility_fleet_reason', null)
217231
->assertJsonPath('tasks.0.summary', 'Workflow task ready to resume the selected run.');
218232
}

tests/Feature/V2DashboardWorkflowListTest.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -883,6 +883,12 @@ public function testV2ListResponseItemsMatchTypedListItemContract(): void
883883
$item = $response->json('data.0');
884884
$expectedFields = RunListItemView::fields();
885885
$expectedFields[] = 'history_budget_indicator';
886+
$expectedFields[] = 'compatibility_supported';
887+
$expectedFields[] = 'compatibility_reason';
888+
$expectedFields[] = 'compatibility_supported_in_fleet';
889+
$expectedFields[] = 'compatibility_fleet_reason';
890+
$expectedFields[] = 'compatibility_namespace';
891+
$expectedFields[] = 'compatibility_semantics';
886892
$expectedFields[] = 'actionability';
887893

888894
$this->assertSame(
@@ -904,6 +910,11 @@ public function testV2ListResponseItemsMatchTypedListItemContract(): void
904910
$this->assertIsArray($item['task_problem_badge']);
905911
$this->assertSame('compatibility', $item['declared_entry_mode']);
906912
$this->assertSame('live_definition', $item['declared_contract_source']);
913+
$this->assertSame('no_required_marker', $item['compatibility_semantics']['state']);
914+
$this->assertNull($item['compatibility_semantics']['required_marker']);
915+
$this->assertTrue($item['compatibility_semantics']['claimable_by_this_build']);
916+
$this->assertTrue($item['compatibility_semantics']['supported_in_active_fleet']);
917+
$this->assertSame('No compatibility marker is required for this row.', $item['compatibility_semantics']['operator_summary']);
907918
$this->assertSame(8, $item['history_event_count']);
908919
$this->assertSame('near_limit', $item['history_budget_indicator']['status']);
909920
$this->assertSame('History near limit', $item['history_budget_indicator']['label']);

0 commit comments

Comments
 (0)