Skip to content

Commit 17734c1

Browse files
Expose the schedule audit history stream in Waterline
Adds GET /waterline/api/v2/schedules/{scheduleId}/history returning the full workflow_schedule_history_events stream for a schedule, with cursor pagination (after_sequence) and a bounded limit. Respects the Waterline namespace scope so multi-tenant deployments see only their schedule's audit entries. Five feature tests pin the event ordering, cursor behaviour, namespace isolation, limit clamp, and the 404 for missing schedules. This makes the canonical per-schedule audit events (ScheduleCreated, SchedulePaused, ScheduleResumed, ScheduleUpdated, ScheduleTriggered, ScheduleTriggerSkipped, ScheduleDeleted) readable through the same HTTP surface operators already use for schedule pause/resume/trigger actions.
1 parent 851ec27 commit 17734c1

3 files changed

Lines changed: 288 additions & 0 deletions

File tree

app/Http/Controllers/V2SchedulesController.php

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use Workflow\V2\Enums\ScheduleOverlapPolicy;
1010
use Workflow\V2\Enums\ScheduleStatus;
1111
use Workflow\V2\Models\WorkflowSchedule;
12+
use Workflow\V2\Models\WorkflowScheduleHistoryEvent;
1213
use Workflow\V2\Support\ScheduleManager;
1314
use Waterline\Waterline;
1415

@@ -153,6 +154,48 @@ public function backfill(Request $request, string $scheduleId): JsonResponse
153154
]);
154155
}
155156

157+
public function history(Request $request, string $scheduleId): JsonResponse
158+
{
159+
$schedule = $this->findSchedule($scheduleId);
160+
161+
if ($schedule === null) {
162+
return response()->json(['error' => 'Schedule not found.'], 404);
163+
}
164+
165+
$limit = $this->parseLimit($request->query('limit'));
166+
$afterSequence = $this->parseAfterSequence($request->query('after_sequence'));
167+
168+
$query = $schedule->historyEvents();
169+
170+
if ($afterSequence !== null) {
171+
$query->where('sequence', '>', $afterSequence);
172+
}
173+
174+
$events = $query->limit($limit + 1)->get();
175+
$hasMore = $events->count() > $limit;
176+
$events = $events->take($limit);
177+
178+
$nextCursor = $hasMore && $events->isNotEmpty()
179+
? (int) $events->last()->sequence
180+
: null;
181+
182+
return response()->json([
183+
'schedule_id' => $schedule->schedule_id,
184+
'namespace' => $schedule->namespace,
185+
'events' => $events->map(fn (WorkflowScheduleHistoryEvent $event): array => [
186+
'id' => $event->id,
187+
'sequence' => (int) $event->sequence,
188+
'event_type' => $event->event_type?->value,
189+
'payload' => is_array($event->payload) ? $event->payload : [],
190+
'workflow_instance_id' => $event->workflow_instance_id,
191+
'workflow_run_id' => $event->workflow_run_id,
192+
'recorded_at' => $event->recorded_at?->toIso8601String(),
193+
])->values(),
194+
'next_cursor' => $nextCursor,
195+
'has_more' => $hasMore,
196+
]);
197+
}
198+
156199
public function destroy(Request $request, string $scheduleId): JsonResponse
157200
{
158201
$schedule = $this->findSchedule($scheduleId);
@@ -166,6 +209,35 @@ public function destroy(Request $request, string $scheduleId): JsonResponse
166209
return response()->json(ScheduleManager::describe($schedule)->toArray());
167210
}
168211

