Skip to content

Commit 362cc63

Browse files
#362: decode command/update payloads with the pinned codec for display
Command and update payload previews rendered in RunDetailView, HistoryTimeline, and RunUpdateView previously went through the legacy Serializer::unserialize sniffer. The sniffer routes by first byte (base64 prefix → Base64, JSON markers → Json, otherwise → Y) and cannot detect binary Avro blobs, so Avro-coded commands rendered as opaque base64 strings (or worse, as the failed Y-decoded garbage) in Waterline's payload inspector and in the CLI's describe/history output. Thread the persisted payload_codec through CommandPayloadPreview and RunUpdateView::normalizeTypedValue so the stored codec is used for decode when known. When codec is null (rows persisted before payload_codec was populated), behavior is unchanged — both helpers still fall through to the sniffer. Advances acceptance criteria on the release-gating parity suite (#362): "Waterline renders Avro payloads readably" and "CLI renders Avro payloads readably" transitively inherit the fix through the shared observability view helpers they already consume. Decode failures in the preview helpers continue to fall back to the raw blob rather than throwing — these are display helpers, not strict decoders. Strict codec-mismatch detection remains at ingress (PayloadEnvelopeResolver). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 537ef6f commit 362cc63

5 files changed

Lines changed: 192 additions & 18 deletions

File tree

src/V2/Support/CommandPayloadPreview.php

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,14 @@ public static function available(mixed $payload): bool
1414
return is_string($payload) && $payload !== '';
1515
}
1616

17+
/**
18+
* Decode a payload blob for display.
19+
*
20+
* When the persisted `payload_codec` is known, prefer
21+
* {@see self::previewWithCodec()} so binary codecs (Avro) decode
22+
* correctly. The no-codec overload falls back to the legacy blob-sniff
23+
* behavior and is retained for call sites that do not track codec.
24+
*/
1725
public static function preview(mixed $payload): mixed
1826
{
1927
if (! self::available($payload)) {
@@ -26,4 +34,34 @@ public static function preview(mixed $payload): mixed
2634
return $payload;
2735
}
2836
}
37+
38+
/**
39+
* Decode a payload blob using an explicit codec for display.
40+
*
41+
* When $codec is null or empty, falls through to the sniff-based
42+
* {@see self::preview()}. When a codec is named, the blob is decoded
43+
* through {@see Serializer::unserializeWithCodec()} so binary codecs
44+
* like Avro (which sniffing cannot detect) render readably in the
45+
* run-detail view, history timeline, and update view.
46+
*
47+
* Decode failures return the raw blob instead of propagating — this is
48+
* a display helper, not a strict decoder. Mixed-codec errors (Avro
49+
* bytes tagged as JSON, etc.) surface at ingress elsewhere.
50+
*/
51+
public static function previewWithCodec(mixed $payload, ?string $codec): mixed
52+
{
53+
if (! self::available($payload)) {
54+
return null;
55+
}
56+
57+
if ($codec === null || $codec === '') {
58+
return self::preview($payload);
59+
}
60+
61+
try {
62+
return Serializer::unserializeWithCodec($codec, $payload);
63+
} catch (Throwable) {
64+
return $payload;
65+
}
66+
}
2967
}

src/V2/Support/HistoryTimeline.php

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -546,6 +546,11 @@ private static function commandMetadata(
546546
return null;
547547
}
548548

