Skip to content

Commit ae6bf6c

Browse files
authored
Merge pull request #913 from durable-workflow/fix/912-preserve-export-floats
Preserve numeric kinds in CLI history exports
2 parents 71213ae + e74fb30 commit ae6bf6c

4 files changed

Lines changed: 169 additions & 2 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## 2.11.4 - 2026-10-23
4+
5+
- Preserve whole-valued floats and signed zero in CLI history-export JSON,
6+
including nested memo projections. Untouched compact and pretty exports
7+
retain their original canonical checksum and pass integrity verification.
8+
39
## 2.11.3 - 2026-10-23
410

511
- Refuse dependency injection that would silently discard recorded activity

‎composer.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@
8787
"dev-main": "2.0.x-dev"
8888
},
8989
"durable-workflow": {
90-
"product-train": "2.11.3",
90+
"product-train": "2.11.4",
9191
"laravel-embedded-upgrade-contract": "resources/laravel-embedded-upgrade-contract.json",
9292
"laravel-dependency-security-policy": "resources/laravel-dependency-security-policy.json"
9393
},

‎src/Commands/V2HistoryExportCommand.php‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ public function handle(): int
8585
*/
8686
private function encode(array $bundle): string
8787
{
88-
$flags = JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR;
88+
$flags = JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION | JSON_THROW_ON_ERROR;
8989

9090
if ((bool) $this->option('pretty')) {
9191
$flags |= JSON_PRETTY_PRINT;
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Unit\Commands;
6+
7+
use Illuminate\Support\Facades\Queue;
8+
use Illuminate\Support\Str;
9+
use Orchestra\Testbench\TestCase;
10+
use PHPUnit\Framework\Attributes\DataProvider;
11+
use Workflow\Providers\WorkflowServiceProvider;
12+
use Workflow\V2\Activity;
13+
use Workflow\V2\Enums\TaskStatus;
14+
use Workflow\V2\Enums\TaskType;
15+
use Workflow\V2\Jobs\RunActivityTask;
16+
use Workflow\V2\Jobs\RunWorkflowTask;
17+
use Workflow\V2\Models\WorkflowTask;
18+
use Workflow\V2\StartOptions;
19+
use Workflow\V2\Support\BundleIntegrityVerifier;
20+
use Workflow\V2\Support\MemoPayload;
21+
use Workflow\V2\Workflow;
22+
use Workflow\V2\WorkflowStub;
23+
24+
final class V2HistoryExportNumericKindsTest extends TestCase
25+
{
26+
/**
27+
* @return iterable<string, array{bool, bool}>
28+
*/
29+
public static function exportModes(): iterable
30+
{
31+
yield 'waiting compact' => [false, false];
32+
yield 'waiting pretty' => [false, true];
33+
yield 'completed compact' => [true, false];
34+
yield 'completed pretty' => [true, true];
35+
}
36+
37+
#[DataProvider('exportModes')]
38+
public function testSerializedExportsPreserveNumericKinds(bool $complete, bool $pretty): void
39+
{
40+
$this->artisan('migrate', [
41+
'--force' => true,
42+
])->assertSuccessful();
43+
Queue::fake();
44+
$memo = [
45+
'nested' => [
46+
'list' => [3.0, -0.0, 1.5, 4],
47+
'signed_zero' => -0.0,
48+
'whole' => 2.0,
49+
],
50+
'signed_zero' => -0.0,
51+
'whole' => 1.0,
52+
];
53+
$workflow = WorkflowStub::make(NumericExportWorkflow::class);
54+
$workflow->start(new StartOptions(executionTimeoutSeconds: 30, runTimeoutSeconds: 30, memo: $memo));
55+
$this->runReadyTask(TaskType::Workflow);
56+
57+
if ($complete) {
58+
$this->runReadyTask(TaskType::Activity);
59+
$this->runReadyTask(TaskType::Workflow);
60+
$this->assertTrue($workflow->refresh()->completed());
61+
} else {
62+
$this->assertFalse($workflow->refresh()->run()->status->isTerminal());
63+
}
64+
65+
$path = sys_get_temp_dir() . '/workflow-numeric-export-' . Str::ulid() . '.json';
66+
$this->beforeApplicationDestroyed(static function () use ($path): void {
67+
if (is_file($path)) {
68+
unlink($path);
69+
}
70+
});
71+
$this->artisan('workflow:v2:history-export', [
72+
'target' => $workflow->runId(),
73+
'--run' => true,
74+
'--output' => $path,
75+
'--pretty' => $pretty,
76+
])->assertSuccessful();
77+
78+
$raw = (string) file_get_contents($path);
79+
$hash = hash('sha256', $raw);
80+
$bundle = json_decode($raw, true, flags: JSON_THROW_ON_ERROR);
81+
$projection = $bundle['workflow']['memo'];
82+
$this->assertSame($memo, $projection);
83+
$this->assertSame(-INF, fdiv(1.0, $projection['signed_zero']));
84+
$this->assertSame(-INF, fdiv(1.0, $projection['nested']['signed_zero']));
85+
$this->assertSame(-INF, fdiv(1.0, $projection['nested']['list'][1]));
86+
$this->assertSame($memo, MemoPayload::decodeEntries($bundle['workflow']['memo_payload']));
87+
$report = BundleIntegrityVerifier::verifyJson($raw);
88+
$this->assertSame(
89+
BundleIntegrityVerifier::STATUS_OK,
90+
$report['status'],
91+
json_encode($report, JSON_THROW_ON_ERROR),
92+
);
93+
$this->artisan('workflow:v2:replay-verify', [
94+
'bundle' => $path,
95+
'--skip-replay' => true,
96+
'--json' => true,
97+
])->assertSuccessful();
98+
$this->assertSame($hash, hash_file('sha256', $path));
99+
100+
// Historical lossy exports remain invalid. A reader must not coerce
101+
// their projections or replace their recorded canonical checksum.
102+
$lossy = json_encode($bundle, JSON_THROW_ON_ERROR);
103+
$refusal = BundleIntegrityVerifier::verifyJson($lossy);
104+
$this->assertSame(BundleIntegrityVerifier::STATUS_FAILED, $refusal['status']);
105+
$rules = array_column($refusal['findings'], 'rule');
106+
$this->assertContains('workflow.memo_projection_mismatch', $rules);
107+
$this->assertContains('integrity.checksum_mismatch', $rules);
108+
}
109+
110+
protected function getPackageProviders($app): array
111+
{
112+
return [WorkflowServiceProvider::class];
113+
}
114+
115+
protected function defineEnvironment($app): void
116+
{
117+
$app['config']->set([
118+
'database.default' => 'sqlite',
119+
'database.connections.sqlite' => [
120+
'driver' => 'sqlite',
121+
'database' => ':memory:',
122+
'prefix' => '',
123+
'foreign_key_constraints' => true,
124+
],
125+
'cache.default' => 'array',
126+
'queue.default' => 'redis',
127+
'workflows.v1.enabled' => false,
128+
'workflows.serializer' => 'avro',
129+
'workflows.v2.compatibility.current' => null,
130+
'workflows.v2.execution_continuation.enabled' => false,
131+
]);
132+
}
133+
134+
private function runReadyTask(TaskType $type): void
135+
{
136+
$task = WorkflowTask::query()->where('status', TaskStatus::Ready->value)
137+
->where('task_type', $type->value)
138+
->sole();
139+
$job = $type === TaskType::Workflow ? new RunWorkflowTask($task->id) : new RunActivityTask($task->id);
140+
$this->app->call([$job, 'handle']);
141+
}
142+
}
143+
144+
final class NumericExportWorkflow extends Workflow
145+
{
146+
public function handle(): array
147+
{
148+
return \Workflow\V2\activity(NumericExportActivity::class);
149+
}
150+
}
151+
152+
final class NumericExportActivity extends Activity
153+
{
154+
public function handle(): array
155+
{
156+
return [
157+
1.0, -0.0, [
158+
'whole' => 2.0,
159+
]];
160+
}
161+
}

0 commit comments

Comments
 (0)