212+
private function parseLimit(mixed $raw): int
213+
{
214+
$default = 100;
215+
$max = 500;
216+
217+
if (! is_string($raw) && ! is_int($raw)) {
218+
return $default;
219+
}
220+
221+
$value = (int) $raw;
222+
223+
if ($value <= 0) {
224+
return $default;
225+
}
226+
227+
return min($value, $max);
228+
}
229+
230+
private function parseAfterSequence(mixed $raw): ?int
231+
{
232+
if (! is_string($raw) && ! is_int($raw)) {
233+
return null;
234+
}
235+
236+
$value = (int) $raw;
237+
238+
return $value > 0 ? $value : null;
239+
}
240+
169241
private function findSchedule(string $scheduleId): ?WorkflowSchedule
170242
{
171243
$namespace = config('waterline.namespace');

routes/web.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
Route::post('/instances/{instanceId}/archive', 'WorkflowsController@archiveInstance')->name('waterline.instances.archive');
5252
Route::get('/v2/schedules', 'V2SchedulesController@index')->name('waterline.v2.schedules.index');
5353
Route::get('/v2/schedules/{scheduleId}', 'V2SchedulesController@show')->name('waterline.v2.schedules.show');
54+
Route::get('/v2/schedules/{scheduleId}/history', 'V2SchedulesController@history')->name('waterline.v2.schedules.history');
5455
Route::post('/v2/schedules/{scheduleId}/pause', 'V2SchedulesController@pause')->name('waterline.v2.schedules.pause');
5556
Route::post('/v2/schedules/{scheduleId}/resume', 'V2SchedulesController@resume')->name('waterline.v2.schedules.resume');
5657
Route::post('/v2/schedules/{scheduleId}/trigger', 'V2SchedulesController@trigger')->name('waterline.v2.schedules.trigger');
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
<?php
2+
3+
namespace Waterline\Tests\Feature;
4+
5+
use Illuminate\Support\Carbon;
6+
use Waterline\Tests\TestCase;
7+
use Workflow\V2\Enums\HistoryEventType;
8+
use Workflow\V2\Enums\ScheduleStatus;
9+
use Workflow\V2\Models\WorkflowSchedule;
10+
use Workflow\V2\Models\WorkflowScheduleHistoryEvent;
11+
12+
class V2SchedulesHistoryControllerTest extends TestCase
13+
{
14+
public function testHistoryReturns404WhenScheduleMissing(): void
15+
{
16+
$this->getJson('/waterline/api/v2/schedules/missing-id/history')
17+
->assertStatus(404)
18+
->assertJsonPath('error', 'Schedule not found.');
19+
}
20+
21+
public function testHistoryReturnsEventsOrderedBySequence(): void
22+
{
23+
$schedule = $this->createSchedule('daily-invoice-sync');
24+
25+
Carbon::setTestNow('2026-04-24T10:00:00+00:00');
26+
WorkflowScheduleHistoryEvent::record(
27+
$schedule,
28+
HistoryEventType::ScheduleCreated,
29+
[
30+
'spec' => ['cron_expressions' => ['0 2 * * *'], 'timezone' => 'UTC'],
31+
'action' => ['workflow_type' => 'workflow.test'],
32+
'overlap_policy' => 'skip',
33+
'next_fire_at' => '2026-04-25T02:00:00+00:00',
34+
'command_context' => ['source' => 'api'],
35+
],
36+
);
37+
38+
Carbon::setTestNow('2026-04-24T10:05:00+00:00');
39+
WorkflowScheduleHistoryEvent::record(
40+
$schedule,
41+
HistoryEventType::SchedulePaused,
42+
[
43+
'reason' => 'maintenance',
44+
'paused_at' => '2026-04-24T10:05:00+00:00',
45+
'command_context' => ['source' => 'waterline'],
46+
],
47+
);
48+
49+
Carbon::setTestNow('2026-04-24T10:10:00+00:00');
50+
WorkflowScheduleHistoryEvent::record(
51+
$schedule,
52+
HistoryEventType::ScheduleResumed,
53+
[
54+
'next_fire_at' => '2026-04-25T02:00:00+00:00',
55+
'command_context' => ['source' => 'waterline'],
56+
],
57+
);
58+
59+
$response = $this->getJson('/waterline/api/v2/schedules/daily-invoice-sync/history')
60+
->assertStatus(200)
61+
->assertJsonPath('schedule_id', 'daily-invoice-sync')
62+
->assertJsonPath('namespace', 'default')
63+
->assertJsonPath('has_more', false)
64+
->assertJsonPath('next_cursor', null)
65+
->assertJsonCount(3, 'events');
66+
67+
$events = $response->json('events');
68+
69+
$this->assertSame(1, $events[0]['sequence']);
70+
$this->assertSame('ScheduleCreated', $events[0]['event_type']);
71+
$this->assertSame('skip', $events[0]['payload']['overlap_policy']);
72+
73+
$this->assertSame(2, $events[1]['sequence']);
74+
$this->assertSame('SchedulePaused', $events[1]['event_type']);
75+
$this->assertSame('maintenance', $events[1]['payload']['reason']);
76+
77+
$this->assertSame(3, $events[2]['sequence']);
78+
$this->assertSame('ScheduleResumed', $events[2]['event_type']);
79+
$this->assertArrayHasKey('recorded_at', $events[2]);
80+
$this->assertArrayHasKey('id', $events[2]);
81+
}
82+
83+
public function testHistorySupportsCursorPagination(): void
84+
{
85+
$schedule = $this->createSchedule('paginated-schedule');
86+
87+
for ($i = 0; $i < 5; $i++) {
88+
WorkflowScheduleHistoryEvent::record(
89+
$schedule,
90+
HistoryEventType::ScheduleTriggered,
91+
[
92+
'workflow_instance_id' => 'instance-'.$i,
93+
'workflow_run_id' => 'run-'.$i,
94+
'schedule_id' => 'paginated-schedule',
95+
'schedule_ulid' => $schedule->id,
96+
'cron_expression' => '*/5 * * * *',
97+
'timezone' => 'UTC',
98+
'overlap_policy' => 'skip',
99+
'outcome' => 'started',
100+
'effective_overlap_policy' => 'skip',
101+
'trigger_number' => $i + 1,
102+
'occurrence_time' => '2026-04-24T10:0'.$i.':00+00:00',
103+
'command_context' => ['source' => 'tick'],
104+
],
105+
);
106+
}
107+
108+
$firstPage = $this->getJson('/waterline/api/v2/schedules/paginated-schedule/history?limit=2')
109+
->assertStatus(200)
110+
->assertJsonCount(2, 'events')
111+
->assertJsonPath('has_more', true)
112+
->assertJsonPath('next_cursor', 2);
113+
114+
$this->assertSame(1, $firstPage->json('events.0.sequence'));
115+
$this->assertSame(2, $firstPage->json('events.1.sequence'));
116+
117+
$secondPage = $this->getJson('/waterline/api/v2/schedules/paginated-schedule/history?limit=2&after_sequence=2')
118+
->assertStatus(200)
119+
->assertJsonCount(2, 'events')
120+
->assertJsonPath('has_more', true)
121+
->assertJsonPath('next_cursor', 4);
122+
123+
$this->assertSame(3, $secondPage->json('events.0.sequence'));
124+
$this->assertSame(4, $secondPage->json('events.1.sequence'));
125+
126+
$thirdPage = $this->getJson('/waterline/api/v2/schedules/paginated-schedule/history?limit=2&after_sequence=4')
127+
->assertStatus(200)
128+
->assertJsonCount(1, 'events')
129+
->assertJsonPath('has_more', false)
130+
->assertJsonPath('next_cursor', null);
131+
132+
$this->assertSame(5, $thirdPage->json('events.0.sequence'));
133+
}
134+
135+
public function testHistoryRespectsConfiguredNamespace(): void
136+
{
137+
config()->set('waterline.namespace', 'tenant-a');
138+
139+
$scheduleA = $this->createSchedule('shared-id', namespace: 'tenant-a');
140+
$scheduleB = $this->createSchedule('shared-id', namespace: 'tenant-b');
141+
142+
WorkflowScheduleHistoryEvent::record(
143+
$scheduleA,
144+
HistoryEventType::SchedulePaused,
145+
[
146+
'reason' => 'tenant-a-pause',
147+
'paused_at' => '2026-04-24T10:00:00+00:00',
148+
'command_context' => ['source' => 'api'],
149+
],
150+
);
151+
152+
WorkflowScheduleHistoryEvent::record(
153+
$scheduleB,
154+
HistoryEventType::SchedulePaused,
155+
[
156+
'reason' => 'tenant-b-pause',
157+
'paused_at' => '2026-04-24T10:00:00+00:00',
158+
'command_context' => ['source' => 'api'],
159+
],
160+
);
161+
162+
$response = $this->getJson('/waterline/api/v2/schedules/shared-id/history')
163+
->assertStatus(200)
164+
->assertJsonPath('namespace', 'tenant-a')
165+
->assertJsonCount(1, 'events');
166+
167+
$this->assertSame('tenant-a-pause', $response->json('events.0.payload.reason'));
168+
}
169+
170+
public function testHistoryClampsLimitAtUpperBound(): void
171+
{
172+
$schedule = $this->createSchedule('big-limit');
173+
174+
WorkflowScheduleHistoryEvent::record(
175+
$schedule,
176+
HistoryEventType::ScheduleCreated,
177+
[
178+
'spec' => ['cron_expressions' => ['0 * * * *'], 'timezone' => 'UTC'],
179+
'action' => ['workflow_type' => 'workflow.test'],
180+
'overlap_policy' => 'skip',
181+
'next_fire_at' => '2026-04-25T00:00:00+00:00',
182+
'command_context' => ['source' => 'api'],
183+
],
184+
);
185+
186+
$this->getJson('/waterline/api/v2/schedules/big-limit/history?limit=99999')
187+
->assertStatus(200)
188+
->assertJsonCount(1, 'events');
189+
190+
$this->getJson('/waterline/api/v2/schedules/big-limit/history?limit=0')
191+
->assertStatus(200)
192+
->assertJsonCount(1, 'events');
193+
194+
$this->getJson('/waterline/api/v2/schedules/big-limit/history?limit=-5')
195+
->assertStatus(200)
196+
->assertJsonCount(1, 'events');
197+
}
198+
199+
private function createSchedule(string $scheduleId, string $namespace = 'default'): WorkflowSchedule
200+
{
201+
return WorkflowSchedule::create([
202+
'schedule_id' => $scheduleId,
203+
'namespace' => $namespace,
204+
'spec' => ['cron_expressions' => ['0 * * * *'], 'timezone' => 'UTC'],
205+
'action' => ['workflow_type' => 'workflow.test', 'workflow_class' => 'Test\\ScheduledWorkflow'],
206+
'status' => ScheduleStatus::Active,
207+
'overlap_policy' => 'skip',
208+
'fires_count' => 0,
209+
'failures_count' => 0,
210+
'skipped_trigger_count' => 0,
211+
'jitter_seconds' => 0,
212+
'next_fire_at' => Carbon::now()->addHour(),
213+
]);
214+
}
215+
}

0 commit comments

Comments
 (0)