549+
$payloadCodec = self::stringValue($snapshot['payload_codec'] ?? null)
550+
?? (is_string($command?->payload_codec ?? null) ? $command->payload_codec : null);
551+
$payloadBlob = self::stringValue($snapshot['payload'] ?? null)
552+
?? (is_string($command?->payload ?? null) ? $command->payload : null);
553+
549554
return [
550555
'id' => $resolvedCommandId,
551556
'sequence' => self::intValue($snapshot['sequence'] ?? null) ?? $command?->command_sequence,
@@ -562,16 +567,9 @@ private static function commandMetadata(
562567
?? $command?->targetName()
563568
?? self::stringValue($payload['signal_name'] ?? null)
564569
?? self::stringValue($payload['update_name'] ?? null),
565-
'payload_codec' => self::stringValue($snapshot['payload_codec'] ?? null)
566-
?? (is_string($command?->payload_codec ?? null) ? $command->payload_codec : null),
567-
'payload_available' => CommandPayloadPreview::available(
568-
self::stringValue($snapshot['payload'] ?? null)
569-
?? (is_string($command?->payload ?? null) ? $command->payload : null)
570-
),
571-
'payload' => CommandPayloadPreview::preview(
572-
self::stringValue($snapshot['payload'] ?? null)
573-
?? (is_string($command?->payload ?? null) ? $command->payload : null)
574-
),
570+
'payload_codec' => $payloadCodec,
571+
'payload_available' => CommandPayloadPreview::available($payloadBlob),
572+
'payload' => CommandPayloadPreview::previewWithCodec($payloadBlob, $payloadCodec),
575573
'source' => self::stringValue($snapshot['source'] ?? null) ?? $command?->source,
576574
'context' => self::arrayValue(
577575
$snapshot['context'] ?? null

src/V2/Support/RunDetailView.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -288,7 +288,10 @@ public static function forRun(WorkflowRun $run): array
288288
'target_name' => $command->targetName(),
289289
'payload_codec' => $command->payload_codec,
290290
'payload_available' => CommandPayloadPreview::available($command->payload),
291-
'payload' => CommandPayloadPreview::preview($command->payload),
291+
'payload' => CommandPayloadPreview::previewWithCodec(
292+
$command->payload,
293+
is_string($command->payload_codec) ? $command->payload_codec : null,
294+
),
292295
'source' => $command->source,
293296
'context' => $command->publicContext(),
294297
'caller_label' => $command->callerLabel(),

src/V2/Support/RunUpdateView.php

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -169,9 +169,9 @@ private static function rowFromUpdate(WorkflowUpdate $update, array $events, arr
169169
'validation_errors' => self::validationErrors($rejected) ?: $update->normalizedValidationErrors(),
170170
'payload_codec' => $update->payload_codec,
171171
'arguments_available' => is_string($arguments),
172-
'arguments' => self::normalizeTypedValue($arguments),
172+
'arguments' => self::normalizeTypedValue($arguments, $update->payload_codec),
173173
'result_available' => is_string($result) && $failureId === null,
174-
'result' => $failureId === null ? self::normalizeTypedValue($result) : null,
174+
'result' => $failureId === null ? self::normalizeTypedValue($result, $update->payload_codec) : null,
175175
'failure_id' => $failureId,
176176
'failure_message' => self::failureMessage($completed, $failureSnapshot)
177177
?? $update->failure_message
@@ -234,7 +234,7 @@ private static function rowFromCommandFallback(
234234
'arguments_available' => true,
235235
'arguments' => $command->payloadArguments(),
236236
'result_available' => is_string($result) && $failureId === null,
237-
'result' => $failureId === null ? self::normalizeTypedValue($result) : null,
237+
'result' => $failureId === null ? self::normalizeTypedValue($result, $command->payload_codec) : null,
238238
'failure_id' => $failureId,
239239
'failure_message' => self::failureMessage($completed, $failureSnapshot),
240240
'exception_type' => $failureSnapshot['exception_type'] ?? null,
@@ -292,9 +292,17 @@ private static function rowFromHistoryFallback(array $events, array $failureSnap
292292
'validation_errors' => self::validationErrors($rejected),
293293
'payload_codec' => self::stringValue($commandSnapshot['payload_codec'] ?? null),
294294
'arguments_available' => is_string($arguments),
295-
'arguments' => self::normalizeTypedValue($arguments),
295+
'arguments' => self::normalizeTypedValue(
296+
$arguments,
297+
self::stringValue($commandSnapshot['payload_codec'] ?? null)
298+
),
296299
'result_available' => is_string($result) && $failureId === null,
297-
'result' => $failureId === null ? self::normalizeTypedValue($result) : null,
300+
'result' => $failureId === null
301+
? self::normalizeTypedValue(
302+
$result,
303+
self::stringValue($commandSnapshot['payload_codec'] ?? null)
304+
)
305+
: null,
298306
'failure_id' => $failureId,
299307
'failure_message' => self::failureMessage($completed, $failureSnapshot),
300308
'exception_type' => $failureSnapshot['exception_type'] ?? null,
@@ -616,13 +624,21 @@ private static function stringValue(mixed $value): ?string
616624
: null;
617625
}
618626

619-
private static function normalizeTypedValue(mixed $value): mixed
627+
private static function normalizeTypedValue(mixed $value, ?string $codec = null): mixed
620628
{
621629
if (! is_string($value)) {
622630
return $value;
623631
}
624632

625-
return Serializer::unserialize($value);
633+
if ($codec === null || $codec === '') {
634+
return Serializer::unserialize($value);
635+
}
636+
637+
try {
638+
return Serializer::unserializeWithCodec($codec, $value);
639+
} catch (\Throwable) {
640+
return $value;
641+
}
626642
}
627643

628644
private static function timestamp(mixed $value): ?string
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Unit\V2;
6+
7+
use Tests\TestCase;
8+
use Workflow\Serializers\Serializer;
9+
use Workflow\V2\Support\CommandPayloadPreview;
10+
11+
final class CommandPayloadPreviewTest extends TestCase
12+
{
13+
public function testAvailableRejectsNonStringOrEmptyBlobs(): void
14+
{
15+
$this->assertFalse(CommandPayloadPreview::available(null));
16+
$this->assertFalse(CommandPayloadPreview::available(''));
17+
$this->assertFalse(CommandPayloadPreview::available(['not', 'a', 'string']));
18+
$this->assertTrue(CommandPayloadPreview::available('{}'));
19+
}
20+
21+
public function testPreviewWithCodecDecodesJsonBlob(): void
22+
{
23+
$blob = Serializer::serializeWithCodec('json', ['name' => 'Taylor', 'n' => 7]);
24+
25+
$this->assertSame(
26+
['name' => 'Taylor', 'n' => 7],
27+
CommandPayloadPreview::previewWithCodec($blob, 'json'),
28+
);
29+
}
30+
31+
public function testPreviewWithCodecDecodesAvroWrappedBlob(): void
32+
{
33+
if (! class_exists(\Apache\Avro\Schema\AvroSchema::class)) {
34+
$this->markTestSkipped('apache/avro package is not installed in this environment.');
35+
}
36+
37+
$payload = ['name' => 'Taylor', 'count' => 3, 'tags' => ['priority', 'vip']];
38+
$blob = Serializer::serializeWithCodec('avro', $payload);
39+
40+
$this->assertSame(
41+
$payload,
42+
CommandPayloadPreview::previewWithCodec($blob, 'avro'),
43+
);
44+
}
45+
46+
public function testPreviewWithCodecFallsBackToRawBlobOnCodecMismatch(): void
47+
{
48+
if (! class_exists(\Apache\Avro\Schema\AvroSchema::class)) {
49+
$this->markTestSkipped('apache/avro package is not installed in this environment.');
50+
}
51+
52+
// Valid JSON bytes tagged as Avro — decode must fail safely and
53+
// return the raw blob instead of throwing. Strict mixed-codec
54+
// detection happens at ingress (PayloadEnvelopeResolver), not in
55+
// this display helper.
56+
$jsonBlob = Serializer::serializeWithCodec('json', ['hello']);
57+
58+
$this->assertSame(
59+
$jsonBlob,
60+
CommandPayloadPreview::previewWithCodec($jsonBlob, 'avro'),
61+
);
62+
}
63+
64+
public function testPreviewWithCodecAcceptsLegacyCodecFqcnAliases(): void
65+
{
66+
$blob = Serializer::serializeWithCodec('workflow-serializer-y', ['a', 'b']);
67+
68+
$this->assertSame(
69+
['a', 'b'],
70+
CommandPayloadPreview::previewWithCodec($blob, \Workflow\Serializers\Y::class),
71+
);
72+
}
73+
74+
public function testPreviewWithCodecFallsThroughToLegacySniffWhenCodecNull(): void
75+
{
76+
$jsonBlob = Serializer::serializeWithCodec('json', ['legacy' => true]);
77+
78+
$this->assertSame(
79+
['legacy' => true],
80+
CommandPayloadPreview::previewWithCodec($jsonBlob, null),
81+
);
82+
}
83+
84+
public function testPreviewWithCodecReturnsNullForEmptyOrNonStringInput(): void
85+
{
86+
$this->assertNull(CommandPayloadPreview::previewWithCodec(null, 'avro'));
87+
$this->assertNull(CommandPayloadPreview::previewWithCodec('', 'avro'));
88+
$this->assertNull(CommandPayloadPreview::previewWithCodec(['x'], 'json'));
89+
}
90+
91+
public function testPreviewWithCodecRendersAvroTypedRecordWhenSchemaContextIsSet(): void
92+
{
93+
if (! class_exists(\Apache\Avro\Schema\AvroSchema::class)) {
94+
$this->markTestSkipped('apache/avro package is not installed in this environment.');
95+
}
96+
97+
$schemaJson = '{"type":"record","name":"OrderPayload","namespace":"durable_workflow.test","fields":['
98+
. '{"name":"order_id","type":"string"},'
99+
. '{"name":"amount","type":"double"},'
100+
. '{"name":"items_count","type":"int"}]}';
101+
102+
$schema = \Workflow\Serializers\Avro::parseSchema($schemaJson);
103+
104+
\Workflow\Serializers\Avro::withSchema($schema);
105+
$blob = Serializer::serializeWithCodec('avro', [
106+
'order_id' => 'ord-42',
107+
'amount' => 19.95,
108+
'items_count' => 3,
109+
]);
110+
111+
\Workflow\Serializers\Avro::withSchema($schema);
112+
$decoded = CommandPayloadPreview::previewWithCodec($blob, 'avro');
113+
114+
$this->assertIsArray($decoded);
115+
$this->assertSame('ord-42', $decoded['order_id']);
116+
$this->assertSame(19.95, $decoded['amount']);
117+
$this->assertSame(3, $decoded['items_count']);
118+
}
119+
}

0 commit comments

Comments
 (0)