diff --git a/CHANGELOG.md b/CHANGELOG.md index ed5542825..5cc3ad333 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 2.11.4 - 2026-10-23 + +- Preserve whole-valued floats and signed zero in CLI history-export JSON, + including nested memo projections. Untouched compact and pretty exports + retain their original canonical checksum and pass integrity verification. + ## 2.11.3 - 2026-10-23 - Refuse dependency injection that would silently discard recorded activity diff --git a/composer.json b/composer.json index 8c8420ea1..9b73cbf5f 100644 --- a/composer.json +++ b/composer.json @@ -87,7 +87,7 @@ "dev-main": "2.0.x-dev" }, "durable-workflow": { - "product-train": "2.11.3", + "product-train": "2.11.4", "laravel-embedded-upgrade-contract": "resources/laravel-embedded-upgrade-contract.json", "laravel-dependency-security-policy": "resources/laravel-dependency-security-policy.json" }, diff --git a/src/Commands/V2HistoryExportCommand.php b/src/Commands/V2HistoryExportCommand.php index 30e1f5064..79a84faa3 100644 --- a/src/Commands/V2HistoryExportCommand.php +++ b/src/Commands/V2HistoryExportCommand.php @@ -85,7 +85,7 @@ public function handle(): int */ private function encode(array $bundle): string { - $flags = JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR; + $flags = JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION | JSON_THROW_ON_ERROR; if ((bool) $this->option('pretty')) { $flags |= JSON_PRETTY_PRINT; diff --git a/tests/Unit/Commands/V2HistoryExportNumericKindsTest.php b/tests/Unit/Commands/V2HistoryExportNumericKindsTest.php new file mode 100644 index 000000000..fb84fb4ac --- /dev/null +++ b/tests/Unit/Commands/V2HistoryExportNumericKindsTest.php @@ -0,0 +1,161 @@ + + */ + public static function exportModes(): iterable + { + yield 'waiting compact' => [false, false]; + yield 'waiting pretty' => [false, true]; + yield 'completed compact' => [true, false]; + yield 'completed pretty' => [true, true]; + } + + #[DataProvider('exportModes')] + public function testSerializedExportsPreserveNumericKinds(bool $complete, bool $pretty): void + { + $this->artisan('migrate', [ + '--force' => true, + ])->assertSuccessful(); + Queue::fake(); + $memo = [ + 'nested' => [ + 'list' => [3.0, -0.0, 1.5, 4], + 'signed_zero' => -0.0, + 'whole' => 2.0, + ], + 'signed_zero' => -0.0, + 'whole' => 1.0, + ]; + $workflow = WorkflowStub::make(NumericExportWorkflow::class); + $workflow->start(new StartOptions(executionTimeoutSeconds: 30, runTimeoutSeconds: 30, memo: $memo)); + $this->runReadyTask(TaskType::Workflow); + + if ($complete) { + $this->runReadyTask(TaskType::Activity); + $this->runReadyTask(TaskType::Workflow); + $this->assertTrue($workflow->refresh()->completed()); + } else { + $this->assertFalse($workflow->refresh()->run()->status->isTerminal()); + } + + $path = sys_get_temp_dir() . '/workflow-numeric-export-' . Str::ulid() . '.json'; + $this->beforeApplicationDestroyed(static function () use ($path): void { + if (is_file($path)) { + unlink($path); + } + }); + $this->artisan('workflow:v2:history-export', [ + 'target' => $workflow->runId(), + '--run' => true, + '--output' => $path, + '--pretty' => $pretty, + ])->assertSuccessful(); + + $raw = (string) file_get_contents($path); + $hash = hash('sha256', $raw); + $bundle = json_decode($raw, true, flags: JSON_THROW_ON_ERROR); + $projection = $bundle['workflow']['memo']; + $this->assertSame($memo, $projection); + $this->assertSame(-INF, fdiv(1.0, $projection['signed_zero'])); + $this->assertSame(-INF, fdiv(1.0, $projection['nested']['signed_zero'])); + $this->assertSame(-INF, fdiv(1.0, $projection['nested']['list'][1])); + $this->assertSame($memo, MemoPayload::decodeEntries($bundle['workflow']['memo_payload'])); + $report = BundleIntegrityVerifier::verifyJson($raw); + $this->assertSame( + BundleIntegrityVerifier::STATUS_OK, + $report['status'], + json_encode($report, JSON_THROW_ON_ERROR), + ); + $this->artisan('workflow:v2:replay-verify', [ + 'bundle' => $path, + '--skip-replay' => true, + '--json' => true, + ])->assertSuccessful(); + $this->assertSame($hash, hash_file('sha256', $path)); + + // Historical lossy exports remain invalid. A reader must not coerce + // their projections or replace their recorded canonical checksum. + $lossy = json_encode($bundle, JSON_THROW_ON_ERROR); + $refusal = BundleIntegrityVerifier::verifyJson($lossy); + $this->assertSame(BundleIntegrityVerifier::STATUS_FAILED, $refusal['status']); + $rules = array_column($refusal['findings'], 'rule'); + $this->assertContains('workflow.memo_projection_mismatch', $rules); + $this->assertContains('integrity.checksum_mismatch', $rules); + } + + protected function getPackageProviders($app): array + { + return [WorkflowServiceProvider::class]; + } + + protected function defineEnvironment($app): void + { + $app['config']->set([ + 'database.default' => 'sqlite', + 'database.connections.sqlite' => [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + 'foreign_key_constraints' => true, + ], + 'cache.default' => 'array', + 'queue.default' => 'redis', + 'workflows.v1.enabled' => false, + 'workflows.serializer' => 'avro', + 'workflows.v2.compatibility.current' => null, + 'workflows.v2.execution_continuation.enabled' => false, + ]); + } + + private function runReadyTask(TaskType $type): void + { + $task = WorkflowTask::query()->where('status', TaskStatus::Ready->value) + ->where('task_type', $type->value) + ->sole(); + $job = $type === TaskType::Workflow ? new RunWorkflowTask($task->id) : new RunActivityTask($task->id); + $this->app->call([$job, 'handle']); + } +} + +final class NumericExportWorkflow extends Workflow +{ + public function handle(): array + { + return \Workflow\V2\activity(NumericExportActivity::class); + } +} + +final class NumericExportActivity extends Activity +{ + public function handle(): array + { + return [ + 1.0, -0.0, [ + 'whole' => 2.0, + ]]; + } +}