diff --git a/.gitattributes b/.gitattributes index 18e14aad..c8693b65 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,5 @@ /tests export-ignore +/.github export-ignore +/docker export-ignore +/scripts/ci export-ignore +/.dockerignore export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac1ae4a4..082f6299 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -727,6 +727,8 @@ jobs: exit 1 fi grep -F 'Installed Composer product-train identifies' "$RUNNER_TEMP/stale-release-metadata.log" + - name: Reject Git metadata and CI tooling in real Composer archives + run: python3 -I -S -B tests/ci/test_composer_archive.py example-lock-audit: name: Laravel example lock audit @@ -910,7 +912,7 @@ jobs: # and must stay immediately before the one registered build command. - name: Bind the Server build's container Composer trust id: container-trust - uses: durable-workflow/.github/.github/actions/verify-composer-container-trust@699d09d602fc0353160bdecaa2b23f07ef4c7c21 # clean-trust + uses: durable-workflow/.github/.github/actions/verify-composer-container-trust@06efa32de898cff5766ef9f3a9db6a368fced981 # composer-container-trust with: request: sdk-php-cooperative-server source-directory: server @@ -985,6 +987,16 @@ jobs: DW_SERVER_SOURCE_COMMIT: ${{ steps.container-trust.outputs.source-commit }} DW_SERVER_DOCKERFILE_SHA256: ${{ steps.container-trust.outputs.dockerfile-sha256 }} run: docker compose down -v --rmi local + - name: Remove only the verified staged public CA + if: ${{ always() && steps.container-trust.outcome == 'success' }} + working-directory: sdk-php + env: + VERIFIER_CONTEXT: ${{ steps.container-trust.outputs.context-directory }} + STAGED_CA: ${{ steps.container-trust.outputs.ca-file }} + CA_SHA256: ${{ steps.container-trust.outputs.ca-sha256 }} + run: >- + python3 -I -S -B "$VERIFIER_CONTEXT/../../../scripts/composer_container_trust.py" cleanup-ca + --ca-file "$STAGED_CA" --ca-sha256 "$CA_SHA256" --compose-directory . target-branch-qualification: name: Target branch qualification diff --git a/.github/workflows/published-worker-metrics.yml b/.github/workflows/published-worker-metrics.yml index 086871d1..cf81f88b 100644 --- a/.github/workflows/published-worker-metrics.yml +++ b/.github/workflows/published-worker-metrics.yml @@ -2,16 +2,24 @@ name: Published worker metrics on: pull_request: - paths: ['.github/workflows/published-worker-metrics.yml', 'tests/smoke/worker-metrics.php', 'tests/smoke/update-outcome.php'] + paths: ['.github/workflows/published-worker-metrics.yml', 'tests/smoke/worker-metrics.php', 'tests/smoke/update-outcome.php', 'tests/smoke/workflow-completion-limits.php'] push: branches: [main] - paths: ['.github/workflows/published-worker-metrics.yml', 'tests/smoke/worker-metrics.php', 'tests/smoke/update-outcome.php'] + paths: ['.github/workflows/published-worker-metrics.yml', 'tests/smoke/worker-metrics.php', 'tests/smoke/update-outcome.php', 'tests/smoke/workflow-completion-limits.php'] workflow_dispatch: inputs: sdk_version: description: Exact published PHP SDK version type: string required: false + completion_limits: + description: Qualify completion limits on the immutable native 3.9.1 tuple + type: boolean + default: false + candidate_source: + description: Optional exact SDK commit for prepublication dist qualification only + type: string + required: false permissions: contents: read @@ -22,8 +30,8 @@ jobs: timeout-minutes: 10 env: SDK_VERSION: ${{ inputs.sdk_version || '3.18.0' }} - SERVER_VERSION: '3.8.0' - SERVER_IMAGE: durableworkflow/server@sha256:7988a23525d9e50e997a9f6f02e801300ed4f626de3cf6977033657ae7e9f58e + SERVER_VERSION: ${{ inputs.completion_limits && '3.9.1' || '3.8.0' }} + SERVER_IMAGE: ${{ inputs.completion_limits && 'durableworkflow/server@sha256:946c65eb70e8b4b7c4694be8a181c9669d46c3408b80d4e44d96f2030ea3cf51' || 'durableworkflow/server@sha256:7988a23525d9e50e997a9f6f02e801300ed4f626de3cf6977033657ae7e9f58e' }} METRICS_RUNTIME_URL: http://127.0.0.1:8080 METRICS_TOKEN: published-metrics-test-token steps: @@ -54,10 +62,56 @@ jobs: - name: Verify Composer trust before requests uses: durable-workflow/.github/.github/actions/verify-composer-trust@242c2a7fb23459db2e1ada5f84dbee6d61d9b1e5 # clean-trust - name: Install only the published SDK + if: inputs.candidate_source == '' run: | [[ "$SDK_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] composer init --working-dir="$CONSUMER" --name=durable-workflow/published-metrics --no-interaction composer require --working-dir="$CONSUMER" "durable-workflow/sdk:$SDK_VERSION" --no-scripts --no-interaction --prefer-dist --no-progress + - name: Validate an optional prepublication source + if: inputs.candidate_source != '' + env: + CANDIDATE_SOURCE: ${{ inputs.candidate_source }} + run: '[[ "$CANDIDATE_SOURCE" =~ ^[0-9a-f]{40}$ ]]' + - name: Check out only the selected public SDK candidate + if: inputs.candidate_source != '' + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ inputs.candidate_source }} + path: .candidate-sdk + persist-credentials: false + - name: Prepare and inspect the exact candidate dist + if: inputs.candidate_source != '' + env: + CANDIDATE_SOURCE: ${{ inputs.candidate_source }} + run: | + python3 -I -S -B - <<'PY' + import hashlib,json,os,pathlib,subprocess,zipfile + source=pathlib.Path('.candidate-sdk') + commit=os.environ['CANDIDATE_SOURCE'] + assert subprocess.check_output(['git','-C',str(source),'rev-parse','HEAD'],text=True).strip()==commit + consumer=pathlib.Path(os.environ['CONSUMER']) + archive=consumer/'candidate.zip' + subprocess.run(['git','-C',str(source),'archive','--format=zip','--prefix=sdk/','--output',str(archive),commit],check=True) + with zipfile.ZipFile(archive) as package: + names={name.removeprefix('sdk/') for name in package.namelist()} + excluded=('.github/','docker/','scripts/ci/','tests/') + assert not any(name.startswith(excluded) or name=='.dockerignore' for name in names) + expected=subprocess.check_output(['git','-C',str(source),'ls-files','src','examples','resources','config','docs/quickstart-contract.json','docs/laravel-adoption-contract.json'],text=True).splitlines() + assert expected and set(expected)<=names + manifest=json.loads((source/'composer.json').read_text()) + assert manifest['name']=='durable-workflow/sdk' and manifest['type']=='library' + assert manifest['extra']['durable-workflow']['product-train']==os.environ['SDK_VERSION'] + manifest.update(version=os.environ['SDK_VERSION'],dist={'type':'zip','url':archive.as_uri(),'reference':commit}) + (consumer/'composer.json').write_text(json.dumps({'name':'durable-workflow/candidate-worker','repositories':[{'type':'package','package':manifest}],'require':{'durable-workflow/sdk':os.environ['SDK_VERSION']},'config':{'allow-plugins':{}}})+'\n') + pathlib.Path('candidate-package.json').write_text(json.dumps({'stage':'prepublication','source':commit,'archive_sha256':hashlib.sha256(archive.read_bytes()).hexdigest(),'retained_runtime_inputs':len(expected),'internal_ci_tooling':False})+'\n') + PY + - name: Verify Composer trust before candidate requests + uses: durable-workflow/.github/.github/actions/verify-composer-trust@242c2a7fb23459db2e1ada5f84dbee6d61d9b1e5 # clean-trust + with: + working-directory: ${{ env.CONSUMER }} + - name: Install only the selected candidate dist + if: inputs.candidate_source != '' + run: composer install --working-dir="$CONSUMER" --no-scripts --no-interaction --prefer-dist --no-progress - name: Start the immutable published native Server run: | docker pull "$SERVER_IMAGE" @@ -80,6 +134,11 @@ jobs: run: | php -l tests/smoke/update-outcome.php timeout 60s php tests/smoke/update-outcome.php "$CONSUMER/vendor/autoload.php" | tee update-outcome.json + - name: Assert installed completion limits and same-worker recovery + if: inputs.completion_limits + run: | + php -l tests/smoke/workflow-completion-limits.php + timeout 240s php tests/smoke/workflow-completion-limits.php "$CONSUMER/vendor/autoload.php" | tee workflow-completion-limits.json - name: Retain the tuple, observations and installed dependency graph if: always() uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 @@ -88,6 +147,9 @@ jobs: path: | worker-metrics.json update-outcome.json + workflow-completion-limits.json + candidate-package.json + ${{ env.CONSUMER }}/candidate.zip ${{ env.CONSUMER }}/composer.lock retention-days: 14 - name: Show bounded Server diagnostics diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b44d6e1..d194f4c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [3.18.1] - 2026-11-13 + ### Fixed - Activity continuations honor the completion aggregate and command per-value @@ -21,6 +23,12 @@ project follows [Semantic Versioning](https://semver.org/). failures keep their existing behavior; older producers retain the conservative ordinary-request fallback. +- Pending update guidance distinguishes admission waits, which retain the original + request key, from GET-by-ID reads, where that key may be unavailable. Repeated + reads preserve the update ID and never admit a second update (.github#212). +- Distribution archives exclude internal CI and container tooling. Runtime + classes, resources, examples and public contracts remain included. + ## [3.18.0] - 2026-11-13 ### Added diff --git a/composer.json b/composer.json index 5c6a02bb..751b0777 100644 --- a/composer.json +++ b/composer.json @@ -84,6 +84,9 @@ }, "sort-packages": true }, + "archive": { + "exclude": ["/.git", "/.github", "/docker", "/scripts/ci", "/tests", "/.dockerignore"] + }, "extra": { "laravel": { "providers": [ @@ -94,7 +97,7 @@ } }, "durable-workflow": { - "product-train": "3.18.0", + "product-train": "3.18.1", "supported-server-versions": "2.5.0", "supported-server-release-baselines": { "2": "2.5.0", diff --git a/docs/portal/build/messages.md b/docs/portal/build/messages.md index c035d1fa..5a876022 100644 --- a/docs/portal/build/messages.md +++ b/docs/portal/build/messages.md @@ -91,10 +91,16 @@ $result = $handle->update( Reuse a stable request ID when a caller may retry the same update after losing its response. Use `waitFor: 'accepted'` when another process will observe completion later. -A request ID names one command. On a Server that advertises `command_request_keys` (native 3.9.0 and later), retrying a signal or update with the same request ID and the same input returns the original receipt, and reusing it with a different name, arguments or run target throws `SignalFailed` or `UpdateFailed` whose `isRequestIdConflict()` is true. `conflict()` names what differs and `retainedCommandId()` the original command. Nothing is written, and the SDK doesn't resend it. Use a new request ID for new input. +A request ID names one command. On a Server that advertises `command_request_keys` (native 3.9.1 and later), retrying a signal or update with the same request ID and the same input returns the original receipt, and reusing it with a different name, arguments or run target throws `SignalFailed` or `UpdateFailed` whose `isRequestIdConflict()` is true. `conflict()` names what differs and `retainedCommandId()` the original command. Nothing is written, and the SDK doesn't resend it. Use a new request ID for new input. If a `waitFor: 'completed'` wait ends before the update finishes, `update()` throws `UpdateStillPending` instead of returning a value. Call `update()` again with `$pending->requestId` to keep waiting. The Server returns the same update and doesn't apply it twice. +Use `Client::updateOutcome($workflowId, $pending->updateId)` to read the same update without another admission. This typed read defaults to `waitFor: 'completed'`: it throws `UpdateStillPending` while work is pending, then returns the result, including a completed `null`. + +This read requires the `update_outcome_reads` capability (native Server 3.6 and later). Without it, the SDK throws `LogicException` and does not resubmit the update. + +A pending read preserves the stable update ID. The producer may omit the original request key on this GET path; `$pending->requestId` is then an empty string for an unknown key. Continue with `updateOutcome()` and that update ID. Never submit another update using an empty, inferred or internal hashed key. + ## Follow run transitions deliberately Ordinary handle methods target the current run. `signalSelectedRun()`, `querySelectedRun()`, and `updateSelectedRun()` preserve the selected run guard. Choose selected-run methods for administrative tools whose answer must refer to one immutable run. diff --git a/docs/quickstart-contract.json b/docs/quickstart-contract.json index 0743a632..fbd87c03 100644 --- a/docs/quickstart-contract.json +++ b/docs/quickstart-contract.json @@ -3,8 +3,8 @@ "schema_version": 2, "package": { "name": "durable-workflow/sdk", - "published_version": "3.18.0", - "composer_requirement": "3.18.0", + "published_version": "3.18.1", + "composer_requirement": "3.18.1", "onboarding_requirement": "^3.18" }, "runtime_targets": { diff --git a/tests/ci/test_composer_archive.py b/tests/ci/test_composer_archive.py new file mode 100644 index 00000000..81f04e6b --- /dev/null +++ b/tests/ci/test_composer_archive.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Exercise Composer's actual archive filters for both Git checkout shapes.""" + +import argparse +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import zipfile + + +def run(*command, cwd=None): + return subprocess.check_output(command, cwd=cwd, text=True, stderr=subprocess.STDOUT) + + +def check(source): + required = run('git', 'ls-files', 'src', 'examples', 'resources', 'config', + 'docs/quickstart-contract.json', 'docs/laravel-adoption-contract.json', cwd=source).splitlines() + assert required + with tempfile.TemporaryDirectory(prefix='sdk-archive-private-marker-') as temporary: + state = Path(temporary) + ordinary = state / 'ordinary' + ordinary.mkdir() + # Materialize tracked bytes without exporting Git's filters first: the + # Composer invocation must prove its own CI/tooling exclusion policy. + for name in run('git', 'ls-files', cwd=source).splitlines(): + original = source / name + target = ordinary / name + target.parent.mkdir(parents=True, exist_ok=True) + if original.is_symlink(): + target.symlink_to(os.readlink(original)) + else: + shutil.copy2(original, target) + run('git', 'init', '-q', cwd=ordinary) + run('git', 'add', '-f', '.', cwd=ordinary) + run('git', '-c', 'core.hooksPath=/dev/null', '-c', 'commit.gpgSign=false', + '-c', 'user.name=Archive fixture', '-c', 'user.email=archive@example.invalid', + 'commit', '-qm', 'Archive fixture', cwd=ordinary) + linked = state / 'linked' + run('git', 'worktree', 'add', '--detach', str(linked), 'HEAD', cwd=ordinary) + assert (ordinary / '.git').is_dir() and (linked / '.git').is_file() + for checkout in (ordinary, linked): + output = state / (checkout.name + '-output') + output.mkdir() + run('composer', '--no-plugins', '--no-scripts', '--no-interaction', + 'archive', '--format=zip', '--dir=' + str(output), '--file=sdk', cwd=checkout) + with zipfile.ZipFile(output / 'sdk.zip') as archive: + names = {name.removeprefix('./') for name in archive.namelist()} + forbidden = ('.git/', '.github/', 'docker/', 'scripts/ci/', 'tests/') + assert not any(name in ('.git', '.dockerignore') or name.startswith(forbidden) + for name in names), 'Archive includes repository control or CI tooling' + assert set(required) <= names, 'Archive omits required package inputs' + for name in required: + assert archive.read(name) == (source / name).read_bytes(), name + for name in names: + if not name.endswith('/'): + content = archive.read(name) + assert str(state).encode() not in content, 'Archive leaks its private worktree path' + assert str(source).encode() not in content, 'Archive leaks the source checkout path' + print(checkout.name + ': real Composer ZIP excludes Git/CI/private paths and retains ' + + str(len(required)) + ' exact package inputs') + + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--source', type=Path, default=Path(__file__).resolve().parents[2]) + check(parser.parse_args().source.resolve(strict=True)) diff --git a/tests/smoke/workflow-completion-limits.php b/tests/smoke/workflow-completion-limits.php new file mode 100644 index 00000000..63ac6bfb --- /dev/null +++ b/tests/smoke/workflow-completion-limits.php @@ -0,0 +1,183 @@ +getFileName()); + requireCompletionAcceptance($file !== false && str_starts_with($file, $root.'/'), 'Source fallback is forbidden.'); +} +requireCompletionAcceptance(function_exists('pcntl_fork') && function_exists('posix_kill'), 'The acceptance profile requires process control.'); + +$client = new Client(getenv('METRICS_RUNTIME_URL'), token: getenv('METRICS_TOKEN')); +$info = $client->clusterInfo(); +$cap = $info->limits['max_workflow_task_completion_bytes'] ?? null; +$ordinary = $info->limits['max_payload_bytes'] ?? null; +$storage = $info->namespace['external_payload_storage']['status'] ?? 'unsupported'; +requireCompletionAcceptance($info->version === getenv('SERVER_VERSION') && ($info->raw['implementation'] ?? null) === 'rust' + && $cap === 33554432 && is_int($ordinary) && $ordinary > 0 + && in_array($storage, ['unconfigured', 'disabled', 'unsupported'], true), 'Unexpected published native limits or storage state.'); +$queue = 'completion-limits-'.bin2hex(random_bytes(6)); +$evidenceFile = tempnam(sys_get_temp_dir(), 'sdk-php-completion-'); +requireCompletionAcceptance(is_string($evidenceFile), 'Cannot create owned worker evidence.'); +$bootstrapFile = tempnam(sys_get_temp_dir(), 'sdk-php-completion-lease-'); +requireCompletionAcceptance(is_string($bootstrapFile), 'Cannot create owned maintenance bootstrap.'); +// Authentication remains in the inherited fixture environment, never in this +// file or the evidence. Only the owner needs HTTP observation middleware. +file_put_contents($bootstrapFile, 'completions); + if ($index !== null) { + $this->completions[$index]['status'] = (int) $tags['status_code']; + } + } + } + public function record(string $name, float $value, array $tags = []): void {} + }; + $exit = 0; + $errorClass = null; + try { + // Observe only actual wire size, command counts and status: no headers, + // credentials or payload contents enter the evidence. + $stack = HandlerStack::create(); + $stack->push(Middleware::tap(static function (RequestInterface $request) use ($trace): void { + $path = $request->getUri()->getPath(); + if ($request->getMethod() === 'POST' && str_contains($path, '/workflow-tasks/') && str_ends_with($path, '/complete')) { + $body = (string) $request->getBody(); + $trace->completions[] = ['bytes' => strlen($body), + 'schedules' => substr_count($body, '"type":"schedule_activity"')]; + } + if (in_array($request->getMethod(), ['POST', 'PUT'], true) && str_contains($path, '/external-payloads/')) { + ++$trace->uploads; + } + })); + $workerClient = (new Client(getenv('METRICS_RUNTIME_URL'), token: getenv('METRICS_TOKEN'), + transport: new Psr18Transport(new HttpClient(['handler' => $stack])), metrics: $trace)) + ->withActivityLeaseBootstrap($bootstrapFile); + $worker = new Worker($workerClient, $queue, diagnosticListener: static function (string $event, array $details) use ($trace): void { + if ($event === 'worker.workflow_task_completion_too_large') { + $trace->diagnostics[] = array_intersect_key($details, array_flip(['limit_kind', 'limit_bytes', 'size_bytes'])); + } + }); + $worker->registerActivity('completion.length', static function (ActivityContext $context, string $value) use ($trace): int { + $bytes = strlen($value); + $trace->activityCalls[$bytes] = ($trace->activityCalls[$bytes] ?? 0) + 1; + return $bytes; + }); + $worker->registerWorkflow('completion.fanout', static function (WorkflowContext $context, int $count, int $bytes): array { + $operations = []; + $argument = str_repeat('x', $bytes); + for ($index = 0; $index < $count; ++$index) { + $operations[] = $context->deferActivity('completion.length', [$argument]); + } + $lengths = $context->all($operations); + return [count($lengths), array_sum($lengths)]; + }); + // Match the other bounded installed smokes: avoid an idle long-poll + // delay before every activity in this serial functional fixture. + $worker->run(0); + } catch (Throwable $error) { + $exit = 1; + $errorClass = $error::class; + } finally { + file_put_contents($evidenceFile, json_encode(['completions' => $trace->completions, + 'diagnostics' => $trace->diagnostics, 'activity_calls' => $trace->activityCalls, + 'uploads' => $trace->uploads, 'worker_error_class' => $errorClass], JSON_THROW_ON_ERROR)); + } + exit($exit); +} + +$parentError = null; +try { + $start = static fn (int $count, int $bytes) => $client->startWorkflow('completion.fanout', + 'completion-'.bin2hex(random_bytes(8)), $queue, [$count, $bytes], executionTimeoutSeconds: 180, runTimeoutSeconds: 180); + $normal = $start(300, 10000); + requireCompletionAcceptance($normal->result(timeoutSeconds: 120) === [300, 3000000], 'The valid fan-out did not complete.'); + $over = $start(60, 450000); + try { + $over->result(timeoutSeconds: 40); + throw new RuntimeException('The oversized workflow unexpectedly completed.'); + } catch (WorkflowFailed) { + // The real Worker must settle the typed HTTP413 as one structural failure. + } + $followup = $start(1, 16); + requireCompletionAcceptance($followup->result(timeoutSeconds: 30) === [1, 16], 'The same Worker did not complete the follow-up.'); + $events = iterator_to_array($client->iterateWorkflowHistory($over->workflowId, $over->selectedRunId)); + $counts = array_count_values(array_column($events, 'event_type')); + $failures = array_values(array_filter($events, static fn (array $event): bool => $event['event_type'] === 'WorkflowFailed')); + requireCompletionAcceptance(($counts['ActivityScheduled'] ?? 0) === 0 && ($counts['WorkflowTaskFailed'] ?? 0) === 0 + && count($failures) === 1 && ($failures[0]['payload']['failure_category'] ?? null) === 'structural_limit', + 'The oversized aggregate admitted partial work or did not settle structurally.'); +} catch (Throwable $error) { + $parentError = $error; +} finally { + posix_kill($pid, SIGTERM); + pcntl_waitpid($pid, $workerStatus); +} + +try { + $evidence = json_decode((string) file_get_contents($evidenceFile), true, flags: JSON_THROW_ON_ERROR); + if ($parentError !== null) { + fwrite(STDERR, json_encode(['parent_error_class' => $parentError::class, 'worker' => $evidence], JSON_THROW_ON_ERROR).PHP_EOL); + throw $parentError; + } + requireCompletionAcceptance(pcntl_wifexited($workerStatus) && pcntl_wexitstatus($workerStatus) === 0 + && $evidence['worker_error_class'] === null, 'The Worker did not shut down cleanly.'); + $large = array_values(array_filter($evidence['completions'], static fn (array $row): bool => $row['bytes'] > $cap)); + $valid = array_values(array_filter($evidence['completions'], static fn (array $row): bool => $row['schedules'] === 300)); + requireCompletionAcceptance(count($large) === 1 && $large[0]['status'] === 413 && $large[0]['schedules'] === 60 + && count($valid) === 1 && $valid[0]['status'] === 200 && $valid[0]['bytes'] > $ordinary + && count($evidence['diagnostics']) === 1 && $evidence['diagnostics'][0]['limit_bytes'] === $cap + && $evidence['diagnostics'][0]['size_bytes'] === $large[0]['bytes'] + && ($evidence['activity_calls'][10000] ?? 0) === 300 && ($evidence['activity_calls'][450000] ?? 0) === 0 + && ($evidence['activity_calls'][16] ?? 0) === 1 && $evidence['uploads'] === 0, + 'Wire, activity, no-upload or typed-refusal evidence differs from the contract.'); + echo json_encode(['outcome' => 'pass', 'sdk_version' => $version, 'sdk_reference' => $reference, + 'server_version' => $info->version, 'php_version' => PHP_VERSION, 'ordinary_limit_bytes' => $ordinary, + 'completion_limit_bytes' => $cap, 'storage_status' => $storage, 'normal' => $valid[0], 'over' => $large[0], + 'over_history_counts' => $counts, 'structural_diagnostic' => $evidence['diagnostics'][0], + 'activity_calls' => $evidence['activity_calls'], 'uploads' => 0, 'same_worker_followup' => true, + 'source_fallback' => false], JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR).PHP_EOL; +} finally { + unlink($evidenceFile); + unlink($bootstrapFile); +}