Skip to content

Commit 95c8c4e

Browse files
authored
Merge pull request #167 from n8bar/claude/issue-163-watcher-liveness
fix(support): watcher health reads the watcher's own run stamp, not payment recency
2 parents 4c30ec0 + d7b994b commit 95c8c4e

6 files changed

Lines changed: 115 additions & 35 deletions

File tree

‎app/Console/Commands/WatchInvoicePayments.php‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use App\Models\Invoice;
66
use App\Services\DonationPaymentSyncService;
77
use App\Services\InvoicePaymentSyncService;
8+
use App\Support\WatcherLiveness;
89
use Illuminate\Console\Command;
910

1011
class WatchInvoicePayments extends Command
@@ -70,6 +71,10 @@ public function handle(): int
7071
if (! $this->option('invoice')) {
7172
$donations = $this->donationSyncService->syncPending();
7273
$this->info("Checked {$donations['checked']} donation addresses, {$donations['paid']} newly paid.");
74+
75+
// A single-invoice run is not a full sweep, so only unfiltered
76+
// runs count as liveness for the support dashboard (#163).
77+
WatcherLiveness::recordCompletedRun();
7378
}
7479

7580
return Command::SUCCESS;

‎app/Http/Controllers/Support/SupportDashboardController.php‎

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use App\Http\Controllers\Controller;
66
use App\Models\User;
7+
use App\Support\WatcherLiveness;
78
use Illuminate\Contracts\View\View;
89
use Illuminate\Http\Request;
910
use Illuminate\Support\Facades\DB;
@@ -70,16 +71,21 @@ private function monitoringPanel(): array
7071
->where('is_adjustment', false)
7172
->max(DB::raw('COALESCE(detected_at, created_at)'));
7273

74+
// Liveness comes from the watcher's own run stamp, never from payment
75+
// recency — a quiet hour is not a dead watcher, and a payment landing
76+
// says nothing about the watcher since (#163). No stamp reads stale.
7377
$staleMinutes = config('support.watcher_stale_minutes', 60);
74-
$watcherStale = $lastPaymentAt !== null
75-
&& now()->diffInMinutes($lastPaymentAt, false) < -$staleMinutes;
78+
$watcherLastRanAt = WatcherLiveness::lastCompletedRunAt();
79+
$watcherStale = $watcherLastRanAt === null
80+
|| $watcherLastRanAt->lt(now()->subMinutes($staleMinutes));
7681

7782
return [
78-
'queue_depth' => $queueDepth,
79-
'recent_failures' => $recentFailures,
80-
'last_payment_at' => $lastPaymentAt,
81-
'watcher_stale' => $watcherStale,
82-
'stale_minutes' => $staleMinutes,
83+
'queue_depth' => $queueDepth,
84+
'recent_failures' => $recentFailures,
85+
'last_payment_at' => $lastPaymentAt,
86+
'watcher_last_ran_at' => $watcherLastRanAt,
87+
'watcher_stale' => $watcherStale,
88+
'stale_minutes' => $staleMinutes,
8389
];
8490
}
8591
}

‎app/Support/WatcherLiveness.php‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
namespace App\Support;
4+
5+
use Illuminate\Support\Carbon;
6+
use Illuminate\Support\Facades\Cache;
7+
8+
/**
9+
* When the payment watcher last completed a full sweep.
10+
*
11+
* Lives in the cache (database store) because the watcher runs in the
12+
* scheduler container while the support dashboard renders in the app
13+
* container — WorkerHeartbeat's files are container-local by design and
14+
* cannot cross that boundary (#163).
15+
*/
16+
class WatcherLiveness
17+
{
18+
public const CACHE_KEY = 'watcher:last_completed_run_at';
19+
20+
public static function recordCompletedRun(): void
21+
{
22+
Cache::forever(self::CACHE_KEY, now()->toIso8601String());
23+
}
24+
25+
public static function lastCompletedRunAt(): ?Carbon
26+
{
27+
$value = Cache::get(self::CACHE_KEY);
28+
29+
return $value ? Carbon::parse($value) : null;
30+
}
31+
}

