Skip to content

Commit df1928c

Browse files
Surface run repair-needed age on dw system:operator-metrics
Adds the `runs.oldest_repair_needed_at` and `runs.max_repair_needed_age_ms` keys on the cli's operator-metrics output schema and renders the worst-case stuck-workflow age rows on `dw system:operator-metrics`. The two keys are the canonical stuck-workflow duplicate-risk age indicator paired with the `durable_resume_paths` health check; their absence from the `--json` contract previously narrowed what HTTP-only fleet operators could read from the metric alone. `OperatorMetricsCommand::renderRuns()` emits "Oldest repair-needed age:" and "Oldest repair-needed at:" rows directly under the existing `Repair needed:` count when the snapshot reports them, omitting both rows gracefully when the snapshot predates the contract. `schemas/output/operator-metrics.schema.json` pins `runs.oldest_repair_needed_at` (`["string","null"]`) and `runs.max_repair_needed_age_ms` (`["integer","null"]`). New tests: - `test_operator_metrics_schema_pins_runs_repair_needed_age_keys` - `test_operator_metrics_command_omits_runs_repair_needed_age_when_snapshot_predates_contract` - render-smoke assertions for the two new rows in `test_operator_metrics_command_renders_rollout_safety_signals` Full `SystemCommandTest` 46/46 green; full cli suite 548/548 green. Public Boundary scan exit 0.
1 parent 97ecc0c commit df1928c

3 files changed

Lines changed: 51 additions & 0 deletions

File tree

schemas/output/operator-metrics.schema.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
"type": "object",
1515
"properties": {
1616
"repair_needed": { "type": ["integer", "null"] },
17+
"oldest_repair_needed_at": { "type": ["string", "null"] },
18+
"max_repair_needed_age_ms": { "type": ["integer", "null"] },
1719
"claim_failed": { "type": ["integer", "null"] },
1820
"compatibility_blocked": { "type": ["integer", "null"] },
1921
"waiting": { "type": ["integer", "null"] },

src/Commands/SystemCommand/OperatorMetricsCommand.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,15 @@ private function renderRuns(OutputInterface $output, array $runs): void
9898
{
9999
$output->writeln('<info>Runs</info>');
100100
$output->writeln(sprintf(' Repair needed: %d', (int) ($runs['repair_needed'] ?? 0)));
101+
if (array_key_exists('max_repair_needed_age_ms', $runs)) {
102+
$output->writeln(sprintf(
103+
' Oldest repair-needed age: %d ms',
104+
(int) ($runs['max_repair_needed_age_ms'] ?? 0),
105+
));
106+
}
107+
if (is_string($runs['oldest_repair_needed_at'] ?? null)) {
108+
$output->writeln(sprintf(' Oldest repair-needed at: %s', $runs['oldest_repair_needed_at']));
109+
}
101110
$output->writeln(sprintf(' Claim failed: %d', (int) ($runs['claim_failed'] ?? 0)));
102111
$output->writeln(sprintf(' Compatibility blocked: %d', (int) ($runs['compatibility_blocked'] ?? 0)));
103112
if (array_key_exists('waiting', $runs)) {

tests/Commands/SystemCommandTest.php

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,8 @@ public function test_operator_metrics_command_renders_rollout_safety_signals():
505505

506506
self::assertStringContainsString('Runs', $display);
507507
self::assertStringContainsString('Repair needed: 4', $display);
508+
self::assertStringContainsString('Oldest repair-needed age: 375000 ms', $display);
509+
self::assertStringContainsString('Oldest repair-needed at: 2026-04-24T11:23:45Z', $display);
508510
self::assertStringContainsString('Claim failed: 2', $display);
509511
self::assertStringContainsString('Compatibility blocked: 1', $display);
510512
self::assertStringContainsString('Waiting (durable resume): 6', $display);
@@ -759,6 +761,42 @@ public function test_operator_metrics_schema_pins_run_wait_age_keys(): void
759761
self::assertSame(['integer', 'null'], $runs['max_wait_age_ms']['type']);
760762
}
761763

764+
public function test_operator_metrics_schema_pins_runs_repair_needed_age_keys(): void
765+
{
766+
$schema = json_decode(
767+
(string) file_get_contents(__DIR__.'/../../schemas/output/operator-metrics.schema.json'),
768+
true,
769+
flags: JSON_THROW_ON_ERROR,
770+
);
771+
772+
$runs = $schema['properties']['operator_metrics']['properties']['runs']['properties'];
773+
774+
self::assertSame(['integer', 'null'], $runs['repair_needed']['type']);
775+
self::assertSame(['string', 'null'], $runs['oldest_repair_needed_at']['type']);
776+
self::assertSame(['integer', 'null'], $runs['max_repair_needed_age_ms']['type']);
777+
}
778+
779+
public function test_operator_metrics_command_omits_runs_repair_needed_age_when_snapshot_predates_contract(): void
780+
{
781+
$payload = self::operatorMetricsPayload();
782+
unset(
783+
$payload['operator_metrics']['runs']['oldest_repair_needed_at'],
784+
$payload['operator_metrics']['runs']['max_repair_needed_age_ms'],
785+
);
786+
787+
$command = new OperatorMetricsCommand();
788+
$command->setServerClient(new SystemFakeClient($payload));
789+
790+
$tester = new CommandTester($command);
791+
self::assertSame(Command::SUCCESS, $tester->execute([]));
792+
793+
$display = $tester->getDisplay();
794+
795+
self::assertStringNotContainsString('Oldest repair-needed age', $display);
796+
self::assertStringNotContainsString('Oldest repair-needed at', $display);
797+
self::assertStringContainsString('Repair needed: 4', $display);
798+
}
799+
762800
public function test_operator_metrics_schema_pins_matching_role_keys(): void
763801
{
764802
$schema = json_decode(
@@ -1038,6 +1076,8 @@ private static function operatorMetricsPayload(): array
10381076
'generated_at' => '2026-04-24T11:30:00Z',
10391077
'runs' => [
10401078
'repair_needed' => 4,
1079+
'oldest_repair_needed_at' => '2026-04-24T11:23:45Z',
1080+
'max_repair_needed_age_ms' => 375000,
10411081
'claim_failed' => 2,
10421082
'compatibility_blocked' => 1,
10431083
'waiting' => 6,

0 commit comments

Comments
 (0)