Skip to content

Commit 1d29e74

Browse files
#355: gate v2 API floor guard on resolved engine source
v1 installs and auto-mode installs that fall back to v1 must boot cleanly even when the v2 surface is absent or stale. assertIfActive() skips the floor check entirely when the resolved engine source is not v2. Refactored assert() into assertAgainst() and findMissing() so tests can exercise the detection and throw paths without requiring a broken workflow package to be installed.
1 parent f216575 commit 1d29e74

4 files changed

Lines changed: 234 additions & 29 deletions

File tree

app/Support/WorkflowPackageApiFloor.php

Lines changed: 75 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,23 @@
99

1010
/**
1111
* Enforces the minimum `durable-workflow/workflow` API surface Waterline
12-
* relies on at runtime.
12+
* relies on when the v2 operator bridge is active.
1313
*
1414
* Waterline's composer constraint (`^1.0 || ^2.0`) intentionally covers a
15-
* wide range. Inside the v2 band, however, specific methods that carry
15+
* wide range because Waterline ships with a v1 fallback. The API floor
16+
* here only matters when the resolved engine source is v2 — a v1 install
17+
* or an `auto`-mode install that falls back to v1 never calls the v2
18+
* schedule mutation signatures and must boot cleanly even when the v2
19+
* classes are absent.
20+
*
21+
* Inside the v2 band, however, specific methods that carry
1622
* `CommandContext` for audit attribution landed only in workflow commit
1723
* `e59e6f2`. An older v2 install that predates that commit lacks the
1824
* context-accepting signatures and fails the Waterline schedule
1925
* pause/resume/trigger/backfill/delete routes with unknown-named-parameter
20-
* or argument-count errors.
21-
*
22-
* Assert the floor at boot so broken pairings surface with a clear
23-
* diagnostic instead of a 500 the first time an operator clicks "Pause"
24-
* in the UI.
26+
* or argument-count errors. `assertIfActive()` is called at boot so those
27+
* broken pairings surface with a clear diagnostic instead of a 500 the
28+
* first time an operator clicks "Pause" in the UI.
2529
*
2630
* @see https://github.com/zorporation/durable-workflow/issues/355
2731
*/
@@ -31,6 +35,8 @@ final class WorkflowPackageApiFloor
3135
* Each entry is `[FQCN, method, required_parameter]`. The class and
3236
* method must exist, the method must be public-static, and the named
3337
* parameter must be declared on its signature.
38+
*
39+
* @var list<array{0: class-string, 1: string, 2: string}>
3440
*/
3541
private const REQUIRED_PARAMETERS = [
3642
[\Workflow\V2\Support\ScheduleManager::class, 'pause', 'context'],
@@ -43,19 +49,77 @@ final class WorkflowPackageApiFloor
4349

4450
public const COMMAND_CONTEXT_CLASS = \Workflow\V2\CommandContext::class;
4551

52+
/**
53+
* Assert the v2 API floor only when the resolved engine source is v2.
54+
*
55+
* v1 installs and `auto`-mode installs that fall back to v1 skip the
56+
* check entirely so they continue to boot even when the v2 classes
57+
* are absent or incomplete.
58+
*/
59+
public static function assertIfActive(): void
60+
{
61+
if (! WorkflowEngineSourceResolver::usesV2()) {
62+
return;
63+
}
64+
65+
self::assert();
66+
}
67+
4668
/**
4769
* Assert every required API surface is present. Throws with a single
4870
* aggregated diagnostic when the installed workflow package is too old.
4971
*/
5072
public static function assert(): void
5173
{
74+
self::assertAgainst();
75+
}
76+
77+
/**
78+
* Assert against an explicit context class and requirement list. Exposed
79+
* so regression tests can verify the throw path without mutating global
80+
* class state. `assert()` calls this with the real REQUIRED_PARAMETERS.
81+
*
82+
* @param list<array{0: string, 1: string, 2: string}>|null $requirements
83+
*/
84+
public static function assertAgainst(
85+
?string $contextClass = null,
86+
?array $requirements = null,
87+
): void {
88+
$missing = self::findMissing($contextClass, $requirements);
89+
90+
if ($missing === []) {
91+
return;
92+
}
93+
94+
throw new RuntimeException(sprintf(
95+
"Installed durable-workflow/workflow package is older than the API floor Waterline requires. "
96+
."Missing: %s. Upgrade the workflow package to a v2 snapshot that includes CommandContext "
97+
.'and the context-accepting schedule mutation signatures (see repos/workflow commit e59e6f2).',
98+
implode(', ', $missing),
99+
));
100+
}
101+
102+
/**
103+
* Return the list of missing API-floor entries, or an empty list when
104+
* the installed workflow package meets the floor. Exposed so regression
105+
* tests can verify the detector fires without catching exceptions.
106+
*
107+
* @param list<array{0: string, 1: string, 2: string}>|null $requirements
108+
* @return list<string>
109+
*/
110+
public static function findMissing(
111+
?string $contextClass = null,
112+
?array $requirements = null,
113+
): array {
114+
$contextClass ??= self::COMMAND_CONTEXT_CLASS;
115+
$requirements ??= self::REQUIRED_PARAMETERS;
52116
$missing = [];
53117

54-
if (! class_exists(self::COMMAND_CONTEXT_CLASS)) {
55-
$missing[] = self::COMMAND_CONTEXT_CLASS;
118+
if (! class_exists($contextClass)) {
119+
$missing[] = $contextClass;
56120
}
57121

58-
foreach (self::REQUIRED_PARAMETERS as [$class, $method, $parameter]) {
122+
foreach ($requirements as [$class, $method, $parameter]) {
59123
$reflectionMethod = self::reflectStaticMethod($class, $method);
60124

61125
if ($reflectionMethod === null) {
@@ -69,16 +133,7 @@ public static function assert(): void
69133
}
70134
}
71135

72-
if ($missing === []) {
73-
return;
74-
}
75-
76-
throw new RuntimeException(sprintf(
77-
"Installed durable-workflow/workflow package is older than the API floor Waterline requires. "
78-
."Missing: %s. Upgrade the workflow package to a v2 snapshot that includes CommandContext "
79-
.'and the context-accepting schedule mutation signatures (see repos/workflow commit e59e6f2).',
80-
implode(', ', $missing),
81-
));
136+
return $missing;
82137
}
83138

84139
private static function reflectStaticMethod(string $class, string $method): ?ReflectionMethod

app/WaterlineServiceProvider.php

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,10 @@ class WaterlineServiceProvider extends ServiceProvider
1515
*/
1616
public function boot()
1717
{
18-
// Assert the installed workflow package meets the API floor
19-
// Waterline depends on (CommandContext-accepting schedule
20-
// mutations, etc.). Older v2 snapshots fail schedule routes with
21-
// unknown-named-parameter errors — catch that at boot instead.
22-
WorkflowPackageApiFloor::assert();
18+
// Assert the v2 API floor only when the resolved engine source is
19+
// v2. v1 installs and auto-mode installs that fall back to v1 must
20+
// continue to boot even when the v2 surface is absent or stale.
21+
WorkflowPackageApiFloor::assertIfActive();
2322

2423
$this->registerRoutes();
2524
$this->registerResources();

tests/Unit/Support/WorkflowPackageApiFloorTest.php

Lines changed: 126 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,23 @@
22

33
namespace Waterline\Tests\Unit\Support;
44

5-
use PHPUnit\Framework\TestCase;
65
use ReflectionClass;
6+
use RuntimeException;
77
use Waterline\Support\WorkflowPackageApiFloor;
8+
use Waterline\Tests\TestCase;
89
use Workflow\V2\CommandContext;
910
use Workflow\V2\Support\ScheduleManager;
1011

1112
/**
1213
* Pins the API floor Waterline relies on from durable-workflow/workflow.
13-
* If one of these assertions fails, Waterline is pinned against a
14-
* workflow package that is too old — upgrade the package rather than
15-
* relaxing the check.
14+
*
15+
* If one of these assertions fails against the currently resolved workflow
16+
* package, Waterline is pinned against a workflow package that is too old —
17+
* upgrade the package rather than relaxing the check.
18+
*
19+
* The boot-time gating behavior (only asserting when the resolved engine
20+
* source is v2) is covered by `assertIfActive()` tests so v1 and
21+
* auto-fallback installs continue to boot cleanly.
1622
*/
1723
class WorkflowPackageApiFloorTest extends TestCase
1824
{
@@ -47,6 +53,117 @@ public function test_schedule_manager_method_accepts_context_parameter(string $m
4753
$this->assertContains('context', $parameterNames, "$method does not declare a \$context parameter");
4854
}
4955

56+
public function test_find_missing_returns_empty_list_on_current_workflow_package(): void
57+
{
58+
$this->assertSame([], WorkflowPackageApiFloor::findMissing());
59+
}
60+
61+
public function test_find_missing_reports_missing_command_context_class(): void
62+
{
63+
$missing = WorkflowPackageApiFloor::findMissing(
64+
contextClass: 'Waterline\\Tests\\Unit\\Support\\NonExistentCommandContext',
65+
requirements: [],
66+
);
67+
68+
$this->assertSame(
69+
['Waterline\\Tests\\Unit\\Support\\NonExistentCommandContext'],
70+
$missing,
71+
);
72+
}
73+
74+
public function test_find_missing_reports_missing_schedule_method(): void
75+
{
76+
$missing = WorkflowPackageApiFloor::findMissing(
77+
requirements: [
78+
[ScheduleManager::class, 'thisMethodDoesNotExist', 'context'],
79+
],
80+
);
81+
82+
$this->assertSame(
83+
[sprintf('%s::thisMethodDoesNotExist()', ScheduleManager::class)],
84+
$missing,
85+
);
86+
}
87+
88+
public function test_find_missing_reports_missing_parameter_on_existing_method(): void
89+
{
90+
$missing = WorkflowPackageApiFloor::findMissing(
91+
requirements: [
92+
[ScheduleManager::class, 'pause', 'nonexistent_parameter_name'],
93+
],
94+
);
95+
96+
$this->assertSame(
97+
[sprintf('%s::pause($nonexistent_parameter_name)', ScheduleManager::class)],
98+
$missing,
99+
);
100+
}
101+
102+
public function test_assert_if_active_skips_when_engine_source_is_explicit_v1(): void
103+
{
104+
config()->set('waterline.engine_source', 'v1');
105+
106+
WorkflowPackageApiFloor::assertIfActive();
107+
108+
$this->expectNotToPerformAssertions();
109+
}
110+
111+
public function test_assert_if_active_skips_when_auto_falls_back_to_v1(): void
112+
{
113+
config()->set('waterline.engine_source', 'auto');
114+
config()->set('workflows.v2.run_summary_model', MissingWorkflowRunSummaryForFloorGuard::class);
115+
116+
WorkflowPackageApiFloor::assertIfActive();
117+
118+
$this->expectNotToPerformAssertions();
119+
}
120+
121+
public function test_assert_if_active_runs_when_engine_source_is_v2_and_passes_on_current_package(): void
122+
{
123+
config()->set('waterline.engine_source', 'v2');
124+
125+
WorkflowPackageApiFloor::assertIfActive();
126+
127+
$this->expectNotToPerformAssertions();
128+
}
129+
130+
public function test_assert_if_active_runs_when_auto_resolves_to_v2_and_passes_on_current_package(): void
131+
{
132+
config()->set('waterline.engine_source', 'auto');
133+
134+
WorkflowPackageApiFloor::assertIfActive();
135+
136+
$this->expectNotToPerformAssertions();
137+
}
138+
139+
public function test_assert_against_throws_with_aggregated_diagnostic_when_floor_is_violated(): void
140+
{
141+
$this->expectException(RuntimeException::class);
142+
$this->expectExceptionMessage('older than the API floor Waterline requires');
143+
$this->expectExceptionMessage('NonExistentCommandContext');
144+
$this->expectExceptionMessage('thisMethodDoesNotExist');
145+
$this->expectExceptionMessage('context-accepting schedule mutation signatures');
146+
147+
WorkflowPackageApiFloor::assertAgainst(
148+
contextClass: 'Waterline\\Tests\\Unit\\Support\\NonExistentCommandContext',
149+
requirements: [
150+
[ScheduleManager::class, 'thisMethodDoesNotExist', 'context'],
151+
],
152+
);
153+
}
154+
155+
public function test_assert_against_passes_when_explicit_requirements_are_met(): void
156+
{
157+
WorkflowPackageApiFloor::assertAgainst(
158+
contextClass: CommandContext::class,
159+
requirements: [
160+
[ScheduleManager::class, 'pause', 'context'],
161+
],
162+
);
163+
164+
$this->expectNotToPerformAssertions();
165+
}
166+
50167
/**
51168
* @return array<string, array{0: string}>
52169
*/
@@ -62,3 +179,8 @@ public static function contextAcceptingScheduleMethodProvider(): array
62179
];
63180
}
64181
}
182+
183+
final class MissingWorkflowRunSummaryForFloorGuard extends \Workflow\V2\Models\WorkflowRunSummary
184+
{
185+
protected $table = 'missing_workflow_run_summaries_for_floor_guard';
186+
}

tests/Unit/WaterlineServiceProviderTest.php

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,35 @@ public function testExplicitV2EngineSourceBindsUnavailableRepositoryWhenOperator
6363

6464
$this->assertInstanceOf(UnavailableV2WorkflowRepository::class, $repository);
6565
}
66+
67+
public function testBootPassesFloorGuardWhenPinnedToV1EvenIfV2SurfaceIsMissing(): void
68+
{
69+
config()->set('waterline.engine_source', 'v1');
70+
config()->set('workflows.v2.run_summary_model', MissingWorkflowRunSummary::class);
71+
72+
(new WaterlineServiceProvider($this->app))->boot();
73+
74+
$this->expectNotToPerformAssertions();
75+
}
76+
77+
public function testBootPassesFloorGuardWhenAutoFallsBackToV1(): void
78+
{
79+
config()->set('waterline.engine_source', 'auto');
80+
config()->set('workflows.v2.run_summary_model', MissingWorkflowRunSummary::class);
81+
82+
(new WaterlineServiceProvider($this->app))->boot();
83+
84+
$this->expectNotToPerformAssertions();
85+
}
86+
87+
public function testBootPassesFloorGuardWhenResolvedToV2OnCurrentWorkflowPackage(): void
88+
{
89+
config()->set('waterline.engine_source', 'v2');
90+
91+
(new WaterlineServiceProvider($this->app))->boot();
92+
93+
$this->expectNotToPerformAssertions();
94+
}
6695
}
6796

6897
final class MissingWorkflowRunSummary extends \Workflow\V2\Models\WorkflowRunSummary

0 commit comments

Comments
 (0)