‎docs/specs/SUPPORT_ACCESS.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ The owner setting copy should communicate all of these points in plain language:
8787
- that the view is read-only support access
8888
- when the access expires
8989
- If the grant expires or is revoked while support is navigating, the next request must deny access cleanly.
90+
- The service-health panel reports watcher liveness from the watcher's own last completed run, readable from any container serving the dashboard.
91+
- Payment recency is separate context on the panel and never stands in for watcher liveness; a watcher with no recorded run reads as needing attention, not as healthy.
9092

9193
## Data Model Requirements
9294
The owner record must persist enough state to determine whether support access is currently valid.

‎resources/views/support/dashboard.blade.php‎

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -175,22 +175,29 @@ class="w-72 max-w-full rounded-md border-gray-300 text-sm shadow-sm focus:border
175175

176176
{{-- Watcher health --}}
177177
<div class="px-6 py-5">
178-
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-slate-400">Watcher last seen</p>
179-
@if ($monitoring['last_payment_at'])
178+
<p class="text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-slate-400">Watcher</p>
179+
@if ($monitoring['watcher_last_ran_at'])
180180
<p class="mt-1 text-sm font-semibold
181181
{{ $monitoring['watcher_stale'] ? 'text-amber-600 dark:text-amber-400' : 'text-gray-900 dark:text-white' }}">
182-
{{ \Illuminate\Support\Carbon::parse($monitoring['last_payment_at'])->setTimezone(config('app.timezone'))->toDayDateTimeString() }}
182+
{{ $monitoring['watcher_last_ran_at']->setTimezone(config('app.timezone'))->toDayDateTimeString() }}
183183
</p>
184184
@if ($monitoring['watcher_stale'])
185185
<p class="mt-0.5 text-xs text-amber-600 dark:text-amber-400">
186-
No activity in over {{ $monitoring['stale_minutes'] }} minutes — worth checking.
186+
No completed run in over {{ $monitoring['stale_minutes'] }} minutes — worth checking.
187187
</p>
188188
@else
189-
<p class="mt-0.5 text-xs text-green-600 dark:text-green-400">recent</p>
189+
<p class="mt-0.5 text-xs text-green-600 dark:text-green-400">on schedule</p>
190190
@endif
191191
@else
192-
<p class="mt-1 text-sm text-gray-400 dark:text-slate-500">No on-chain payments recorded yet.</p>
192+
<p class="mt-1 text-xs text-amber-600 dark:text-amber-400">No completed run recorded yet — worth checking.</p>
193193
@endif
194+
<p class="mt-2 text-xs text-gray-500 dark:text-slate-400">
195+
@if ($monitoring['last_payment_at'])
196+
Last on-chain payment: {{ \Illuminate\Support\Carbon::parse($monitoring['last_payment_at'])->setTimezone(config('app.timezone'))->toDayDateTimeString() }}
197+
@else
198+
No on-chain payments recorded yet.
199+
@endif
200+
</p>
194201
</div>
195202

196203
</div>

‎tests/Feature/SupportMonitoringTest.php‎

Lines changed: 51 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
use App\Models\Invoice;
77
use App\Models\InvoicePayment;
88
use App\Models\User;
9+
use App\Support\WatcherLiveness;
910
use Illuminate\Foundation\Testing\RefreshDatabase;
11+
use Illuminate\Support\Facades\Cache;
1012
use Tests\TestCase;
1113

1214
class SupportMonitoringTest extends TestCase
@@ -110,52 +112,76 @@ public function test_failures_older_than_24_hours_are_excluded(): void
110112
$this->assertStringNotContainsString('old@example.com', $response->getContent());
111113
}
112114

113-
public function test_watcher_healthy_shows_recent_label(): void
115+
/**
116+
* #163: watcher liveness comes from the watcher's own run stamp, so a
117+
* quiet hour with no payments must not read as a stale watcher.
118+
*/
119+
public function test_watcher_reads_healthy_from_fresh_run_stamp_despite_no_payments(): void
114120
{
115121
$agent = $this->supportAgent();
116-
$issuer = User::factory()->create();
117-
$invoice = $this->makeInvoice($issuer);
118-
119-
InvoicePayment::create([
120-
'invoice_id' => $invoice->id,
121-
'txid' => 'tx-mon-health',
122-
'sats_received' => 10000,
123-
'is_adjustment' => false,
124-
'detected_at' => now()->subMinutes(10),
125-
'confirmed_at' => now()->subMinutes(9),
126-
'usd_rate' => 50000,
127-
'fiat_amount' => 5.00,
128-
]);
122+
WatcherLiveness::recordCompletedRun();
129123

130124
$this->actingAs($agent)
131125
->get(route('support.dashboard'))
132126
->assertOk()
133-
->assertSee('recent');
127+
->assertSee('on schedule')
128+
->assertDontSee('worth checking');
134129
}
135130

136-
public function test_watcher_staleness_flag_triggers_past_threshold(): void
131+
public function test_watcher_reads_stale_from_old_run_stamp_despite_recent_payment(): void
137132
{
138133
$agent = $this->supportAgent();
139134
config(['support.watcher_stale_minutes' => 30]);
140135

136+
Cache::forever(WatcherLiveness::CACHE_KEY, now()->subMinutes(65)->toIso8601String());
137+
141138
$issuer = User::factory()->create();
142139
$invoice = $this->makeInvoice($issuer);
143140

144141
InvoicePayment::create([
145142
'invoice_id' => $invoice->id,
146-
'txid' => 'tx-mon-stale',
143+
'txid' => 'tx-mon-dead-watcher',
147144
'sats_received' => 10000,
148145
'is_adjustment' => false,
149-
'detected_at' => now()->subMinutes(60),
150-
'confirmed_at' => now()->subMinutes(59),
146+
'detected_at' => now()->subMinutes(5),
147+
'confirmed_at' => now()->subMinutes(4),
151148
'usd_rate' => 50000,
152149
'fiat_amount' => 5.00,
153150
]);
154151

155152
$this->actingAs($agent)
156153
->get(route('support.dashboard'))
157154
->assertOk()
158-
->assertSee('No activity in over 30 minutes');
155+
->assertSee('No completed run in over 30 minutes');
156+
}
157+
158+
public function test_watcher_with_no_recorded_run_reads_as_needing_attention(): void
159+
{
160+
$agent = $this->supportAgent();
161+
162+
$this->actingAs($agent)
163+
->get(route('support.dashboard'))
164+
->assertOk()
165+
->assertSee('No completed run recorded yet');
166+
}
167+
168+
public function test_watch_command_records_a_completed_run_stamp(): void
169+
{
170+
$this->assertNull(WatcherLiveness::lastCompletedRunAt());
171+
172+
$this->artisan('wallet:watch-payments')->assertExitCode(0);
173+
174+
$this->assertNotNull(WatcherLiveness::lastCompletedRunAt());
175+
}
176+
177+
public function test_single_invoice_run_does_not_record_a_completed_run_stamp(): void
178+
{
179+
$issuer = User::factory()->create();
180+
$invoice = $this->makeInvoice($issuer);
181+
182+
$this->artisan('wallet:watch-payments', ['--invoice' => $invoice->id])->assertExitCode(0);
183+
184+
$this->assertNull(WatcherLiveness::lastCompletedRunAt());
159185
}
160186

161187
public function test_adjustment_payments_excluded_from_watcher_health(): void
@@ -166,7 +192,7 @@ public function test_adjustment_payments_excluded_from_watcher_health(): void
166192
$issuer = User::factory()->create();
167193
$invoice = $this->makeInvoice($issuer);
168194

169-
// Only an adjustment — should not count as watcher activity
195+
// Only an adjustment — the payment-recency reading counts on-chain rows only
170196
InvoicePayment::create([
171197
'invoice_id' => $invoice->id,
172198
'txid' => 'tx-adj',
@@ -190,6 +216,8 @@ public function test_healthy_state_renders_all_three_indicators_cleanly(): void
190216
$issuer = User::factory()->create();
191217
$invoice = $this->makeInvoice($issuer);
192218

219+
WatcherLiveness::recordCompletedRun();
220+
193221
InvoicePayment::create([
194222
'invoice_id' => $invoice->id,
195223
'txid' => 'tx-mon-ok',
@@ -206,7 +234,8 @@ public function test_healthy_state_renders_all_three_indicators_cleanly(): void
206234
->assertOk()
207235
->assertSee('Service Health')
208236
->assertSee('no failures')
209-
->assertSee('recent');
237+
->assertSee('on schedule')
238+
->assertSee('Last on-chain payment');
210239
}
211240

212241
public function test_no_payments_recorded_yet_renders_cleanly(): void

0 commit comments

Comments
 (0)