feat: implement operations UI operator actions regression gate
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 3m44s
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 3m44s
This commit is contained in:
parent
3ce1cae71e
commit
2a856d2693
@ -21,9 +21,9 @@
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\Operations\OperationLifecyclePolicy;
|
||||
use App\Support\OpsUx\OperationRunProgressContract;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\Operations\OperationLifecyclePolicy;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDefaults;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
@ -305,10 +305,10 @@ public function landingHierarchySummary(): array
|
||||
return [
|
||||
'scope_label' => $operateHubShell->scopeLabel(request()),
|
||||
'scope_body' => $filteredTenant instanceof ManagedEnvironment
|
||||
? 'Operations Hub is workspace-scoped and filtered by an explicit environment filter.'
|
||||
? 'Filtered to one environment in this workspace.'
|
||||
: ($activeEnvironment instanceof ManagedEnvironment
|
||||
? 'Operations Hub is currently narrowed to one environment inside the active workspace.'
|
||||
: 'Operations Hub is showing workspace-wide execution records across all entitled environments.'),
|
||||
? 'Showing the active environment inside this workspace.'
|
||||
: 'Showing workspace-wide execution records across entitled environments.'),
|
||||
'return_label' => $returnLabel,
|
||||
'return_body' => $returnBody,
|
||||
'scope_reset_label' => $activeEnvironment instanceof ManagedEnvironment ? __('localization.shell.show_all_environments') : null,
|
||||
@ -445,6 +445,8 @@ private function workbenchOperationPayload(OperationRun $run, bool $hasAttention
|
||||
{
|
||||
$progress = OperationRunProgressContract::forRun($run);
|
||||
$decisionTruth = OperationUxPresenter::decisionZoneTruth($run);
|
||||
$actionDecision = OperationRunResource::actionDecision($run);
|
||||
$primaryAction = is_array($actionDecision['primary_action'] ?? null) ? $actionDecision['primary_action'] : null;
|
||||
$tenant = $run->tenant;
|
||||
|
||||
return [
|
||||
@ -461,9 +463,11 @@ private function workbenchOperationPayload(OperationRun $run, bool $hasAttention
|
||||
'environment' => $tenant instanceof ManagedEnvironment ? (string) $tenant->name : 'Workspace-level operation',
|
||||
'timing' => $this->operationTiming($run),
|
||||
'proof_label' => 'Operation detail available',
|
||||
'proof_body' => 'Open operation for stored proof, related links, and authorized diagnostics. Artifact or evidence links are unavailable here unless the detail surface proves them.',
|
||||
'primary_action_label' => OperationRunLinks::openLabel(),
|
||||
'primary_action_url' => OperationRunLinks::tenantlessView($run),
|
||||
'proof_body' => (string) ($actionDecision['attention_reason'] ?? 'Open operation for stored proof, related links, and authorized diagnostics.'),
|
||||
'primary_action_label' => is_string($primaryAction['label'] ?? null)
|
||||
? (string) $primaryAction['label']
|
||||
: OperationRunLinks::openLabel(),
|
||||
'primary_action_url' => OperationRunResource::primaryActionUrl($run),
|
||||
'progress' => $progress,
|
||||
'progress_label' => is_string($progress['label'] ?? null) ? $progress['label'] : null,
|
||||
'show_progress_bar' => ($progress['display'] ?? null) === OperationRunProgressContract::COUNTED,
|
||||
@ -609,7 +613,7 @@ public function table(Table $table): Table
|
||||
$allowedTenantIds = $this->allowedTenantIdsForWorkspaceScope($workspaceId);
|
||||
|
||||
$query = OperationRun::query()
|
||||
->with('user')
|
||||
->with(['tenant', 'user'])
|
||||
->latest('id')
|
||||
->when(
|
||||
$workspaceId,
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Services\Baselines\BaselineEvidenceCaptureResumeService;
|
||||
use App\Services\Operations\OperationRunOperatorActionService;
|
||||
use App\Services\Tenants\TenantOperabilityService;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
@ -21,6 +22,7 @@
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\Operations\OperationRunActionEligibility;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\OpsUx\RunDetailPolling;
|
||||
@ -150,6 +152,12 @@ protected function getHeaderActions(): array
|
||||
return $actions;
|
||||
}
|
||||
|
||||
$primaryAction = $this->primaryOperationAction();
|
||||
|
||||
if ($primaryAction instanceof Action) {
|
||||
$actions[] = $primaryAction;
|
||||
}
|
||||
|
||||
$related = $this->relatedLinks();
|
||||
|
||||
$relatedActions = [];
|
||||
@ -181,6 +189,85 @@ protected function getHeaderActions(): array
|
||||
return $actions;
|
||||
}
|
||||
|
||||
private function primaryOperationAction(): ?Action
|
||||
{
|
||||
$decision = $this->operationActionDecision();
|
||||
$primary = is_array($decision['primary_action'] ?? null) ? $decision['primary_action'] : null;
|
||||
|
||||
if (! is_array($primary)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$key = (string) ($primary['key'] ?? '');
|
||||
|
||||
if ($key === '' || $key === 'view_details') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($key === 'reconcile') {
|
||||
return $this->reconcileOperationRunAction($primary);
|
||||
}
|
||||
|
||||
$url = $primary['url'] ?? null;
|
||||
|
||||
if (! is_string($url) || trim($url) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Action::make('primary_'.$key)
|
||||
->label((string) ($primary['label'] ?? OperationRunLinks::openLabel()))
|
||||
->icon(is_string($primary['icon'] ?? null) ? (string) $primary['icon'] : 'heroicon-o-arrow-top-right-on-square')
|
||||
->color(is_string($primary['color'] ?? null) ? (string) $primary['color'] : 'primary')
|
||||
->url($url);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $primary
|
||||
*/
|
||||
private function reconcileOperationRunAction(array $primary): Action
|
||||
{
|
||||
return Action::make('reconcileOperationRun')
|
||||
->label((string) ($primary['label'] ?? __('localization.operations.actions.reconcile')))
|
||||
->icon(is_string($primary['icon'] ?? null) ? (string) $primary['icon'] : 'heroicon-o-wrench-screwdriver')
|
||||
->color(is_string($primary['color'] ?? null) ? (string) $primary['color'] : 'warning')
|
||||
->requiresConfirmation()
|
||||
->modalHeading((string) ($primary['modal_heading'] ?? __('localization.operations.actions.reconcile_heading')))
|
||||
->modalDescription((string) ($primary['modal_description'] ?? __('localization.operations.actions.reconcile_description')))
|
||||
->modalSubmitActionLabel(__('localization.operations.actions.reconcile_submit'))
|
||||
->action(function (): void {
|
||||
$this->reconcileOperationRun();
|
||||
});
|
||||
}
|
||||
|
||||
private function reconcileOperationRun(): void
|
||||
{
|
||||
$user = $this->resolveViewerActor();
|
||||
|
||||
$result = app(OperationRunOperatorActionService::class)->reconcile($this->run, $user);
|
||||
|
||||
$freshRun = $this->run->fresh(['workspace', 'tenant', 'user']);
|
||||
|
||||
if ($freshRun instanceof OperationRun) {
|
||||
$this->run = $freshRun;
|
||||
}
|
||||
|
||||
if (($result['applied'] ?? false) !== true) {
|
||||
Notification::make()
|
||||
->title(__('localization.operations.actions.reconcile_noop_title'))
|
||||
->body(__('localization.operations.actions.reconcile_noop_body'))
|
||||
->warning()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Notification::make()
|
||||
->title(__('localization.operations.actions.reconcile_success_title'))
|
||||
->body(__('localization.operations.actions.reconcile_success_body'))
|
||||
->success()
|
||||
->send();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* scope_label: string,
|
||||
@ -213,7 +300,15 @@ public function monitoringDetailSummary(): array
|
||||
? 'Open keeps secondary drilldowns grouped under one control when downstream context exists.'
|
||||
: 'Open keeps secondary drilldowns grouped under one control: '.implode(', ', $relatedLabels).'.';
|
||||
|
||||
$followUpLabel = $this->canResumeCapture() ? 'Resume capture' : null;
|
||||
$actionDecision = $this->operationActionDecision();
|
||||
$primaryAction = $actionDecision['primary_action'] ?? null;
|
||||
$hasOperatorFollowUp = is_array($primaryAction)
|
||||
&& ($primaryAction['key'] ?? null) !== 'view_details'
|
||||
&& is_string($primaryAction['label'] ?? null);
|
||||
$canResumeCapture = $this->canResumeCapture();
|
||||
$followUpLabel = $canResumeCapture
|
||||
? 'Resume capture'
|
||||
: ($hasOperatorFollowUp ? (string) $primaryAction['label'] : null);
|
||||
|
||||
return [
|
||||
'scope_label' => $operateHubShell->scopeLabel(request()),
|
||||
@ -222,13 +317,23 @@ public function monitoringDetailSummary(): array
|
||||
'navigation_body' => $navigationBody,
|
||||
'utility_body' => 'Refresh keeps the current run state accurate without changing scope.',
|
||||
'related_body' => $relatedBody,
|
||||
'follow_up_body' => $followUpLabel !== null
|
||||
? 'Resume capture only appears when this run supports additional evidence collection.'
|
||||
: 'No run-specific follow-up is currently available.',
|
||||
'follow_up_body' => match (true) {
|
||||
$canResumeCapture => 'Resume capture only appears when this run supports additional evidence collection.',
|
||||
$hasOperatorFollowUp => (string) ($actionDecision['attention_reason'] ?? 'Run-specific follow-up is available from the header.'),
|
||||
default => 'No run-specific follow-up is currently available.',
|
||||
},
|
||||
'follow_up_label' => $followUpLabel,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function operationActionDecision(): array
|
||||
{
|
||||
return app(OperationRunActionEligibility::class)->forRun($this->run, $this->resolveViewerActor());
|
||||
}
|
||||
|
||||
private function openSupportDiagnosticsAction(): Action
|
||||
{
|
||||
$action = Action::make('openSupportDiagnostics')
|
||||
|
||||
@ -28,6 +28,7 @@
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\Operations\OperationRunActionEligibility;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\RunDurationInsights;
|
||||
use App\Support\OpsUx\SummaryCountsNormalizer;
|
||||
@ -55,6 +56,7 @@
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Str;
|
||||
use UnitEnum;
|
||||
|
||||
class OperationRunResource extends Resource
|
||||
@ -106,7 +108,7 @@ public static function getEloquentQuery(): Builder
|
||||
$workspaceId = app(WorkspaceContext::class)->currentWorkspaceId();
|
||||
|
||||
return parent::getEloquentQuery()
|
||||
->with('user')
|
||||
->with(['tenant', 'user'])
|
||||
->latest('id')
|
||||
->when($workspaceId, fn (Builder $query) => $query->where('workspace_id', (int) $workspaceId))
|
||||
->when(! $workspaceId, fn (Builder $query) => $query->whereRaw('1 = 0'));
|
||||
@ -145,18 +147,33 @@ public static function table(Table $table): Table
|
||||
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->color)
|
||||
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->icon)
|
||||
->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->iconColor)
|
||||
->description(fn (OperationRun $record): ?string => static::lifecycleAttentionSummary($record)),
|
||||
->description(fn (OperationRun $record): ?string => static::historyStatusDescription($record))
|
||||
->visibleFrom('xl')
|
||||
->width('10rem'),
|
||||
Tables\Columns\TextColumn::make('type')
|
||||
->label('Operation')
|
||||
->formatStateUsing(fn (?string $state): string => OperationCatalog::label((string) $state))
|
||||
->description(fn (OperationRun $record): string => static::historyOperationContext($record))
|
||||
->lineClamp(1)
|
||||
->width('18rem')
|
||||
->wrap()
|
||||
->searchable()
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('scope')
|
||||
->label('Scope')
|
||||
->getStateUsing(fn (OperationRun $record): string => static::targetScopeDisplay($record) ?? 'Workspace-level operation')
|
||||
->wrap()
|
||||
->visibleFrom('2xl')
|
||||
->toggleable(),
|
||||
Tables\Columns\TextColumn::make('initiator_name')
|
||||
->label('Initiator')
|
||||
->visibleFrom('2xl')
|
||||
->searchable(),
|
||||
Tables\Columns\TextColumn::make('created_at')
|
||||
->label('Started')
|
||||
->since()
|
||||
->visibleFrom('xl')
|
||||
->width('8rem')
|
||||
->sortable(),
|
||||
Tables\Columns\TextColumn::make('duration')
|
||||
->getStateUsing(function (OperationRun $record): string {
|
||||
@ -165,14 +182,26 @@ public static function table(Table $table): Table
|
||||
}
|
||||
|
||||
return '—';
|
||||
}),
|
||||
})
|
||||
->visibleFrom('2xl')
|
||||
->width('7rem'),
|
||||
Tables\Columns\TextColumn::make('outcome')
|
||||
->badge()
|
||||
->formatStateUsing(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->label)
|
||||
->color(fn (mixed $state, OperationRun $record): string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->color)
|
||||
->icon(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->icon)
|
||||
->iconColor(fn (mixed $state, OperationRun $record): ?string => BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, static::outcomeBadgeState($record))->iconColor)
|
||||
->description(fn (OperationRun $record): ?string => static::surfaceGuidance($record)),
|
||||
->description(fn (OperationRun $record): ?string => static::historyOutcomeDescription($record))
|
||||
->width('14rem'),
|
||||
Tables\Columns\TextColumn::make('next_action')
|
||||
->label('Next action')
|
||||
->getStateUsing(fn (OperationRun $record): string => static::primaryActionLabel($record))
|
||||
->description(fn (OperationRun $record): ?string => static::historyActionDescription($record))
|
||||
->icon(fn (OperationRun $record): ?string => data_get(static::actionDecision($record), 'primary_action.icon'))
|
||||
->color(fn (OperationRun $record): string => (string) (data_get(static::actionDecision($record), 'primary_action.color') ?: 'gray'))
|
||||
->url(fn (OperationRun $record): string => static::primaryActionUrl($record))
|
||||
->width('14rem')
|
||||
->wrap(),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\SelectFilter::make('managed_environment_id')
|
||||
@ -704,6 +733,21 @@ private static function resolvePrimaryNextStep(
|
||||
?OperatorExplanationPattern $operatorExplanation,
|
||||
): array {
|
||||
$candidates = [];
|
||||
$actionDecision = static::actionDecision($record);
|
||||
$primaryAction = is_array($actionDecision['primary_action'] ?? null) ? $actionDecision['primary_action'] : null;
|
||||
|
||||
if (static::shouldPromotePrimaryActionToNextStep($primaryAction)) {
|
||||
$actionText = trim((string) ($primaryAction['label'] ?? ''));
|
||||
$attentionReason = trim((string) ($actionDecision['attention_reason'] ?? ''));
|
||||
|
||||
if ($actionText !== '') {
|
||||
static::pushNextStepCandidate(
|
||||
$candidates,
|
||||
$attentionReason !== '' ? $actionText.'. '.$attentionReason : $actionText,
|
||||
'operator_action',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
static::pushNextStepCandidate($candidates, $operatorExplanation?->nextActionText, 'operator_explanation');
|
||||
static::pushNextStepCandidate($candidates, $artifactTruth?->nextStepText(), 'artifact_truth');
|
||||
@ -745,6 +789,23 @@ private static function resolvePrimaryNextStep(
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $primaryAction
|
||||
*/
|
||||
private static function shouldPromotePrimaryActionToNextStep(?array $primaryAction): bool
|
||||
{
|
||||
return is_array($primaryAction)
|
||||
&& in_array((string) ($primaryAction['key'] ?? ''), [
|
||||
'reconcile',
|
||||
'view_review',
|
||||
'view_evidence',
|
||||
'view_report',
|
||||
'view_restore_details',
|
||||
'view_backup_details',
|
||||
'view_affected_families',
|
||||
], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{text: string, source: string, normalized: string}> $candidates
|
||||
*/
|
||||
@ -805,6 +866,7 @@ private static function guidanceLabel(string $source): string
|
||||
'artifact_truth' => 'Artifact guidance',
|
||||
'blocked_reason' => 'Blocked prerequisite',
|
||||
'lifecycle_attention' => 'Lifecycle guidance',
|
||||
'operator_action' => 'Safe operator action',
|
||||
default => 'General guidance',
|
||||
};
|
||||
}
|
||||
@ -849,6 +911,10 @@ private static function decisionFacts(
|
||||
mixed $restoreContinuation,
|
||||
?\App\Support\OpsUx\GovernanceRunDiagnosticSummary $diagnosticSummary,
|
||||
): array {
|
||||
$actionDecision = static::actionDecision($record);
|
||||
$primaryActionLabel = static::primaryActionLabel($record);
|
||||
$disabledReason = static::primaryDisabledReason($record);
|
||||
|
||||
if (! $diagnosticSummary instanceof \App\Support\OpsUx\GovernanceRunDiagnosticSummary) {
|
||||
return array_values(array_filter([
|
||||
$factory->keyFact(
|
||||
@ -861,6 +927,15 @@ private static function decisionFacts(
|
||||
$outcomeSpec->label,
|
||||
badge: $factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor),
|
||||
),
|
||||
$factory->keyFact(
|
||||
'Safe next action',
|
||||
$primaryActionLabel,
|
||||
(string) ($actionDecision['attention_reason'] ?? 'Action availability is derived from stored run truth.'),
|
||||
tone: ($actionDecision['high_risk'] ?? false) ? 'warning' : null,
|
||||
),
|
||||
$disabledReason !== null
|
||||
? $factory->keyFact('Unavailable action', $disabledReason)
|
||||
: null,
|
||||
static::artifactTruthFact($factory, $artifactTruth),
|
||||
$operatorExplanation instanceof OperatorExplanationPattern
|
||||
? $factory->keyFact(
|
||||
@ -900,6 +975,15 @@ private static function decisionFacts(
|
||||
$diagnosticSummary->executionOutcomeLabel,
|
||||
badge: $factory->statusBadge($outcomeSpec->label, $outcomeSpec->color, $outcomeSpec->icon, $outcomeSpec->iconColor),
|
||||
),
|
||||
$factory->keyFact(
|
||||
'Safe next action',
|
||||
$primaryActionLabel,
|
||||
(string) ($actionDecision['attention_reason'] ?? 'Action availability is derived from stored run truth.'),
|
||||
tone: ($actionDecision['high_risk'] ?? false) ? 'warning' : null,
|
||||
),
|
||||
$disabledReason !== null
|
||||
? $factory->keyFact('Unavailable action', $disabledReason)
|
||||
: null,
|
||||
static::artifactTruthFact(
|
||||
$factory,
|
||||
$artifactTruth,
|
||||
@ -1327,6 +1411,150 @@ private static function verificationReportViewData(OperationRun $record): array
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function actionDecision(OperationRun $record): array
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
return app(OperationRunActionEligibility::class)->forRun(
|
||||
$record,
|
||||
$user instanceof User ? $user : null,
|
||||
);
|
||||
}
|
||||
|
||||
public static function primaryActionLabel(OperationRun $record): string
|
||||
{
|
||||
$primaryAction = static::actionDecision($record)['primary_action'] ?? null;
|
||||
|
||||
if (is_array($primaryAction) && is_string($primaryAction['label'] ?? null) && trim((string) $primaryAction['label']) !== '') {
|
||||
return trim((string) $primaryAction['label']);
|
||||
}
|
||||
|
||||
return __('localization.operations.actions.no_safe_action');
|
||||
}
|
||||
|
||||
public static function primaryActionUrl(OperationRun $record): string
|
||||
{
|
||||
$primaryAction = static::actionDecision($record)['primary_action'] ?? null;
|
||||
$url = is_array($primaryAction) && is_string($primaryAction['url'] ?? null)
|
||||
? trim((string) $primaryAction['url'])
|
||||
: '';
|
||||
|
||||
return $url !== '' ? $url : OperationRunLinks::tenantlessView($record);
|
||||
}
|
||||
|
||||
private static function primaryDisabledReason(OperationRun $record): ?string
|
||||
{
|
||||
$decision = static::actionDecision($record);
|
||||
$disabledReasons = is_array($decision['disabled_reasons'] ?? null) ? $decision['disabled_reasons'] : [];
|
||||
|
||||
foreach (['retry', 'reconcile'] as $key) {
|
||||
if (is_string($disabledReasons[$key] ?? null) && trim((string) $disabledReasons[$key]) !== '') {
|
||||
return trim((string) $disabledReasons[$key]);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static function historyStatusDescription(OperationRun $record): ?string
|
||||
{
|
||||
$freshness = static::freshnessLabel($record);
|
||||
|
||||
if ($freshness !== null) {
|
||||
return $freshness;
|
||||
}
|
||||
|
||||
return static::lifecycleAttentionSummary($record);
|
||||
}
|
||||
|
||||
private static function historyOperationContext(OperationRun $record): string
|
||||
{
|
||||
$status = BadgeRenderer::spec(BadgeDomain::OperationRunStatus, static::statusBadgeState($record))->label;
|
||||
$scope = static::compactTargetScopeDisplay($record);
|
||||
$started = $record->created_at?->diffForHumans() ?? 'Timing unavailable';
|
||||
|
||||
return implode(' · ', array_values(array_filter([
|
||||
$status,
|
||||
$scope,
|
||||
$started,
|
||||
], static fn (?string $part): bool => $part !== null && $part !== '')));
|
||||
}
|
||||
|
||||
private static function historyOutcomeDescription(OperationRun $record): ?string
|
||||
{
|
||||
if ($record->isCurrentlyActive()) {
|
||||
return 'Execution in progress';
|
||||
}
|
||||
|
||||
if ($record->isLifecycleReconciled()) {
|
||||
return 'Reconciled truth';
|
||||
}
|
||||
|
||||
return match ((string) $record->outcome) {
|
||||
OperationRunOutcome::Succeeded->value => static::hasFollowUpPrimaryAction($record)
|
||||
? 'Execution complete; follow-up separate'
|
||||
: 'Execution complete',
|
||||
OperationRunOutcome::PartiallySucceeded->value => 'Partial result',
|
||||
OperationRunOutcome::Failed->value => 'Needs failure review',
|
||||
OperationRunOutcome::Blocked->value => 'Blocked prerequisite',
|
||||
default => static::surfaceGuidance($record),
|
||||
};
|
||||
}
|
||||
|
||||
private static function historyActionDescription(OperationRun $record): ?string
|
||||
{
|
||||
$decision = static::actionDecision($record);
|
||||
$primaryAction = is_array($decision['primary_action'] ?? null) ? $decision['primary_action'] : null;
|
||||
$key = is_string($primaryAction['key'] ?? null) ? (string) $primaryAction['key'] : '';
|
||||
|
||||
return match ($key) {
|
||||
'reconcile' => 'Metadata-only repair',
|
||||
'view_review' => 'Review proof',
|
||||
'view_evidence' => 'Evidence proof',
|
||||
'view_report' => 'Report proof',
|
||||
'view_backup_details' => 'Backup proof',
|
||||
'view_restore_details' => ($decision['high_risk'] ?? false) ? 'Inspect only' : 'Restore proof',
|
||||
'view_affected_families' => 'Coverage proof',
|
||||
'view_details' => $record->requiresOperatorReview() ? 'Run proof' : null,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private static function hasFollowUpPrimaryAction(OperationRun $record): bool
|
||||
{
|
||||
$primaryAction = static::actionDecision($record)['primary_action'] ?? null;
|
||||
$key = is_array($primaryAction) && is_string($primaryAction['key'] ?? null)
|
||||
? (string) $primaryAction['key']
|
||||
: '';
|
||||
|
||||
return in_array($key, [
|
||||
'reconcile',
|
||||
'view_review',
|
||||
'view_evidence',
|
||||
'view_report',
|
||||
'view_restore_details',
|
||||
'view_backup_details',
|
||||
'view_affected_families',
|
||||
], true);
|
||||
}
|
||||
|
||||
private static function compactTargetScopeDisplay(OperationRun $record): string
|
||||
{
|
||||
$tenant = $record->relationLoaded('tenant') ? $record->tenant : null;
|
||||
$scope = $tenant instanceof ManagedEnvironment && trim((string) $tenant->name) !== ''
|
||||
? (string) $tenant->name
|
||||
: static::targetScopeDisplay($record);
|
||||
|
||||
if ($scope === null || $scope === '') {
|
||||
$scope = static::targetScopeDisplay($record);
|
||||
}
|
||||
|
||||
return Str::limit($scope ?: 'Workspace-level operation', 42);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
|
||||
@ -2,13 +2,14 @@
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Models\WorkspaceMembership;
|
||||
use App\Services\Auth\ManagedEnvironmentAccessScopeResolver;
|
||||
use App\Support\Operations\OperationRunCapabilityResolver;
|
||||
use App\Support\Operations\Reconciliation\OperationRunReconciliationRegistry;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||
use Illuminate\Auth\Access\Response;
|
||||
@ -100,4 +101,57 @@ public function view(User $user, OperationRun $run): Response|bool
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function reconcile(User $user, OperationRun $run): Response|bool
|
||||
{
|
||||
$view = $this->view($user, $run);
|
||||
|
||||
if ($view instanceof Response && $view->denied()) {
|
||||
return $view;
|
||||
}
|
||||
|
||||
if ($view === false) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
if (app(OperationRunReconciliationRegistry::class)->forType($run->canonicalOperationType()) === null) {
|
||||
return Response::deny('Operation type does not support reconciliation.');
|
||||
}
|
||||
|
||||
$requiredCapability = app(OperationRunCapabilityResolver::class)
|
||||
->requiredExecutionCapabilityForType((string) $run->type);
|
||||
|
||||
if (! is_string($requiredCapability) || $requiredCapability === '') {
|
||||
return Response::deny('Operation type has no reconcile capability.');
|
||||
}
|
||||
|
||||
$workspaceId = (int) ($run->workspace_id ?? 0);
|
||||
$tenantId = (int) ($run->managed_environment_id ?? 0);
|
||||
|
||||
if (str_starts_with($requiredCapability, 'workspace')) {
|
||||
$workspace = Workspace::query()->whereKey($workspaceId)->first();
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
return Gate::forUser($user)->allows($requiredCapability, $workspace)
|
||||
? Response::allow()
|
||||
: Response::deny();
|
||||
}
|
||||
|
||||
if ($tenantId <= 0) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
$tenant = ManagedEnvironment::query()->withTrashed()->whereKey($tenantId)->first();
|
||||
|
||||
if (! $tenant instanceof ManagedEnvironment || (int) $tenant->workspace_id !== $workspaceId) {
|
||||
return Response::denyAsNotFound();
|
||||
}
|
||||
|
||||
return Gate::forUser($user)->allows($requiredCapability, $tenant)
|
||||
? Response::allow()
|
||||
: Response::deny();
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,196 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\Operations;
|
||||
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\AdapterRunReconciler;
|
||||
use App\Services\Audit\AuditRecorder;
|
||||
use App\Support\Audit\AuditActorSnapshot;
|
||||
use App\Support\Audit\AuditTargetSnapshot;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\Operations\OperationRunActionEligibility;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
|
||||
final class OperationRunOperatorActionService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AdapterRunReconciler $reconciler,
|
||||
private readonly OperationRunActionEligibility $eligibility,
|
||||
private readonly AuditRecorder $auditRecorder,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function reconcile(OperationRun $run, User $actor): array
|
||||
{
|
||||
$run->loadMissing(['workspace', 'tenant']);
|
||||
|
||||
$authorization = Gate::forUser($actor)->inspect('reconcile', $run);
|
||||
|
||||
if ($authorization->denied()) {
|
||||
$this->recordAction(
|
||||
run: $run,
|
||||
actor: $actor,
|
||||
action: 'operation.reconcile_denied',
|
||||
status: 'denied',
|
||||
reasonCode: 'policy_denied',
|
||||
before: $this->state($run),
|
||||
after: $this->state($run),
|
||||
);
|
||||
|
||||
abort($authorization->status() ?: 403);
|
||||
}
|
||||
|
||||
$decision = $this->eligibility->forRun($run, $actor);
|
||||
|
||||
if (! $this->hasEnabledAction($decision, 'reconcile')) {
|
||||
$this->recordAction(
|
||||
run: $run,
|
||||
actor: $actor,
|
||||
action: 'operation.reconcile_denied',
|
||||
status: 'denied',
|
||||
reasonCode: array_key_exists('reconcile', $decision['disabled_reasons'])
|
||||
? 'reconcile_unavailable'
|
||||
: 'reconcile_not_primary',
|
||||
before: $this->state($run),
|
||||
after: $this->state($run),
|
||||
);
|
||||
|
||||
abort(403);
|
||||
}
|
||||
|
||||
$before = $this->state($run);
|
||||
$change = $this->reconciler->reconcileOperationRun($run, false);
|
||||
|
||||
$run->refresh();
|
||||
$after = $this->state($run);
|
||||
|
||||
if (! is_array($change) || ($change['applied'] ?? false) !== true) {
|
||||
$this->recordAction(
|
||||
run: $run,
|
||||
actor: $actor,
|
||||
action: 'operation.reconcile_noop',
|
||||
status: 'warning',
|
||||
reasonCode: is_string($change['reason_code'] ?? null) ? (string) $change['reason_code'] : 'no_reconciliation_applied',
|
||||
before: $before,
|
||||
after: $after,
|
||||
);
|
||||
|
||||
return [
|
||||
'applied' => false,
|
||||
'reason_code' => is_string($change['reason_code'] ?? null) ? (string) $change['reason_code'] : 'no_reconciliation_applied',
|
||||
'change' => $change,
|
||||
];
|
||||
}
|
||||
|
||||
$this->recordAction(
|
||||
run: $run,
|
||||
actor: $actor,
|
||||
action: 'operation.reconciled_by_operator',
|
||||
status: 'success',
|
||||
reasonCode: is_string($change['reason_code'] ?? null) ? (string) $change['reason_code'] : 'operator_reconcile_applied',
|
||||
before: $before,
|
||||
after: $after,
|
||||
);
|
||||
|
||||
return [
|
||||
'applied' => true,
|
||||
'reason_code' => is_string($change['reason_code'] ?? null) ? (string) $change['reason_code'] : 'operator_reconcile_applied',
|
||||
'change' => $change,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $decision
|
||||
*/
|
||||
private function hasEnabledAction(array $decision, string $key): bool
|
||||
{
|
||||
$primary = $decision['primary_action'] ?? null;
|
||||
|
||||
if (is_array($primary) && ($primary['key'] ?? null) === $key) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach ($decision['secondary_actions'] ?? [] as $action) {
|
||||
if (is_array($action) && ($action['key'] ?? null) === $key) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{status:string,outcome:string}
|
||||
*/
|
||||
private function state(OperationRun $run): array
|
||||
{
|
||||
return [
|
||||
'status' => (string) $run->status,
|
||||
'outcome' => (string) $run->outcome,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{status:string,outcome:string} $before
|
||||
* @param array{status:string,outcome:string} $after
|
||||
*/
|
||||
private function recordAction(
|
||||
OperationRun $run,
|
||||
User $actor,
|
||||
string $action,
|
||||
string $status,
|
||||
string $reasonCode,
|
||||
array $before,
|
||||
array $after,
|
||||
): void {
|
||||
$workspace = $run->workspace instanceof Workspace ? $run->workspace : null;
|
||||
$tenant = $run->tenant instanceof ManagedEnvironment ? $run->tenant : null;
|
||||
|
||||
if (! $workspace instanceof Workspace) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->auditRecorder->record(
|
||||
action: $action,
|
||||
context: [
|
||||
'metadata' => [
|
||||
'operator_action' => 'reconcile',
|
||||
'operation_run_id' => (int) $run->getKey(),
|
||||
'workspace_id' => (int) $workspace->getKey(),
|
||||
'managed_environment_id' => $tenant instanceof ManagedEnvironment ? (int) $tenant->getKey() : null,
|
||||
'actor_user_id' => (int) $actor->getKey(),
|
||||
'operation_type' => OperationCatalog::canonicalCode((string) $run->type),
|
||||
'previous_status' => $before['status'],
|
||||
'previous_outcome' => $before['outcome'],
|
||||
'resulting_status' => $after['status'],
|
||||
'resulting_outcome' => $after['outcome'],
|
||||
'reason_code' => $reasonCode,
|
||||
'requested_at' => now()->toIso8601String(),
|
||||
'mutation_scope' => 'tenantpilot_operation_metadata_only',
|
||||
],
|
||||
],
|
||||
workspace: $workspace,
|
||||
tenant: $tenant,
|
||||
actor: AuditActorSnapshot::human($actor),
|
||||
target: new AuditTargetSnapshot(
|
||||
type: 'operation_run',
|
||||
id: (int) $run->getKey(),
|
||||
label: OperationCatalog::label((string) $run->type).' #'.$run->getKey(),
|
||||
),
|
||||
outcome: $status,
|
||||
summary: match ($status) {
|
||||
'success' => 'Operation run reconciled by operator',
|
||||
'warning' => 'Operation run reconcile action had no effect',
|
||||
default => 'Operation run reconcile action denied',
|
||||
},
|
||||
operationRunId: (int) $run->getKey(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,456 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Operations;
|
||||
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Services\Auth\WorkspaceCapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\OperationCatalog;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OperationRunType;
|
||||
use App\Support\Operations\Reconciliation\OperationRunReconciliationAdapter;
|
||||
use App\Support\Operations\Reconciliation\OperationRunReconciliationRegistry;
|
||||
use App\Support\Operations\Reconciliation\ReconciliationResult;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Throwable;
|
||||
|
||||
final class OperationRunActionEligibility
|
||||
{
|
||||
public function __construct(
|
||||
private readonly OperationRunReconciliationRegistry $reconciliationRegistry,
|
||||
private readonly OperationRunCapabilityResolver $capabilityResolver,
|
||||
private readonly CapabilityResolver $tenantCapabilities,
|
||||
private readonly WorkspaceCapabilityResolver $workspaceCapabilities,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @return array{
|
||||
* primary_action: array<string, mixed>|null,
|
||||
* secondary_actions: list<array<string, mixed>>,
|
||||
* disabled_actions: list<array<string, mixed>>,
|
||||
* disabled_reasons: array<string, string>,
|
||||
* attention_reason: string,
|
||||
* mutation_scope: ?string,
|
||||
* high_risk: bool,
|
||||
* freshness_state: string
|
||||
* }
|
||||
*/
|
||||
public function forRun(OperationRun $run, ?User $user = null): array
|
||||
{
|
||||
$run->loadMissing(['workspace', 'tenant']);
|
||||
|
||||
$canonicalType = $run->canonicalOperationType();
|
||||
$freshnessState = $run->freshnessState();
|
||||
$highRisk = $this->isHighRisk($canonicalType);
|
||||
$disabledReasons = [];
|
||||
$secondaryActions = [];
|
||||
|
||||
if ($user instanceof User && Gate::forUser($user)->inspect('view', $run)->denied()) {
|
||||
return $this->result(
|
||||
primaryAction: null,
|
||||
secondaryActions: [],
|
||||
disabledReasons: [
|
||||
'view_details' => __('localization.operations.actions.disabled.scope_unavailable'),
|
||||
'reconcile' => __('localization.operations.actions.disabled.scope_unavailable'),
|
||||
'retry' => __('localization.operations.actions.disabled.scope_unavailable'),
|
||||
],
|
||||
attentionReason: __('localization.operations.actions.attention.scope_unavailable'),
|
||||
mutationScope: null,
|
||||
highRisk: $highRisk,
|
||||
freshnessState: $freshnessState->value,
|
||||
);
|
||||
}
|
||||
|
||||
$relatedPrimary = $this->primaryRelatedAction($run);
|
||||
$canReconcile = $this->canOfferReconcile($run, $user);
|
||||
$reconcileAction = $this->reconcileAction($run);
|
||||
|
||||
if (! $canReconcile) {
|
||||
$disabledReasons['reconcile'] = $this->reconcileDisabledReason($run, $user);
|
||||
}
|
||||
|
||||
$retryReason = $this->retryDisabledReason($run, $highRisk);
|
||||
$disabledReasons['retry'] = $retryReason;
|
||||
|
||||
if ($this->canViewDiagnostics($run, $user)) {
|
||||
$secondaryActions[] = $this->diagnosticsAction();
|
||||
} else {
|
||||
$disabledReasons['open_support_diagnostics'] = __('localization.operations.actions.disabled.missing_diagnostics_capability');
|
||||
}
|
||||
|
||||
$primaryAction = match (true) {
|
||||
$canReconcile && ! $highRisk => $reconcileAction,
|
||||
$relatedPrimary !== null => $relatedPrimary,
|
||||
default => $this->viewDetailsAction($run),
|
||||
};
|
||||
|
||||
if ($canReconcile && $primaryAction['key'] !== 'reconcile') {
|
||||
array_unshift($secondaryActions, $reconcileAction);
|
||||
}
|
||||
|
||||
return $this->result(
|
||||
primaryAction: $primaryAction,
|
||||
secondaryActions: $secondaryActions,
|
||||
disabledReasons: $disabledReasons,
|
||||
attentionReason: $this->attentionReason($run, $primaryAction, $retryReason),
|
||||
mutationScope: $primaryAction['mutation_scope'] ?? null,
|
||||
highRisk: $highRisk,
|
||||
freshnessState: $freshnessState->value,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $secondaryActions
|
||||
* @param array<string, string> $disabledReasons
|
||||
* @return array{
|
||||
* primary_action: array<string, mixed>|null,
|
||||
* secondary_actions: list<array<string, mixed>>,
|
||||
* disabled_actions: list<array<string, mixed>>,
|
||||
* disabled_reasons: array<string, string>,
|
||||
* attention_reason: string,
|
||||
* mutation_scope: ?string,
|
||||
* high_risk: bool,
|
||||
* freshness_state: string
|
||||
* }
|
||||
*/
|
||||
private function result(
|
||||
?array $primaryAction,
|
||||
array $secondaryActions,
|
||||
array $disabledReasons,
|
||||
string $attentionReason,
|
||||
?string $mutationScope,
|
||||
bool $highRisk,
|
||||
string $freshnessState,
|
||||
): array {
|
||||
return [
|
||||
'primary_action' => $primaryAction,
|
||||
'secondary_actions' => array_values($secondaryActions),
|
||||
'disabled_actions' => array_map(
|
||||
static fn (string $key, string $reason): array => [
|
||||
'key' => $key,
|
||||
'label' => __('localization.operations.actions.disabled_labels.'.$key),
|
||||
'disabled_reason' => $reason,
|
||||
],
|
||||
array_keys($disabledReasons),
|
||||
array_values($disabledReasons),
|
||||
),
|
||||
'disabled_reasons' => $disabledReasons,
|
||||
'attention_reason' => $attentionReason,
|
||||
'mutation_scope' => $mutationScope,
|
||||
'high_risk' => $highRisk,
|
||||
'freshness_state' => $freshnessState,
|
||||
];
|
||||
}
|
||||
|
||||
private function canOfferReconcile(OperationRun $run, ?User $user): bool
|
||||
{
|
||||
if (! $run->isCurrentlyActive()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$adapter = $this->reconciliationRegistry->forType($run->canonicalOperationType());
|
||||
|
||||
if (! $adapter instanceof OperationRunReconciliationAdapter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $run->freshnessState()->isLikelyStale()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($run->isLifecycleReconciled()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($user instanceof User && Gate::forUser($user)->inspect('reconcile', $run)->denied()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->hasSufficientReconciliationProof($adapter, $run);
|
||||
}
|
||||
|
||||
private function reconcileDisabledReason(OperationRun $run, ?User $user): string
|
||||
{
|
||||
if ($run->isLifecycleReconciled()) {
|
||||
return __('localization.operations.actions.disabled.already_reconciled');
|
||||
}
|
||||
|
||||
if (! $run->isCurrentlyActive()) {
|
||||
return __('localization.operations.actions.disabled.terminal_run');
|
||||
}
|
||||
|
||||
$adapter = $this->reconciliationRegistry->forType($run->canonicalOperationType());
|
||||
|
||||
if (! $adapter instanceof OperationRunReconciliationAdapter) {
|
||||
return __('localization.operations.actions.disabled.unsupported_reconcile');
|
||||
}
|
||||
|
||||
if (! $run->freshnessState()->isLikelyStale()) {
|
||||
return __('localization.operations.actions.disabled.lifecycle_fresh');
|
||||
}
|
||||
|
||||
if ($user instanceof User && Gate::forUser($user)->inspect('reconcile', $run)->denied()) {
|
||||
return __('localization.operations.actions.disabled.missing_capability');
|
||||
}
|
||||
|
||||
if (! $this->hasSufficientReconciliationProof($adapter, $run)) {
|
||||
return __('localization.operations.actions.disabled.insufficient_proof');
|
||||
}
|
||||
|
||||
return __('localization.operations.actions.disabled.insufficient_proof');
|
||||
}
|
||||
|
||||
private function hasSufficientReconciliationProof(
|
||||
OperationRunReconciliationAdapter $adapter,
|
||||
OperationRun $run,
|
||||
): bool {
|
||||
try {
|
||||
$result = $adapter->reconcile($run);
|
||||
} catch (Throwable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $result instanceof ReconciliationResult
|
||||
&& $result->safeForAutoCompletion
|
||||
&& $result->shouldFinalizeRun()
|
||||
&& $result->decision !== 'attention_required';
|
||||
}
|
||||
|
||||
private function retryDisabledReason(OperationRun $run, bool $highRisk): string
|
||||
{
|
||||
if ($highRisk) {
|
||||
return __('localization.operations.actions.disabled.high_risk_retry');
|
||||
}
|
||||
|
||||
if ((string) $run->status === OperationRunStatus::Completed->value
|
||||
&& (string) $run->outcome === OperationRunOutcome::Succeeded->value) {
|
||||
return __('localization.operations.actions.disabled.completed_succeeded');
|
||||
}
|
||||
|
||||
return __('localization.operations.actions.disabled.retry_deferred');
|
||||
}
|
||||
|
||||
private function canViewDiagnostics(OperationRun $run, ?User $user): bool
|
||||
{
|
||||
if (! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$tenant = $run->tenant;
|
||||
|
||||
if ($tenant instanceof ManagedEnvironment) {
|
||||
return $this->tenantCapabilities->isMember($user, $tenant)
|
||||
&& $this->tenantCapabilities->can($user, $tenant, Capabilities::SUPPORT_DIAGNOSTICS_VIEW);
|
||||
}
|
||||
|
||||
$workspace = $run->workspace;
|
||||
|
||||
return $workspace instanceof Workspace
|
||||
&& $this->workspaceCapabilities->isMember($user, $workspace)
|
||||
&& $this->workspaceCapabilities->can($user, $workspace, Capabilities::AUDIT_VIEW);
|
||||
}
|
||||
|
||||
private function isHighRisk(string $canonicalType): bool
|
||||
{
|
||||
if (in_array($canonicalType, [
|
||||
OperationRunType::RestoreExecute->value,
|
||||
OperationRunType::PromotionExecute->value,
|
||||
], true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (! array_key_exists($canonicalType, OperationCatalog::canonicalInventory())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
foreach (['restore', 'delete', 'purge', 'force_delete', 'promotion'] as $needle) {
|
||||
if (str_contains($canonicalType, $needle)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function primaryRelatedAction(OperationRun $run): ?array
|
||||
{
|
||||
$tenant = $run->tenant instanceof ManagedEnvironment ? $run->tenant : null;
|
||||
$links = OperationRunLinks::related($run, $tenant);
|
||||
unset($links[OperationRunLinks::collectionLabel()]);
|
||||
|
||||
if ($links === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$canonicalType = $run->canonicalOperationType();
|
||||
$preferred = match ($canonicalType) {
|
||||
OperationRunType::EnvironmentReviewCompose->value => ['ManagedEnvironment Review', 'Review'],
|
||||
OperationRunType::EvidenceSnapshotGenerate->value => ['Evidence Snapshot'],
|
||||
OperationRunType::ReviewPackGenerate->value => ['Review Pack', 'Report'],
|
||||
OperationRunType::RestoreExecute->value => ['Restore Run', 'Restore Runs'],
|
||||
OperationRunType::BackupScheduleExecute->value => ['Backup Set', 'Backup Sets'],
|
||||
OperationRunType::InventorySync->value => ['Inventory Coverage', 'Inventory'],
|
||||
default => [],
|
||||
};
|
||||
|
||||
$label = null;
|
||||
$url = null;
|
||||
|
||||
foreach ($preferred as $candidate) {
|
||||
if (isset($links[$candidate])) {
|
||||
$label = $candidate;
|
||||
$url = $links[$candidate];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($label === null || $url === null) {
|
||||
$label = array_key_first($links);
|
||||
$url = $label !== null ? $links[$label] : null;
|
||||
}
|
||||
|
||||
if (! is_string($label) || ! is_string($url) || $url === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'key' => $this->relatedActionKey($canonicalType, $label),
|
||||
'label' => $this->relatedActionLabel($canonicalType, $label),
|
||||
'type' => 'url',
|
||||
'url' => $url,
|
||||
'icon' => 'heroicon-o-arrow-top-right-on-square',
|
||||
'color' => $canonicalType === OperationRunType::RestoreExecute->value ? 'warning' : 'primary',
|
||||
'requires_confirmation' => false,
|
||||
'mutation_scope' => null,
|
||||
'related_label' => $label,
|
||||
];
|
||||
}
|
||||
|
||||
private function relatedActionKey(string $canonicalType, string $label): string
|
||||
{
|
||||
return match ($canonicalType) {
|
||||
OperationRunType::EnvironmentReviewCompose->value => 'view_review',
|
||||
OperationRunType::EvidenceSnapshotGenerate->value => 'view_evidence',
|
||||
OperationRunType::ReviewPackGenerate->value => 'view_report',
|
||||
OperationRunType::RestoreExecute->value => 'view_restore_details',
|
||||
OperationRunType::BackupScheduleExecute->value => 'view_backup_details',
|
||||
OperationRunType::InventorySync->value => 'view_affected_families',
|
||||
default => 'view_'.str($label)->slug('_')->toString(),
|
||||
};
|
||||
}
|
||||
|
||||
private function relatedActionLabel(string $canonicalType, string $fallbackLabel): string
|
||||
{
|
||||
return match ($canonicalType) {
|
||||
OperationRunType::EnvironmentReviewCompose->value => __('localization.operations.actions.view_review'),
|
||||
OperationRunType::EvidenceSnapshotGenerate->value => __('localization.operations.actions.view_evidence'),
|
||||
OperationRunType::ReviewPackGenerate->value => __('localization.operations.actions.view_report'),
|
||||
OperationRunType::RestoreExecute->value => __('localization.operations.actions.view_restore_details'),
|
||||
OperationRunType::BackupScheduleExecute->value => __('localization.operations.actions.view_backup_details'),
|
||||
OperationRunType::InventorySync->value => __('localization.operations.actions.view_affected_families'),
|
||||
default => $fallbackLabel,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function reconcileAction(OperationRun $run): array
|
||||
{
|
||||
return [
|
||||
'key' => 'reconcile',
|
||||
'label' => __('localization.operations.actions.reconcile'),
|
||||
'type' => 'action',
|
||||
'url' => null,
|
||||
'icon' => 'heroicon-o-wrench-screwdriver',
|
||||
'color' => 'warning',
|
||||
'requires_confirmation' => true,
|
||||
'mutation_scope' => __('localization.operations.actions.mutation_scope_reconcile'),
|
||||
'modal_heading' => __('localization.operations.actions.reconcile_heading'),
|
||||
'modal_description' => __('localization.operations.actions.reconcile_description'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function diagnosticsAction(): array
|
||||
{
|
||||
return [
|
||||
'key' => 'open_support_diagnostics',
|
||||
'label' => __('localization.operations.actions.open_diagnostics'),
|
||||
'type' => 'modal',
|
||||
'url' => null,
|
||||
'icon' => 'heroicon-o-lifebuoy',
|
||||
'color' => 'gray',
|
||||
'requires_confirmation' => false,
|
||||
'mutation_scope' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function viewDetailsAction(OperationRun $run): array
|
||||
{
|
||||
return [
|
||||
'key' => 'view_details',
|
||||
'label' => __('localization.operations.actions.view_details'),
|
||||
'type' => 'url',
|
||||
'url' => OperationRunLinks::tenantlessView($run),
|
||||
'icon' => 'heroicon-o-document-magnifying-glass',
|
||||
'color' => 'gray',
|
||||
'requires_confirmation' => false,
|
||||
'mutation_scope' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $primaryAction
|
||||
*/
|
||||
private function attentionReason(OperationRun $run, ?array $primaryAction, string $retryReason): string
|
||||
{
|
||||
if ($run->isLifecycleReconciled() && $run->reconciledRelatedType() !== null) {
|
||||
return __('localization.operations.actions.attention.related_available');
|
||||
}
|
||||
|
||||
if ($run->freshnessState()->isLikelyStale()) {
|
||||
return ($primaryAction['key'] ?? null) === 'reconcile'
|
||||
? __('localization.operations.actions.attention.reconcile_available')
|
||||
: __('localization.operations.actions.attention.stale_review');
|
||||
}
|
||||
|
||||
if ($this->isHighRisk($run->canonicalOperationType())) {
|
||||
return $retryReason;
|
||||
}
|
||||
|
||||
if ((string) $run->outcome === OperationRunOutcome::PartiallySucceeded->value) {
|
||||
return __('localization.operations.actions.attention.partial');
|
||||
}
|
||||
|
||||
if ((string) $run->outcome === OperationRunOutcome::Blocked->value) {
|
||||
return __('localization.operations.actions.attention.blocked');
|
||||
}
|
||||
|
||||
if ((string) $run->outcome === OperationRunOutcome::Failed->value) {
|
||||
return __('localization.operations.actions.attention.failed');
|
||||
}
|
||||
|
||||
if ($run->isCurrentlyActive()) {
|
||||
return __('localization.operations.actions.attention.active');
|
||||
}
|
||||
|
||||
return __('localization.operations.actions.attention.default');
|
||||
}
|
||||
}
|
||||
@ -1281,6 +1281,60 @@
|
||||
'policies' => 'Richtlinien',
|
||||
],
|
||||
],
|
||||
'operations' => [
|
||||
'actions' => [
|
||||
'view_details' => 'Details anzeigen',
|
||||
'view_review' => 'Review anzeigen',
|
||||
'view_evidence' => 'Evidence anzeigen',
|
||||
'view_report' => 'Report anzeigen',
|
||||
'view_backup_details' => 'Backup-Details anzeigen',
|
||||
'view_restore_details' => 'Wiederherstellungsdetails anzeigen',
|
||||
'view_affected_families' => 'Betroffene Familien anzeigen',
|
||||
'reconcile' => 'Lauf abgleichen',
|
||||
'reconcile_heading' => 'Diesen OperationRun abgleichen?',
|
||||
'reconcile_description' => 'TenantPilot prüft vorhandene Repository-Proofs und aktualisiert nur TenantPilot-OperationRun- und Aktionsmetadaten, wenn der Adapter den finalen Laufzustand belegen kann. Dies startet keinen Retry und verändert den Microsoft-Tenant nicht.',
|
||||
'reconcile_submit' => 'Lauf abgleichen',
|
||||
'reconcile_success_title' => 'Operation abgeglichen',
|
||||
'reconcile_success_body' => 'TenantPilot hat den Lauf aus vorhandenen Repository-Proofs aktualisiert.',
|
||||
'reconcile_noop_title' => 'Kein Abgleich angewendet',
|
||||
'reconcile_noop_body' => 'Der Adapter konnte keine sichere Zustandsänderung für diesen Lauf belegen.',
|
||||
'open_diagnostics' => 'Diagnose öffnen',
|
||||
'no_safe_action' => 'Keine sichere Aktion',
|
||||
'mutation_scope_reconcile' => 'Nur TenantPilot-OperationRun- und Aktionsmetadaten.',
|
||||
'attention' => [
|
||||
'active' => 'Die Operation liegt noch im erwarteten Lebenszyklusfenster.',
|
||||
'blocked' => 'Prüfen Sie die blockierte Voraussetzung, bevor überlappende Arbeit gestartet wird.',
|
||||
'default' => 'Öffnen Sie die Operationsdetails für gespeicherte Proofs und nächsten Kontext.',
|
||||
'failed' => 'Prüfen Sie den Fehler-Proof, bevor überlappende Arbeit gestartet wird.',
|
||||
'partial' => 'Prüfen Sie betroffene Elemente, bevor Sie sich auf das Ergebnis verlassen.',
|
||||
'reconcile_available' => 'Vorhandene Repository-Proofs können diesen stale Lauf sicher abgleichen.',
|
||||
'related_available' => 'Das zugehörige Artefakt ist über kanonische Metadaten verfügbar.',
|
||||
'scope_unavailable' => 'Die Operation liegt außerhalb des aktuellen Benutzerkontexts.',
|
||||
'stale_review' => 'Dieser Lauf liegt außerhalb seines Lebenszyklusfensters und muss vor einem Retry geprüft werden.',
|
||||
],
|
||||
'disabled' => [
|
||||
'already_reconciled' => 'Dieser Lauf hat bereits Reconciliation-Metadaten.',
|
||||
'completed_succeeded' => 'Erfolgreich abgeschlossene Läufe sind in dieser Ansicht nicht retryfähig.',
|
||||
'forbidden' => 'Diese Aktion ist für OperationRuns verboten.',
|
||||
'high_risk' => 'High-Risk-Operationen dürfen keine erfolgserzwingenden oder destruktiven Aktionen anbieten.',
|
||||
'high_risk_retry' => 'High-Risk-Operationen können in dieser Ansicht nicht erneut gestartet werden.',
|
||||
'insufficient_proof' => 'Gespeicherte Proofs reichen für einen sicheren Abgleich nicht aus.',
|
||||
'lifecycle_fresh' => 'Die Operation liegt noch im erwarteten Lebenszyklusfenster.',
|
||||
'missing_capability' => 'Ihnen fehlt die erforderliche Berechtigung für diese Operator-Aktion.',
|
||||
'missing_diagnostics_capability' => 'Support-Diagnosen erfordern die Support-Diagnose-Berechtigung.',
|
||||
'retry_deferred' => 'Retry ist nicht verfügbar, weil für diese Operationsfamilie kein sicherer repo-verifizierter Retry-Seam existiert.',
|
||||
'scope_unavailable' => 'Die Operation liegt außerhalb des aktuellen Benutzerkontexts.',
|
||||
'terminal_run' => 'Terminale Läufe können über diese Aktion nicht abgeglichen werden.',
|
||||
'unsupported_reconcile' => 'Dieser Operationstyp hat keinen Reconciliation-Adapter.',
|
||||
],
|
||||
'disabled_labels' => [
|
||||
'open_support_diagnostics' => 'Diagnose öffnen',
|
||||
'reconcile' => 'Lauf abgleichen',
|
||||
'retry' => 'Lauf erneut starten',
|
||||
'view_details' => 'Details anzeigen',
|
||||
],
|
||||
],
|
||||
],
|
||||
'notifications' => [
|
||||
'locale_override_saved' => 'Sprachüberschreibung angewendet.',
|
||||
'locale_override_cleared' => 'Sprachüberschreibung gelöscht.',
|
||||
|
||||
@ -1281,6 +1281,60 @@
|
||||
'policies' => 'Policies',
|
||||
],
|
||||
],
|
||||
'operations' => [
|
||||
'actions' => [
|
||||
'view_details' => 'View details',
|
||||
'view_review' => 'View review',
|
||||
'view_evidence' => 'View evidence',
|
||||
'view_report' => 'View report',
|
||||
'view_backup_details' => 'View backup details',
|
||||
'view_restore_details' => 'View restore details',
|
||||
'view_affected_families' => 'View affected families',
|
||||
'reconcile' => 'Reconcile run',
|
||||
'reconcile_heading' => 'Reconcile this operation run?',
|
||||
'reconcile_description' => 'TenantPilot will inspect existing repository proof and update only TenantPilot OperationRun/action metadata when the adapter can prove the final run state. This does not retry or change the Microsoft tenant.',
|
||||
'reconcile_submit' => 'Reconcile run',
|
||||
'reconcile_success_title' => 'Operation reconciled',
|
||||
'reconcile_success_body' => 'TenantPilot updated the run from existing repository proof.',
|
||||
'reconcile_noop_title' => 'No reconciliation applied',
|
||||
'reconcile_noop_body' => 'The adapter could not prove a safe state change for this run.',
|
||||
'open_diagnostics' => 'Open diagnostics',
|
||||
'no_safe_action' => 'No safe action',
|
||||
'mutation_scope_reconcile' => 'TenantPilot-only OperationRun/action metadata.',
|
||||
'attention' => [
|
||||
'active' => 'The operation is still inside its expected lifecycle window.',
|
||||
'blocked' => 'Review the blocked prerequisite before starting overlapping work.',
|
||||
'default' => 'Open the operation detail for stored proof and next-step context.',
|
||||
'failed' => 'Review the failure proof before starting overlapping work.',
|
||||
'partial' => 'Review affected items before relying on the result.',
|
||||
'reconcile_available' => 'Existing repository proof may safely reconcile this stale run.',
|
||||
'related_available' => 'The related artifact is already available from canonical metadata.',
|
||||
'scope_unavailable' => 'The operation is outside the current user scope.',
|
||||
'stale_review' => 'This run is past its lifecycle window and needs review before retrying.',
|
||||
],
|
||||
'disabled' => [
|
||||
'already_reconciled' => 'This run already has reconciliation metadata.',
|
||||
'completed_succeeded' => 'Completed successful runs are not retryable from this view.',
|
||||
'forbidden' => 'This action is forbidden for OperationRuns.',
|
||||
'high_risk' => 'High-risk operations cannot expose success-forcing or destructive actions.',
|
||||
'high_risk_retry' => 'High-risk operations cannot be retried from this view.',
|
||||
'insufficient_proof' => 'Stored proof is insufficient for safe reconciliation.',
|
||||
'lifecycle_fresh' => 'The operation is still within its expected lifecycle window.',
|
||||
'missing_capability' => 'You do not have the capability required for this operator action.',
|
||||
'missing_diagnostics_capability' => 'Support diagnostics require the support diagnostics capability.',
|
||||
'retry_deferred' => 'Retry is unavailable because no safe repo-verified retry seam exists for this operation family.',
|
||||
'scope_unavailable' => 'The operation is outside the current user scope.',
|
||||
'terminal_run' => 'Terminal runs cannot be reconciled from this action.',
|
||||
'unsupported_reconcile' => 'This operation type has no reconciliation adapter.',
|
||||
],
|
||||
'disabled_labels' => [
|
||||
'open_support_diagnostics' => 'Open diagnostics',
|
||||
'reconcile' => 'Reconcile run',
|
||||
'retry' => 'Retry run',
|
||||
'view_details' => 'View details',
|
||||
],
|
||||
],
|
||||
],
|
||||
'notifications' => [
|
||||
'locale_override_saved' => 'Language override applied.',
|
||||
'locale_override_cleared' => 'Language override cleared.',
|
||||
|
||||
@ -8,6 +8,12 @@
|
||||
$diagnostics = $workbench['diagnostics'] ?? [];
|
||||
$staleAttentionTab = \App\Models\OperationRun::PROBLEM_CLASS_ACTIVE_STALE_ATTENTION;
|
||||
$terminalFollowUpTab = \App\Models\OperationRun::PROBLEM_CLASS_TERMINAL_FOLLOW_UP;
|
||||
$attentionBadgeColor = match (true) {
|
||||
$selectedOperation === null => 'gray',
|
||||
($workbench['has_attention'] ?? false) === true => 'warning',
|
||||
($selectedOperation['progress']['display'] ?? null) !== \App\Support\OpsUx\OperationRunProgressContract::NONE => 'info',
|
||||
default => 'gray',
|
||||
};
|
||||
@endphp
|
||||
|
||||
<div class="space-y-6">
|
||||
@ -15,21 +21,21 @@
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div class="max-w-3xl space-y-2">
|
||||
<p class="text-xs font-medium uppercase text-gray-500 dark:text-gray-400">Operations Hub</p>
|
||||
<h2 class="text-2xl font-semibold text-gray-950 dark:text-white">Execution follow-up workbench</h2>
|
||||
<h2 class="text-2xl font-semibold text-gray-950 dark:text-white">Execution follow-up</h2>
|
||||
<p class="text-sm leading-6 text-gray-600 dark:text-gray-300">
|
||||
OperationRuns are execution truth. This page prioritizes stored operation outcomes, proof paths, and follow-up without claiming environment or governance health.
|
||||
Scan active, stale, failed, and partial OperationRuns. Open proof or the one safe next action without exposing diagnostics by default.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 text-sm">
|
||||
<div class="flex max-w-full flex-col gap-1 text-sm sm:max-w-md lg:items-end lg:text-right">
|
||||
@if ($landingHierarchy['scope_label'] !== __('localization.shell.all_environments'))
|
||||
<span class="inline-flex items-center rounded-md bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
|
||||
<x-filament::badge color="gray" size="sm">
|
||||
{{ $landingHierarchy['scope_label'] }}
|
||||
</span>
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
<span class="inline-flex items-center rounded-md bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
|
||||
<p class="text-xs leading-5 text-gray-500 dark:text-gray-400" data-testid="operations-hub-scope-body">
|
||||
{{ $landingHierarchy['scope_body'] }}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -56,22 +62,17 @@
|
||||
</div>
|
||||
|
||||
@if ($selectedOperation !== null)
|
||||
<span @class([
|
||||
'inline-flex w-fit items-center rounded-lg px-2.5 py-1 text-xs font-medium',
|
||||
'bg-warning-50 text-warning-700 dark:bg-warning-500/10 dark:text-warning-300' => ($workbench['has_attention'] ?? false) === true,
|
||||
'bg-info-50 text-info-700 dark:bg-info-950/40 dark:text-info-300' => ($workbench['has_attention'] ?? false) === false && ($selectedOperation['progress']['display'] ?? null) !== \App\Support\OpsUx\OperationRunProgressContract::NONE,
|
||||
'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-200' => ($workbench['has_attention'] ?? false) === false && ($selectedOperation['progress']['display'] ?? null) === \App\Support\OpsUx\OperationRunProgressContract::NONE,
|
||||
])>
|
||||
<x-filament::badge :color="$attentionBadgeColor" size="sm">
|
||||
{{ $selectedOperation['attention_label'] }}
|
||||
</span>
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if (! ($workbench['has_attention'] ?? false))
|
||||
<div class="mt-5 rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-800 dark:bg-gray-950/40" data-testid="operations-hub-no-attention-state">
|
||||
<h3 class="text-base font-semibold text-gray-950 dark:text-white">No operations need attention</h3>
|
||||
<h3 class="text-base font-semibold text-gray-950 dark:text-white">No operations need follow-up</h3>
|
||||
<p class="mt-1 text-sm leading-6 text-gray-600 dark:text-gray-300">
|
||||
No failed, blocked, partial, or stale OperationRuns are visible in this scope. This is execution follow-up only, not an environment health claim.
|
||||
No failed, blocked, partial, or stale OperationRuns are visible in this scope.
|
||||
</p>
|
||||
</div>
|
||||
@endif
|
||||
@ -87,12 +88,12 @@
|
||||
<div class="mt-5 space-y-5">
|
||||
<div class="space-y-2">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="inline-flex items-center rounded-lg bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
|
||||
<x-filament::badge color="gray" size="sm">
|
||||
{{ $selectedOperation['identifier'] }}
|
||||
</span>
|
||||
<span class="inline-flex items-center rounded-lg bg-gray-100 px-2.5 py-1 text-xs font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
|
||||
</x-filament::badge>
|
||||
<x-filament::badge color="gray" size="sm">
|
||||
Environment: {{ $selectedOperation['environment'] }}
|
||||
</span>
|
||||
</x-filament::badge>
|
||||
</div>
|
||||
|
||||
<h3 class="text-lg font-semibold text-gray-950 dark:text-white">
|
||||
@ -230,9 +231,9 @@ class="mt-2 w-full"
|
||||
|
||||
<section class="space-y-4" data-testid="operations-hub-secondary-history">
|
||||
<div class="space-y-1">
|
||||
<h2 class="text-lg font-semibold text-gray-950 dark:text-white">Operations history</h2>
|
||||
<h2 class="text-lg font-semibold text-gray-950 dark:text-white">Recent runs</h2>
|
||||
<p class="text-sm leading-6 text-gray-600 dark:text-gray-300">
|
||||
Secondary context for scanning OperationRun history after the top decision path is clear.
|
||||
Chronological operation record. Use tabs for attention states; open a row for proof and authorized diagnostics.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -296,7 +297,7 @@ class="mt-2 w-full"
|
||||
</x-filament::tabs>
|
||||
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Environment filters and the selected operations tab remain shareable through the URL. Additional table filters still restore from the last compatible session state.
|
||||
Tabs are shareable through the URL. Table filters restore from your session.
|
||||
</p>
|
||||
|
||||
@if (($lifecycleSummary['likely_stale'] ?? 0) > 0 || ($lifecycleSummary['reconciled'] ?? 0) > 0)
|
||||
|
||||
@ -100,8 +100,8 @@
|
||||
'activeTab' => 'active',
|
||||
]))
|
||||
->waitForText('Operations Hub')
|
||||
->assertSee('Environment filters and the selected operations tab remain shareable through the URL.')
|
||||
->assertSee('Open operation')
|
||||
->assertSee('Tabs are shareable through the URL.')
|
||||
->assertSee('Next action')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs();
|
||||
|
||||
|
||||
@ -21,7 +21,7 @@
|
||||
->waitForText('Operations Hub')
|
||||
->assertDontSee(__('localization.shell.no_environment_selected'))
|
||||
->assertDontSee('Environment filter:')
|
||||
->assertSee('Execution follow-up workbench')
|
||||
->assertSee('Execution follow-up')
|
||||
->assertSee('Which operation needs attention now?')
|
||||
->assertSee('Decision workbench')
|
||||
->assertSee('Needs attention')
|
||||
@ -41,9 +41,10 @@
|
||||
->assertSee('Proof')
|
||||
->assertSee('Operation detail available')
|
||||
->assertSee('Primary next action')
|
||||
->assertSee('Open operation')
|
||||
->assertSee('Operations history')
|
||||
->assertSee('Next action')
|
||||
->assertSee('Recent runs')
|
||||
->assertSee('Policy sync')
|
||||
->assertSee('View affected families')
|
||||
->assertDontSee('tenant filter')
|
||||
->assertDontSee('current tenant')
|
||||
->assertDontSee('entitled tenant')
|
||||
@ -54,6 +55,23 @@
|
||||
->assertDontSee('debug metadata should stay hidden')
|
||||
->assertDontSee('internal exception should stay hidden')
|
||||
->assertScript('document.querySelector("[data-testid=\"operations-hub-diagnostics\"]")?.open === false', true)
|
||||
->assertScript('(() => {
|
||||
const labels = Array.from(document.querySelectorAll("td.fi-ta-cell-next-action p"))
|
||||
.filter((element) => element.textContent?.includes("View affected families"));
|
||||
|
||||
if (labels.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return labels.every((element) => {
|
||||
const styles = getComputedStyle(element);
|
||||
|
||||
return element.scrollWidth <= element.clientWidth + 1
|
||||
&& element.scrollHeight <= element.clientHeight + 1
|
||||
&& styles.textOverflow !== "ellipsis"
|
||||
&& styles.webkitLineClamp !== "1";
|
||||
});
|
||||
})()', true)
|
||||
->assertScript('(() => {
|
||||
const summaryCards = document.querySelector("[data-testid=\"operations-hub-summary-cards\"]");
|
||||
const nativeStats = summaryCards?.querySelector(".fi-wi-stats-overview");
|
||||
@ -205,8 +223,8 @@
|
||||
|
||||
visit(OperationRunLinks::index(workspace: $environment->workspace))
|
||||
->waitForText('Operations Hub')
|
||||
->assertSee('No operations need attention')
|
||||
->assertSee('This is execution follow-up only, not an environment health claim.')
|
||||
->assertSee('No operations need follow-up')
|
||||
->assertSee('No failed, blocked, partial, or stale OperationRuns are visible in this scope.')
|
||||
->assertSee('Operation detail available')
|
||||
->assertDontSee('environment is healthy')
|
||||
->assertDontSee('governance health is complete')
|
||||
|
||||
@ -0,0 +1,301 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\EnvironmentReview;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Models\User;
|
||||
use App\Services\EnvironmentReviews\EnvironmentReviewFingerprint;
|
||||
use App\Support\Evidence\EvidenceCompletenessState;
|
||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\RestoreRunStatus;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
pest()->browser()->timeout(60_000);
|
||||
|
||||
it('Spec365 smokes confirmed review reconciliation guidance on operations surfaces', function (): void {
|
||||
[$user, $environment] = createUserWithTenant(role: 'owner', workspaceRole: 'manager');
|
||||
|
||||
spec365BrowserAuthenticate($this, $user, $environment);
|
||||
|
||||
$snapshot = seedEnvironmentReviewEvidence($environment, operationRunCount: 0);
|
||||
$fingerprint = app(EnvironmentReviewFingerprint::class)->forSnapshot($environment, $snapshot);
|
||||
$run = spec365BrowserCreateStaleReviewRun($environment, $user, $fingerprint, $snapshot);
|
||||
|
||||
spec365BrowserCreateReadyReviewTruth($environment, $user, $snapshot, $fingerprint);
|
||||
|
||||
visit(OperationRunLinks::index($environment))
|
||||
->resize(1440, 1100)
|
||||
->waitForText('Operations Hub')
|
||||
->assertSee('Reconcile run')
|
||||
->assertSee('Existing repository proof may safely reconcile this stale run.')
|
||||
->assertDontSee('SQLSTATE')
|
||||
->assertDontSee('access token')
|
||||
->assertDontSee('client secret')
|
||||
->assertDontSee('serialized job')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs();
|
||||
|
||||
visit(OperationRunLinks::tenantlessView($run))
|
||||
->waitForText('Monitoring detail')
|
||||
->assertSee('Reconcile run')
|
||||
->click('Reconcile run')
|
||||
->assertSee('Reconcile this operation run?')
|
||||
->assertSee('This does not retry or change the Microsoft tenant.')
|
||||
->assertDontSee('SQLSTATE')
|
||||
->assertDontSee('environment_reviews_fingerprint_mutable_unique')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs();
|
||||
});
|
||||
|
||||
it('Spec365 smokes related proof actions across evidence report sync and backup states', function (): void {
|
||||
[$user, $environment] = createUserWithTenant(role: 'owner', workspaceRole: 'manager');
|
||||
|
||||
spec365BrowserAuthenticate($this, $user, $environment);
|
||||
spec365BrowserCreateEvidenceRun($environment, $user);
|
||||
spec365BrowserCreateReviewPackRun($environment, $user);
|
||||
spec365BrowserCreatePartialInventoryRun($environment, $user);
|
||||
spec365BrowserCreateBlockedBackupRun($environment, $user);
|
||||
|
||||
visit(OperationRunLinks::index($environment))
|
||||
->resize(1440, 1100)
|
||||
->waitForText('Operations Hub')
|
||||
->assertSee('View evidence')
|
||||
->assertSee('View report')
|
||||
->assertSee('View affected families')
|
||||
->assertSee('View backup details')
|
||||
->assertDontSee('SQLSTATE')
|
||||
->assertDontSee('Guzzle')
|
||||
->assertDontSee('stack trace')
|
||||
->assertDontSee('access token')
|
||||
->assertDontSee('client secret')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs();
|
||||
});
|
||||
|
||||
it('Spec365 smokes high-risk restore guidance without unsafe operator actions', function (): void {
|
||||
[$user, $environment] = createUserWithTenant(role: 'owner', workspaceRole: 'manager');
|
||||
|
||||
spec365BrowserAuthenticate($this, $user, $environment);
|
||||
|
||||
$run = spec365BrowserCreateStaleRestoreRun($environment, $user);
|
||||
|
||||
visit(OperationRunLinks::tenantlessView($run))
|
||||
->resize(1440, 1100)
|
||||
->waitForText('Monitoring detail')
|
||||
->assertSee('View restore details')
|
||||
->assertSee('High-risk operations cannot be retried from this view.')
|
||||
->assertDontSee('Retry restore')
|
||||
->assertDontSee('Force complete')
|
||||
->assertDontSee('Mark succeeded')
|
||||
->assertDontSee('Delete run')
|
||||
->assertDontSee('Purge run')
|
||||
->assertDontSee('stack trace')
|
||||
->assertDontSee('access token')
|
||||
->assertDontSee('client secret')
|
||||
->assertNoJavaScriptErrors()
|
||||
->assertNoConsoleLogs();
|
||||
});
|
||||
|
||||
function spec365BrowserAuthenticate(mixed $test, User $user, ManagedEnvironment $environment): void
|
||||
{
|
||||
$workspaceId = (int) $environment->workspace_id;
|
||||
|
||||
$test->actingAs($user)->withSession([
|
||||
WorkspaceContext::SESSION_KEY => $workspaceId,
|
||||
WorkspaceContext::LAST_ENVIRONMENT_IDS_SESSION_KEY => [
|
||||
(string) $workspaceId => (int) $environment->getKey(),
|
||||
],
|
||||
]);
|
||||
|
||||
session()->put(WorkspaceContext::SESSION_KEY, $workspaceId);
|
||||
session()->put(WorkspaceContext::LAST_ENVIRONMENT_IDS_SESSION_KEY, [
|
||||
(string) $workspaceId => (int) $environment->getKey(),
|
||||
]);
|
||||
|
||||
setAdminPanelContext($environment);
|
||||
}
|
||||
|
||||
function spec365BrowserCreateStaleReviewRun(
|
||||
ManagedEnvironment $environment,
|
||||
User $user,
|
||||
string $fingerprint,
|
||||
EvidenceSnapshot $snapshot,
|
||||
): OperationRun {
|
||||
return OperationRun::factory()->forTenant($environment)->create([
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'initiator_name' => $user->name,
|
||||
'type' => 'environment.review.compose',
|
||||
'status' => OperationRunStatus::Queued->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
'created_at' => now()->subMinutes(10),
|
||||
'context' => [
|
||||
'workspace_id' => (int) $environment->workspace_id,
|
||||
'managed_environment_id' => (int) $environment->getKey(),
|
||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'review_fingerprint' => $fingerprint,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
function spec365BrowserCreateReadyReviewTruth(
|
||||
ManagedEnvironment $environment,
|
||||
User $user,
|
||||
EvidenceSnapshot $snapshot,
|
||||
string $fingerprint,
|
||||
): EnvironmentReview {
|
||||
$publishedRun = OperationRun::factory()->forTenant($environment)->create([
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'initiator_name' => $user->name,
|
||||
'type' => 'environment.review.compose',
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
'completed_at' => now()->subMinutes(5),
|
||||
'context' => [
|
||||
'workspace_id' => (int) $environment->workspace_id,
|
||||
'managed_environment_id' => (int) $environment->getKey(),
|
||||
'review_fingerprint' => $fingerprint,
|
||||
],
|
||||
]);
|
||||
|
||||
return EnvironmentReview::factory()->ready()->create([
|
||||
'workspace_id' => (int) $environment->workspace_id,
|
||||
'managed_environment_id' => (int) $environment->getKey(),
|
||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
'operation_run_id' => (int) $publishedRun->getKey(),
|
||||
'fingerprint' => $fingerprint,
|
||||
'summary' => [
|
||||
'finding_count' => 4,
|
||||
'report_count' => 2,
|
||||
'operation_count' => 1,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
function spec365BrowserCreateStaleRestoreRun(ManagedEnvironment $environment, User $user): OperationRun
|
||||
{
|
||||
$backupSet = BackupSet::factory()->for($environment)->create(['status' => 'completed']);
|
||||
$run = OperationRun::factory()->forTenant($environment)->create([
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'initiator_name' => $user->name,
|
||||
'type' => 'restore.execute',
|
||||
'status' => OperationRunStatus::Queued->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
'created_at' => now()->subMinutes(10),
|
||||
]);
|
||||
|
||||
$restoreRun = RestoreRun::withoutEvents(fn (): RestoreRun => RestoreRun::factory()
|
||||
->for($environment, 'tenant')
|
||||
->for($backupSet)
|
||||
->create([
|
||||
'workspace_id' => (int) $environment->workspace_id,
|
||||
'operation_run_id' => (int) $run->getKey(),
|
||||
'status' => RestoreRunStatus::Completed->value,
|
||||
'is_dry_run' => false,
|
||||
]));
|
||||
|
||||
$run->forceFill([
|
||||
'context' => [
|
||||
'restore_run_id' => (int) $restoreRun->getKey(),
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
],
|
||||
])->save();
|
||||
|
||||
return $run->fresh();
|
||||
}
|
||||
|
||||
function spec365BrowserCreateEvidenceRun(ManagedEnvironment $environment, User $user): OperationRun
|
||||
{
|
||||
$run = OperationRun::factory()->forTenant($environment)->create([
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'initiator_name' => $user->name,
|
||||
'type' => 'tenant.evidence.snapshot.generate',
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
'completed_at' => now()->subMinutes(3),
|
||||
]);
|
||||
|
||||
EvidenceSnapshot::query()->create([
|
||||
'workspace_id' => (int) $environment->workspace_id,
|
||||
'managed_environment_id' => (int) $environment->getKey(),
|
||||
'operation_run_id' => (int) $run->getKey(),
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
'fingerprint' => hash('sha256', 'spec365-browser-evidence-'.$run->getKey()),
|
||||
'status' => EvidenceSnapshotStatus::Active->value,
|
||||
'completeness_state' => EvidenceCompletenessState::Complete->value,
|
||||
'summary' => ['source' => 'spec365-browser'],
|
||||
'generated_at' => now()->subMinutes(3),
|
||||
]);
|
||||
|
||||
return $run->fresh();
|
||||
}
|
||||
|
||||
function spec365BrowserCreateReviewPackRun(ManagedEnvironment $environment, User $user): OperationRun
|
||||
{
|
||||
$run = OperationRun::factory()->forTenant($environment)->create([
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'initiator_name' => $user->name,
|
||||
'type' => 'environment.review_pack.generate',
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
'completed_at' => now()->subMinutes(4),
|
||||
]);
|
||||
|
||||
ReviewPack::factory()->ready()->create([
|
||||
'workspace_id' => (int) $environment->workspace_id,
|
||||
'managed_environment_id' => (int) $environment->getKey(),
|
||||
'operation_run_id' => (int) $run->getKey(),
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
|
||||
return $run->fresh();
|
||||
}
|
||||
|
||||
function spec365BrowserCreatePartialInventoryRun(ManagedEnvironment $environment, User $user): OperationRun
|
||||
{
|
||||
return OperationRun::factory()->forTenant($environment)->create([
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'initiator_name' => $user->name,
|
||||
'type' => 'inventory.sync',
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::PartiallySucceeded->value,
|
||||
'completed_at' => now()->subMinutes(5),
|
||||
'summary_counts' => [
|
||||
'total' => 4,
|
||||
'processed' => 4,
|
||||
'succeeded' => 2,
|
||||
'failed' => 1,
|
||||
'skipped' => 1,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
function spec365BrowserCreateBlockedBackupRun(ManagedEnvironment $environment, User $user): OperationRun
|
||||
{
|
||||
$backupSet = BackupSet::factory()->for($environment)->create(['status' => 'completed']);
|
||||
|
||||
return OperationRun::factory()->forTenant($environment)->create([
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'initiator_name' => $user->name,
|
||||
'type' => 'backup.schedule.execute',
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Blocked->value,
|
||||
'completed_at' => now()->subMinutes(6),
|
||||
'context' => [
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
'reason_code' => 'missing_provider_permission',
|
||||
],
|
||||
]);
|
||||
}
|
||||
@ -28,7 +28,7 @@
|
||||
->assertOk()
|
||||
->assertSee('Operations Hub')
|
||||
->assertSee('Which operation needs attention now?')
|
||||
->assertSee('Operations history')
|
||||
->assertSee('Recent runs')
|
||||
->assertSee('All')
|
||||
->assertSee('Active')
|
||||
->assertSee('Likely stale')
|
||||
|
||||
@ -2,8 +2,8 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\OperationRun;
|
||||
use App\Support\Navigation\CanonicalNavigationContext;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
@ -29,10 +29,10 @@
|
||||
->get(route('admin.operations.index', ['workspace' => $tenant->workspace]))
|
||||
->assertOk()
|
||||
->assertSee('Operations Hub')
|
||||
->assertSee('Execution follow-up workbench')
|
||||
->assertSee('Execution follow-up')
|
||||
->assertSee('Which operation needs attention now?')
|
||||
->assertSee('Operations history')
|
||||
->assertSee('Open operation');
|
||||
->assertSee('Recent runs')
|
||||
->assertSee('Next action');
|
||||
});
|
||||
|
||||
it('surfaces canonical return context separately from the operations work lane', function (): void {
|
||||
@ -57,5 +57,5 @@
|
||||
->assertSee('Which operation needs attention now?')
|
||||
->assertSee('Back to backup set')
|
||||
->assertSee('/admin/tenant/backup-sets/1', false)
|
||||
->assertSee('Operations history');
|
||||
->assertSee('Recent runs');
|
||||
});
|
||||
|
||||
@ -71,7 +71,9 @@
|
||||
->get(OperationRunLinks::index(workspace: $environment->workspace))
|
||||
->assertOk()
|
||||
->assertSee('Operations Hub')
|
||||
->assertSee('Execution follow-up workbench')
|
||||
->assertSee('Execution follow-up')
|
||||
->assertSee('data-testid="operations-hub-scope-body"', false)
|
||||
->assertSee('Showing workspace-wide execution records across entitled environments.')
|
||||
->assertSee('Which operation needs attention now?')
|
||||
->assertSee('Decision workbench')
|
||||
->assertSee('Needs attention')
|
||||
@ -97,13 +99,15 @@
|
||||
->assertSee('Reason')
|
||||
->assertSee('Impact')
|
||||
->assertSee('Environment: Spec328 Environment Alpha')
|
||||
->assertSee('class="fi-badge fi-size-sm"', false)
|
||||
->assertSee('fi-badge-label', false)
|
||||
->assertSee('Proof')
|
||||
->assertSee('Operation detail available')
|
||||
->assertSee('Next action')
|
||||
->assertSee('Open operation')
|
||||
->assertSee('Next action')
|
||||
->assertSee('Operation summary')
|
||||
->assertSee('Operations history')
|
||||
->assertSee('Secondary context for scanning OperationRun history')
|
||||
->assertSee('Recent runs')
|
||||
->assertSee('Chronological operation record')
|
||||
->assertSee('Diagnostics')
|
||||
->assertSee('Collapsed')
|
||||
->assertDontSee('raw payload should stay hidden')
|
||||
@ -116,6 +120,8 @@
|
||||
->assertDontSee('sparkline')
|
||||
->assertDontSee('trend')
|
||||
->assertDontSee('wire:poll', false)
|
||||
->assertDontSee('inline-flex items-center rounded-lg bg-gray-100', false)
|
||||
->assertDontSee('inline-flex w-fit items-center rounded-lg px-2.5 py-1', false)
|
||||
->assertDontSee('tenant filter')
|
||||
->assertDontSee('current tenant')
|
||||
->assertDontSee('entitled tenant')
|
||||
@ -187,8 +193,8 @@
|
||||
->get(OperationRunLinks::index(workspace: $environment->workspace))
|
||||
->assertOk()
|
||||
->assertSee('Which operation needs attention now?')
|
||||
->assertSee('No operations need attention')
|
||||
->assertSee('This is execution follow-up only, not an environment health claim.')
|
||||
->assertSee('No operations need follow-up')
|
||||
->assertSee('No failed, blocked, partial, or stale OperationRuns are visible in this scope.')
|
||||
->assertSee('Operation detail available')
|
||||
->assertDontSee('environment is healthy')
|
||||
->assertDontSee('governance health is complete')
|
||||
|
||||
@ -0,0 +1,244 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Filament\Pages\Operations\TenantlessOperationRunViewer;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\EnvironmentReview;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\ManagedEnvironmentMembership;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\User;
|
||||
use App\Services\EnvironmentReviews\EnvironmentReviewFingerprint;
|
||||
use App\Services\Operations\OperationRunOperatorActionService;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\Operations\OperationRunActionEligibility;
|
||||
use App\Support\Workspaces\WorkspaceContext;
|
||||
use Filament\Facades\Filament;
|
||||
use Livewire\Livewire;
|
||||
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||
|
||||
it('reconciles a stale review-compose run through the confirmed Filament operator action and audits the mutation in Spec365', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$snapshot = seedEnvironmentReviewEvidence($tenant, operationRunCount: 0);
|
||||
$fingerprint = app(EnvironmentReviewFingerprint::class)->forSnapshot($tenant, $snapshot);
|
||||
|
||||
$run = spec365StaleReviewComposeRun($tenant, $user, $fingerprint, (int) $snapshot->getKey());
|
||||
$review = spec365ReadyReviewTruth($tenant, $user, $snapshot, $fingerprint);
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(TenantlessOperationRunViewer::class, ['run' => $run])
|
||||
->assertActionVisible('reconcileOperationRun')
|
||||
->callAction('reconcileOperationRun')
|
||||
->assertStatus(200);
|
||||
|
||||
$run->refresh();
|
||||
|
||||
expect($run->status)->toBe(OperationRunStatus::Completed->value)
|
||||
->and($run->outcome)->toBe(OperationRunOutcome::Succeeded->value)
|
||||
->and($run->reconciliationAdapter())->toBe('environment_review_compose')
|
||||
->and($run->reconciledRelatedReviewId())->toBe((int) $review->getKey());
|
||||
|
||||
$postDecision = app(OperationRunActionEligibility::class)->forRun($run->fresh(), $user);
|
||||
|
||||
expect(data_get($postDecision, 'primary_action.key'))->toBe('view_review')
|
||||
->and($postDecision['disabled_reasons']['reconcile'])->toBe(__('localization.operations.actions.disabled.already_reconciled'));
|
||||
|
||||
$audit = AuditLog::query()
|
||||
->where('action', 'operation.reconciled_by_operator')
|
||||
->where('operation_run_id', (int) $run->getKey())
|
||||
->first();
|
||||
|
||||
expect($audit)->not->toBeNull();
|
||||
|
||||
$metadata = is_array($audit?->metadata) ? $audit->metadata : [];
|
||||
$encodedMetadata = json_encode($metadata, JSON_THROW_ON_ERROR);
|
||||
|
||||
expect($metadata)->toMatchArray([
|
||||
'operator_action' => 'reconcile',
|
||||
'operation_run_id' => (int) $run->getKey(),
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'actor_user_id' => (int) $user->getKey(),
|
||||
'operation_type' => 'environment.review.compose',
|
||||
'previous_status' => OperationRunStatus::Queued->value,
|
||||
'previous_outcome' => OperationRunOutcome::Pending->value,
|
||||
'resulting_status' => OperationRunStatus::Completed->value,
|
||||
'resulting_outcome' => OperationRunOutcome::Succeeded->value,
|
||||
'mutation_scope' => 'tenantpilot_operation_metadata_only',
|
||||
])->and($encodedMetadata)->not->toContain('access_token', 'client_secret', 'refresh_token');
|
||||
});
|
||||
|
||||
it('denies direct reconcile attempts for review viewers without manage capability and records a denied audit in Spec365', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly', workspaceRole: 'readonly');
|
||||
$snapshot = seedEnvironmentReviewEvidence($tenant, operationRunCount: 0);
|
||||
$fingerprint = app(EnvironmentReviewFingerprint::class)->forSnapshot($tenant, $snapshot);
|
||||
|
||||
$run = spec365StaleReviewComposeRun($tenant, $user, $fingerprint, (int) $snapshot->getKey());
|
||||
spec365ReadyReviewTruth($tenant, $user, $snapshot, $fingerprint);
|
||||
|
||||
Filament::setTenant(null, true);
|
||||
session([WorkspaceContext::SESSION_KEY => (int) $tenant->workspace_id]);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(TenantlessOperationRunViewer::class, ['run' => $run])
|
||||
->assertDontSee(__('localization.operations.actions.reconcile'));
|
||||
|
||||
try {
|
||||
app(OperationRunOperatorActionService::class)->reconcile($run, $user);
|
||||
$this->fail('Readonly users should not be able to reconcile review-compose operation runs.');
|
||||
} catch (HttpException $exception) {
|
||||
expect($exception->getStatusCode())->toBe(403);
|
||||
}
|
||||
|
||||
$run->refresh();
|
||||
|
||||
expect($run->status)->toBe(OperationRunStatus::Queued->value)
|
||||
->and($run->outcome)->toBe(OperationRunOutcome::Pending->value)
|
||||
->and($run->isLifecycleReconciled())->toBeFalse()
|
||||
->and(AuditLog::query()
|
||||
->where('action', 'operation.reconcile_denied')
|
||||
->where('operation_run_id', (int) $run->getKey())
|
||||
->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
it('denies unsupported reconcile attempts without mutating run state in Spec365', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$run = OperationRun::factory()->forTenant($tenant)->create([
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'initiator_name' => $user->name,
|
||||
'type' => 'unknown.operation',
|
||||
'status' => OperationRunStatus::Queued->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
'created_at' => now()->subMinutes(10),
|
||||
]);
|
||||
|
||||
$decision = app(OperationRunActionEligibility::class)->forRun($run, $user);
|
||||
|
||||
expect(data_get($decision, 'primary_action.key'))->toBe('view_details')
|
||||
->and($decision['disabled_reasons']['reconcile'])->toBe(__('localization.operations.actions.disabled.unsupported_reconcile'));
|
||||
|
||||
try {
|
||||
app(OperationRunOperatorActionService::class)->reconcile($run, $user);
|
||||
$this->fail('Unsupported operation runs should not reconcile.');
|
||||
} catch (HttpException $exception) {
|
||||
expect($exception->getStatusCode())->toBe(403);
|
||||
}
|
||||
|
||||
$run->refresh();
|
||||
|
||||
expect($run->status)->toBe(OperationRunStatus::Queued->value)
|
||||
->and($run->outcome)->toBe(OperationRunOutcome::Pending->value)
|
||||
->and($run->isLifecycleReconciled())->toBeFalse();
|
||||
});
|
||||
|
||||
it('returns not found for direct reconcile attempts outside workspace scope in Spec365', function (): void {
|
||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
||||
[$outsider] = createUserWithTenant(role: 'owner');
|
||||
$snapshot = seedEnvironmentReviewEvidence($tenant, operationRunCount: 0);
|
||||
$fingerprint = app(EnvironmentReviewFingerprint::class)->forSnapshot($tenant, $snapshot);
|
||||
|
||||
$run = spec365StaleReviewComposeRun($tenant, $owner, $fingerprint, (int) $snapshot->getKey());
|
||||
|
||||
try {
|
||||
app(OperationRunOperatorActionService::class)->reconcile($run, $outsider);
|
||||
$this->fail('Cross-workspace users should not be able to reconcile operation runs.');
|
||||
} catch (HttpException $exception) {
|
||||
expect($exception->getStatusCode())->toBe(404);
|
||||
}
|
||||
|
||||
$run->refresh();
|
||||
|
||||
expect($run->status)->toBe(OperationRunStatus::Queued->value)
|
||||
->and($run->outcome)->toBe(OperationRunOutcome::Pending->value);
|
||||
});
|
||||
|
||||
it('returns not found for direct reconcile attempts outside managed environment scope in Spec365', function (): void {
|
||||
[$user, $allowedTenant] = createUserWithTenant(role: 'owner');
|
||||
$deniedTenant = ManagedEnvironment::factory()->active()->create([
|
||||
'workspace_id' => (int) $allowedTenant->workspace_id,
|
||||
]);
|
||||
|
||||
expect(ManagedEnvironmentMembership::query()
|
||||
->where('user_id', (int) $user->getKey())
|
||||
->where('managed_environment_id', (int) $allowedTenant->getKey())
|
||||
->exists())->toBeTrue();
|
||||
|
||||
$snapshot = seedEnvironmentReviewEvidence($deniedTenant, operationRunCount: 0);
|
||||
$fingerprint = app(EnvironmentReviewFingerprint::class)->forSnapshot($deniedTenant, $snapshot);
|
||||
|
||||
$run = spec365StaleReviewComposeRun($deniedTenant, $user, $fingerprint, (int) $snapshot->getKey());
|
||||
spec365ReadyReviewTruth($deniedTenant, $user, $snapshot, $fingerprint);
|
||||
|
||||
$decision = app(OperationRunActionEligibility::class)->forRun($run, $user);
|
||||
|
||||
expect($decision['primary_action'])->toBeNull()
|
||||
->and($decision['disabled_reasons']['reconcile'])->toBe(__('localization.operations.actions.disabled.scope_unavailable'));
|
||||
|
||||
try {
|
||||
app(OperationRunOperatorActionService::class)->reconcile($run, $user);
|
||||
$this->fail('Cross-environment users should not be able to reconcile operation runs.');
|
||||
} catch (HttpException $exception) {
|
||||
expect($exception->getStatusCode())->toBe(404);
|
||||
}
|
||||
|
||||
$run->refresh();
|
||||
|
||||
expect($run->status)->toBe(OperationRunStatus::Queued->value)
|
||||
->and($run->outcome)->toBe(OperationRunOutcome::Pending->value);
|
||||
});
|
||||
|
||||
function spec365StaleReviewComposeRun(ManagedEnvironment $tenant, User $user, string $fingerprint, int $snapshotId): OperationRun
|
||||
{
|
||||
return OperationRun::factory()->forTenant($tenant)->create([
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'initiator_name' => $user->name,
|
||||
'type' => 'environment.review.compose',
|
||||
'status' => OperationRunStatus::Queued->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
'created_at' => now()->subMinutes(10),
|
||||
'context' => [
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'evidence_snapshot_id' => $snapshotId,
|
||||
'review_fingerprint' => $fingerprint,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
function spec365ReadyReviewTruth(ManagedEnvironment $tenant, User $user, EvidenceSnapshot $snapshot, string $fingerprint): EnvironmentReview
|
||||
{
|
||||
$publishedRun = OperationRun::factory()->forTenant($tenant)->create([
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'initiator_name' => $user->name,
|
||||
'type' => 'environment.review.compose',
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
'completed_at' => now()->subMinutes(5),
|
||||
'context' => [
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'review_fingerprint' => $fingerprint,
|
||||
],
|
||||
]);
|
||||
|
||||
return EnvironmentReview::factory()->ready()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
'operation_run_id' => (int) $publishedRun->getKey(),
|
||||
'fingerprint' => $fingerprint,
|
||||
'summary' => [
|
||||
'finding_count' => 4,
|
||||
'report_count' => 2,
|
||||
'operation_count' => 1,
|
||||
],
|
||||
]);
|
||||
}
|
||||
@ -0,0 +1,268 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\EnvironmentReview;
|
||||
use App\Models\ManagedEnvironment;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\User;
|
||||
use App\Services\EnvironmentReviews\EnvironmentReviewFingerprint;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\Operations\OperationRunActionEligibility;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('keeps fresh active operation runs on detail-only guidance and disables unsafe follow-up actions in Spec365', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$run = OperationRun::factory()->forTenant($tenant)->create([
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'type' => 'environment.review.compose',
|
||||
'status' => OperationRunStatus::Queued->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
'created_at' => now()->subMinute(),
|
||||
]);
|
||||
|
||||
$decision = spec365OperationRunDecision($run, $user);
|
||||
|
||||
expect(data_get($decision, 'primary_action.key'))->toBe('view_details')
|
||||
->and($decision['freshness_state'])->toBe('fresh_active')
|
||||
->and($decision['disabled_reasons']['reconcile'])->toBe(__('localization.operations.actions.disabled.lifecycle_fresh'))
|
||||
->and($decision['disabled_reasons']['retry'])->toBe(__('localization.operations.actions.disabled.retry_deferred'));
|
||||
});
|
||||
|
||||
it('fails closed for stale supported review-compose runs without canonical proof in Spec365', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$snapshot = seedEnvironmentReviewEvidence($tenant, operationRunCount: 0);
|
||||
$fingerprint = app(EnvironmentReviewFingerprint::class)->forSnapshot($tenant, $snapshot);
|
||||
|
||||
$run = OperationRun::factory()->forTenant($tenant)->create([
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'type' => 'environment.review.compose',
|
||||
'status' => OperationRunStatus::Queued->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
'created_at' => now()->subMinutes(10),
|
||||
'context' => [
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'review_fingerprint' => $fingerprint,
|
||||
],
|
||||
]);
|
||||
|
||||
$decision = spec365OperationRunDecision($run, $user);
|
||||
|
||||
expect(data_get($decision, 'primary_action.key'))->toBe('view_details')
|
||||
->and(spec365OperationRunActionKeys($decision['secondary_actions']))->not->toContain('reconcile')
|
||||
->and($decision['disabled_reasons']['reconcile'])->toBe(__('localization.operations.actions.disabled.insufficient_proof'))
|
||||
->and($decision['disabled_reasons']['retry'])->toBe(__('localization.operations.actions.disabled.retry_deferred'));
|
||||
});
|
||||
|
||||
it('offers a confirmed reconcile action for stale supported review-compose runs when canonical proof exists in Spec365', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$snapshot = seedEnvironmentReviewEvidence($tenant, operationRunCount: 0);
|
||||
$fingerprint = app(EnvironmentReviewFingerprint::class)->forSnapshot($tenant, $snapshot);
|
||||
|
||||
$run = OperationRun::factory()->forTenant($tenant)->create([
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'type' => 'environment.review.compose',
|
||||
'status' => OperationRunStatus::Queued->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
'created_at' => now()->subMinutes(10),
|
||||
'context' => [
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'review_fingerprint' => $fingerprint,
|
||||
],
|
||||
]);
|
||||
|
||||
spec365UnitReadyReviewTruth($tenant, $user, $fingerprint, (int) $snapshot->getKey());
|
||||
|
||||
$decision = spec365OperationRunDecision($run, $user);
|
||||
|
||||
expect(data_get($decision, 'primary_action.key'))->toBe('reconcile')
|
||||
->and(data_get($decision, 'primary_action.requires_confirmation'))->toBeTrue()
|
||||
->and($decision['mutation_scope'])->toBe(__('localization.operations.actions.mutation_scope_reconcile'))
|
||||
->and($decision['attention_reason'])->toBe(__('localization.operations.actions.attention.reconcile_available'))
|
||||
->and($decision['disabled_reasons']['retry'])->toBe(__('localization.operations.actions.disabled.retry_deferred'));
|
||||
});
|
||||
|
||||
it('offers reconcile for stale running supported review-compose runs in Spec365', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$snapshot = seedEnvironmentReviewEvidence($tenant, operationRunCount: 0);
|
||||
$fingerprint = app(EnvironmentReviewFingerprint::class)->forSnapshot($tenant, $snapshot);
|
||||
|
||||
$run = OperationRun::factory()->forTenant($tenant)->create([
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'type' => 'environment.review.compose',
|
||||
'status' => OperationRunStatus::Running->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
'started_at' => now()->subMinutes(20),
|
||||
'created_at' => now()->subMinutes(20),
|
||||
'context' => [
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'review_fingerprint' => $fingerprint,
|
||||
],
|
||||
]);
|
||||
|
||||
spec365UnitReadyReviewTruth($tenant, $user, $fingerprint, (int) $snapshot->getKey());
|
||||
|
||||
$decision = spec365OperationRunDecision($run, $user);
|
||||
|
||||
expect(data_get($decision, 'primary_action.key'))->toBe('reconcile')
|
||||
->and($decision['freshness_state'])->toBe('likely_stale');
|
||||
});
|
||||
|
||||
it('fails closed for unsupported operation types in Spec365', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$run = OperationRun::factory()->forTenant($tenant)->create([
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'type' => 'unknown.operation',
|
||||
'status' => OperationRunStatus::Queued->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
'created_at' => now()->subMinutes(10),
|
||||
]);
|
||||
|
||||
$decision = spec365OperationRunDecision($run, $user);
|
||||
|
||||
expect($decision['high_risk'])->toBeTrue()
|
||||
->and(data_get($decision, 'primary_action.key'))->toBe('view_details')
|
||||
->and($decision['disabled_reasons']['reconcile'])->toBe(__('localization.operations.actions.disabled.unsupported_reconcile'))
|
||||
->and($decision['disabled_reasons']['retry'])->toBe(__('localization.operations.actions.disabled.high_risk_retry'));
|
||||
});
|
||||
|
||||
it('fails closed for review viewers who lack the execution capability in Spec365', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'readonly', workspaceRole: 'readonly');
|
||||
|
||||
$run = OperationRun::factory()->forTenant($tenant)->create([
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'type' => 'environment.review.compose',
|
||||
'status' => OperationRunStatus::Queued->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
'created_at' => now()->subMinutes(10),
|
||||
]);
|
||||
|
||||
$decision = spec365OperationRunDecision($run, $user);
|
||||
|
||||
expect(data_get($decision, 'primary_action.key'))->toBe('view_details')
|
||||
->and(spec365OperationRunActionKeys($decision['secondary_actions']))->not->toContain('reconcile')
|
||||
->and($decision['disabled_reasons']['reconcile'])->toBe(__('localization.operations.actions.disabled.missing_capability'));
|
||||
});
|
||||
|
||||
it('returns no enabled actions outside the actor workspace scope in Spec365', function (): void {
|
||||
[$owner, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$outsider = User::factory()->create();
|
||||
|
||||
$run = OperationRun::factory()->forTenant($tenant)->create([
|
||||
'user_id' => (int) $owner->getKey(),
|
||||
'type' => 'environment.review.compose',
|
||||
'status' => OperationRunStatus::Queued->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
'created_at' => now()->subMinutes(10),
|
||||
]);
|
||||
|
||||
$decision = spec365OperationRunDecision($run, $outsider);
|
||||
|
||||
expect($decision['primary_action'])->toBeNull()
|
||||
->and($decision['disabled_reasons']['view_details'])->toBe(__('localization.operations.actions.disabled.scope_unavailable'))
|
||||
->and($decision['disabled_reasons']['reconcile'])->toBe(__('localization.operations.actions.disabled.scope_unavailable'))
|
||||
->and($decision['attention_reason'])->toBe(__('localization.operations.actions.attention.scope_unavailable'));
|
||||
});
|
||||
|
||||
it('keeps high-risk restore runs off primary mutation actions and forbids success-forcing controls in Spec365', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$run = OperationRun::factory()->forTenant($tenant)->create([
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'type' => 'restore.execute',
|
||||
'status' => OperationRunStatus::Queued->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
'created_at' => now()->subMinutes(10),
|
||||
]);
|
||||
|
||||
$decision = spec365OperationRunDecision($run, $user);
|
||||
$enabledKeys = array_merge(
|
||||
[data_get($decision, 'primary_action.key')],
|
||||
spec365OperationRunActionKeys($decision['secondary_actions']),
|
||||
);
|
||||
|
||||
expect($decision['high_risk'])->toBeTrue()
|
||||
->and(data_get($decision, 'primary_action.key'))->toBe('view_restore_details')
|
||||
->and($decision['disabled_reasons']['retry'])->toBe(__('localization.operations.actions.disabled.high_risk_retry'))
|
||||
->and($enabledKeys)->not->toContain(
|
||||
'retry_restore',
|
||||
'restore_reexecute',
|
||||
'force_complete',
|
||||
'mark_succeeded',
|
||||
'delete_run',
|
||||
'purge_run',
|
||||
);
|
||||
|
||||
foreach ([
|
||||
'retry_restore',
|
||||
'restore_reexecute',
|
||||
'force_complete',
|
||||
'mark_succeeded',
|
||||
'delete_run',
|
||||
'purge_run',
|
||||
] as $action) {
|
||||
expect($decision['disabled_reasons'])->not->toHaveKey($action)
|
||||
->and(spec365OperationRunActionKeys($decision['disabled_actions']))->not->toContain($action);
|
||||
}
|
||||
});
|
||||
|
||||
function spec365UnitReadyReviewTruth(
|
||||
ManagedEnvironment $tenant,
|
||||
User $user,
|
||||
string $fingerprint,
|
||||
int $snapshotId,
|
||||
): EnvironmentReview {
|
||||
$publishedRun = OperationRun::factory()->forTenant($tenant)->create([
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'initiator_name' => $user->name,
|
||||
'type' => 'environment.review.compose',
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
'completed_at' => now()->subMinutes(5),
|
||||
'context' => [
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'review_fingerprint' => $fingerprint,
|
||||
],
|
||||
]);
|
||||
|
||||
return EnvironmentReview::factory()->ready()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'evidence_snapshot_id' => $snapshotId,
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
'operation_run_id' => (int) $publishedRun->getKey(),
|
||||
'fingerprint' => $fingerprint,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
function spec365OperationRunDecision(OperationRun $run, ?User $user): array
|
||||
{
|
||||
return app(OperationRunActionEligibility::class)->forRun($run, $user);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $actions
|
||||
* @return list<string>
|
||||
*/
|
||||
function spec365OperationRunActionKeys(array $actions): array
|
||||
{
|
||||
return array_values(array_filter(array_map(
|
||||
static fn (array $action): ?string => is_string($action['key'] ?? null) ? (string) $action['key'] : null,
|
||||
$actions,
|
||||
)));
|
||||
}
|
||||
@ -0,0 +1,218 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\EnvironmentReview;
|
||||
use App\Models\EvidenceSnapshot;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\ReviewPack;
|
||||
use App\Support\Evidence\EvidenceCompletenessState;
|
||||
use App\Support\Evidence\EvidenceSnapshotStatus;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\Operations\OperationRunActionEligibility;
|
||||
use App\Support\RestoreRunStatus;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('prefers the related environment review as the primary safe action in Spec365', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$snapshot = seedEnvironmentReviewEvidence($tenant, operationRunCount: 0);
|
||||
|
||||
$run = OperationRun::factory()->forTenant($tenant)->create([
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'type' => 'environment.review.compose',
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
]);
|
||||
|
||||
EnvironmentReview::factory()->ready()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'evidence_snapshot_id' => (int) $snapshot->getKey(),
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
'operation_run_id' => (int) $run->getKey(),
|
||||
]);
|
||||
|
||||
$decision = app(OperationRunActionEligibility::class)->forRun($run->fresh(), $user);
|
||||
|
||||
expect(data_get($decision, 'primary_action.key'))->toBe('view_review')
|
||||
->and(data_get($decision, 'primary_action.label'))->toBe(__('localization.operations.actions.view_review'))
|
||||
->and(data_get($decision, 'primary_action.requires_confirmation'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('prefers the related evidence snapshot as the primary safe action in Spec365', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$run = OperationRun::factory()->forTenant($tenant)->create([
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'type' => 'tenant.evidence.snapshot.generate',
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
]);
|
||||
|
||||
EvidenceSnapshot::query()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'operation_run_id' => (int) $run->getKey(),
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
'fingerprint' => hash('sha256', 'spec365-evidence-'.$run->getKey()),
|
||||
'status' => EvidenceSnapshotStatus::Active->value,
|
||||
'completeness_state' => EvidenceCompletenessState::Complete->value,
|
||||
'summary' => ['source' => 'spec365'],
|
||||
'generated_at' => now(),
|
||||
]);
|
||||
|
||||
$decision = app(OperationRunActionEligibility::class)->forRun($run->fresh(), $user);
|
||||
|
||||
expect(data_get($decision, 'primary_action.key'))->toBe('view_evidence')
|
||||
->and(data_get($decision, 'primary_action.label'))->toBe(__('localization.operations.actions.view_evidence'));
|
||||
});
|
||||
|
||||
it('prefers the related review pack as the primary safe action in Spec365', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$run = OperationRun::factory()->forTenant($tenant)->create([
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'type' => 'environment.review_pack.generate',
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
]);
|
||||
|
||||
ReviewPack::factory()->ready()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'managed_environment_id' => (int) $tenant->getKey(),
|
||||
'operation_run_id' => (int) $run->getKey(),
|
||||
'initiated_by_user_id' => (int) $user->getKey(),
|
||||
]);
|
||||
|
||||
$decision = app(OperationRunActionEligibility::class)->forRun($run->fresh(), $user);
|
||||
|
||||
expect(data_get($decision, 'primary_action.key'))->toBe('view_report')
|
||||
->and(data_get($decision, 'primary_action.label'))->toBe(__('localization.operations.actions.view_report'));
|
||||
});
|
||||
|
||||
it('maps partial inventory sync runs to affected-family drilldown in Spec365', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$run = OperationRun::factory()->forTenant($tenant)->create([
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'type' => 'inventory.sync',
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::PartiallySucceeded->value,
|
||||
]);
|
||||
|
||||
$decision = app(OperationRunActionEligibility::class)->forRun($run->fresh(), $user);
|
||||
|
||||
expect(data_get($decision, 'primary_action.key'))->toBe('view_affected_families')
|
||||
->and(data_get($decision, 'primary_action.label'))->toBe(__('localization.operations.actions.view_affected_families'));
|
||||
});
|
||||
|
||||
it('maps blocked backup executions with backup truth to backup details in Spec365', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$backupSet = BackupSet::factory()->for($tenant)->create(['status' => 'completed']);
|
||||
|
||||
$run = OperationRun::factory()->forTenant($tenant)->create([
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'type' => 'backup.schedule.execute',
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Blocked->value,
|
||||
'context' => [
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
],
|
||||
]);
|
||||
|
||||
$decision = app(OperationRunActionEligibility::class)->forRun($run->fresh(), $user);
|
||||
|
||||
expect(data_get($decision, 'primary_action.key'))->toBe('view_backup_details')
|
||||
->and(data_get($decision, 'primary_action.label'))->toBe(__('localization.operations.actions.view_backup_details'));
|
||||
});
|
||||
|
||||
it('prefers restore details over mutation actions for high-risk restore runs in Spec365', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$backupSet = BackupSet::factory()->for($tenant)->create(['status' => 'completed']);
|
||||
$run = OperationRun::factory()->forTenant($tenant)->create([
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'type' => 'restore.execute',
|
||||
'status' => OperationRunStatus::Queued->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
'created_at' => now()->subMinutes(10),
|
||||
]);
|
||||
|
||||
$restoreRun = RestoreRun::withoutEvents(fn (): RestoreRun => RestoreRun::factory()
|
||||
->for($tenant, 'tenant')
|
||||
->for($backupSet)
|
||||
->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'operation_run_id' => (int) $run->getKey(),
|
||||
'status' => RestoreRunStatus::Completed->value,
|
||||
'is_dry_run' => false,
|
||||
]));
|
||||
|
||||
$run->forceFill([
|
||||
'context' => [
|
||||
'restore_run_id' => (int) $restoreRun->getKey(),
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
],
|
||||
])->save();
|
||||
|
||||
$decision = app(OperationRunActionEligibility::class)->forRun($run->fresh(), $user);
|
||||
|
||||
expect($decision['high_risk'])->toBeTrue()
|
||||
->and(data_get($decision, 'primary_action.key'))->toBe('view_restore_details')
|
||||
->and(data_get($decision, 'primary_action.color'))->toBe('warning')
|
||||
->and(data_get($decision, 'primary_action.requires_confirmation'))->toBeFalse()
|
||||
->and(spec365OperationRunPrimaryActionKeys($decision['secondary_actions']))->toContain('reconcile');
|
||||
});
|
||||
|
||||
it('keeps failed restore runs on restore details without unsafe high-risk actions in Spec365', function (): void {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
|
||||
$backupSet = BackupSet::factory()->for($tenant)->create(['status' => 'completed']);
|
||||
$run = OperationRun::factory()->forTenant($tenant)->create([
|
||||
'user_id' => (int) $user->getKey(),
|
||||
'type' => 'restore.execute',
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Failed->value,
|
||||
]);
|
||||
|
||||
$restoreRun = RestoreRun::withoutEvents(fn (): RestoreRun => RestoreRun::factory()
|
||||
->failedOutcome()
|
||||
->for($tenant, 'tenant')
|
||||
->for($backupSet)
|
||||
->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'operation_run_id' => (int) $run->getKey(),
|
||||
'status' => RestoreRunStatus::Failed->value,
|
||||
]));
|
||||
|
||||
$run->forceFill([
|
||||
'context' => [
|
||||
'restore_run_id' => (int) $restoreRun->getKey(),
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
],
|
||||
])->save();
|
||||
|
||||
$decision = app(OperationRunActionEligibility::class)->forRun($run->fresh(), $user);
|
||||
|
||||
expect($decision['high_risk'])->toBeTrue()
|
||||
->and(data_get($decision, 'primary_action.key'))->toBe('view_restore_details')
|
||||
->and($decision['disabled_reasons']['retry'])->toBe(__('localization.operations.actions.disabled.high_risk_retry'))
|
||||
->and(spec365OperationRunPrimaryActionKeys($decision['secondary_actions']))->not->toContain('reconcile');
|
||||
});
|
||||
|
||||
/**
|
||||
* @param list<array<string, mixed>> $actions
|
||||
* @return list<string>
|
||||
*/
|
||||
function spec365OperationRunPrimaryActionKeys(array $actions): array
|
||||
{
|
||||
return array_values(array_filter(array_map(
|
||||
static fn (array $action): ?string => is_string($action['key'] ?? null) ? (string) $action['key'] : null,
|
||||
$actions,
|
||||
)));
|
||||
}
|
||||
@ -13,36 +13,36 @@ # UI-003 Operations
|
||||
|
||||
## First Five Seconds
|
||||
|
||||
The page reads as an operations monitor. It communicates execution truth, but it still needs a sharper split between active work, terminal follow-up, and diagnostic history.
|
||||
The page reads as an operations monitor with decision-first follow-up. It exposes run scope, lifecycle/outcome truth, and one safe next action before operators move into diagnostics. The history table now compresses secondary fields below wide desktop widths so the core decision columns remain visible.
|
||||
|
||||
## Productization Review
|
||||
|
||||
- Decision-first: medium; monitoring tends to be chronological.
|
||||
- Decision-first: high; run rows and detail summaries surface a resolver-owned safe next action and concise attention context.
|
||||
- Evidence-first: OperationRun records are the source.
|
||||
- Context: workspace route is explicit.
|
||||
- Customer/auditor safety: not customer-facing by default.
|
||||
- Diagnostics: appropriate as the primary mode here, but should not imply governance health.
|
||||
- Diagnostics: secondary; raw/support context remains gated behind detail/diagnostic affordances.
|
||||
|
||||
## Information Inventory
|
||||
|
||||
The page exposes run state, status, recent work, and likely links to run detail. Execution outcome is visible; governance result and artifact truth remain separate surfaces.
|
||||
The page exposes run state, status, scope, recent work, attention reason, and safe next action. Execution outcome is visible; governance result and artifact truth remain separate surfaces and related artifacts are opened through canonical OperationRun links. In the history table, operation context absorbs status/scope/timing on constrained widths while secondary columns remain available on wider screens.
|
||||
|
||||
## Dangerous Actions
|
||||
|
||||
Potential actions include retry, cancel, investigate, or open related artifacts. Target design should keep terminal actions confirmation-gated and secondary to run inspection.
|
||||
Potential actions are intentionally constrained to inspect/open related artifacts or a confirmation-gated Reconcile action. Retry remains unavailable unless a repo-verified safe seam exists, and high-risk restore/promotion states must not expose retry, re-execute, force-complete, mark-succeeded, delete, or purge controls.
|
||||
|
||||
## Scores
|
||||
|
||||
| IA | Density | User Clarity | Sellability | Disclosure | Hierarchy | DS Fit | A11y | Responsive | Components | UX Writing | Perf |
|
||||
| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||
| 3 | 4 | 4 | 3 | 4 | 3 | 4 | 3 | 3 | 4 | 3 | 4 |
|
||||
| 3 | 4 | 4 | 3 | 4 | 4 | 4 | 3 | 4 | 4 | 4 | 4 |
|
||||
|
||||
## Top Issues
|
||||
|
||||
1. Execution truth must stay visually separate from product health.
|
||||
2. Detail drilldowns need consistent evidence/result links.
|
||||
3. Responsive/table behavior was not captured.
|
||||
2. Detail drilldowns must keep evidence/result links canonical and scope-safe.
|
||||
3. Wider-screen target composition should still evolve toward a true attention queue plus proof rail, but the current table no longer requires horizontal scanning for the core decision on constrained desktop widths.
|
||||
|
||||
## Target Direction
|
||||
|
||||
P1 strategic target. Use one monitoring pattern for active, failed, partial, and completed runs, with evidence/result links delegated to shared OperationRun UX contracts.
|
||||
P1 strategic target. Use one monitoring pattern for active, failed, partial, and completed runs, with safe next actions and evidence/result links delegated to shared OperationRun UX contracts.
|
||||
|
||||
@ -0,0 +1,71 @@
|
||||
# Spec 365 Action Eligibility Matrix
|
||||
|
||||
This matrix is the product and test contract for `OperationRunActionEligibility`. It is derived from existing OperationRun truth and does not introduce new persisted status/outcome values.
|
||||
|
||||
## Global Rules
|
||||
|
||||
- At most one primary action is visible per run.
|
||||
- If eligibility is uncertain, the action is unavailable.
|
||||
- Direct action execution must enforce the same authorization/scope rules as UI visibility.
|
||||
- Reconcile writes through `AdapterRunReconciler` and `OperationRunService`.
|
||||
- Retry is unavailable unless a repo-verified safe non-high-risk retry/start seam exists.
|
||||
- Restore, tenant mutation, destructive mutation, unknown operation, and high-risk operation are never retryable in this spec.
|
||||
- Force Complete, Mark Succeeded, Delete, Purge, and Restore Re-execute are always forbidden.
|
||||
- Related actions use canonical metadata and existing link/policy seams.
|
||||
- Diagnostics are secondary and capability-gated.
|
||||
|
||||
## Matrix
|
||||
|
||||
| Family | Canonical example | Run state | Primary action | Reconcile | Retry | Related | Diagnostics | Disabled / attention reason | Required tests |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| Queue | any supported operation | fresh queued | View details | no | no | maybe | yes if capability | Operation is still within expected lifecycle window | unit, browser |
|
||||
| Queue | any supported operation | stale queued | Reconcile when adapter/proof exists, otherwise View details | maybe | no by default | maybe | yes if capability | Waiting longer than expected; reconciliation may be safe only with adapter proof | unit, feature |
|
||||
| Queue | any supported operation | stale running | Reconcile when adapter/proof exists, otherwise View details | maybe | no by default | maybe | yes if capability | Running longer than expected; fail closed without proof | unit, feature |
|
||||
| Review compose | `environment.review.compose` | related review already available / reconciled | View review | no after reconciled | only if failed and safe seam verified | yes | yes if capability | Review result already exists | unit, feature, browser |
|
||||
| Review compose | `environment.review.compose` | stale eligible with adapter proof | Reconcile | yes | no by default | maybe after reconcile | yes if capability | Existing review proof can reconcile this run | unit, feature |
|
||||
| Review pack / report | `environment.review_pack.generate` | artifact already available / reconciled | View report | no after reconciled | only if safe seam verified | yes | yes if capability | Report artifact already exists | unit, feature, browser |
|
||||
| Evidence | `tenant.evidence.snapshot.generate` | evidence snapshot already available / reconciled | View evidence | no after reconciled | only if safe seam verified | yes | yes if capability | Evidence snapshot already exists | unit, feature, browser |
|
||||
| Sync | `inventory.sync` / `policy.sync` | partial | View affected families | no unless adapter proof says terminal reconciliation is safe | only if safe seam verified | maybe | yes if capability | Some resource families completed; others blocked or failed | unit, feature, browser |
|
||||
| Sync | `inventory.sync` / `policy.sync` | blocked | View missing permissions/details | no unless adapter proof says terminal reconciliation is safe | only if safe seam verified | maybe | yes if capability | Provider access or precondition blocked capture | unit, feature |
|
||||
| Backup | `backup.schedule.execute` | partial | View backup details | no unless adapter proof says terminal reconciliation is safe | only if safe seam verified and non-destructive | yes if backup set exists | yes if capability | Backup completed with partial results | unit, feature |
|
||||
| Backup | `backup.schedule.execute` | blocked | View missing permissions/details | no unless adapter proof says terminal reconciliation is safe | only if safe seam verified and non-destructive | maybe | yes if capability | Backup blocked by access or precondition | unit, feature, browser |
|
||||
| Restore | `restore.execute` | verification required | View restore details | maybe only if Spec364 verification proof is sufficient | no | yes | yes if capability | High-risk operation requires verification; retry unavailable | unit, feature, browser |
|
||||
| Restore | `restore.execute` | partial | View restore details | maybe only if Spec364 proof is sufficient | no | yes | yes if capability | Restore completed only partially; retry unavailable | unit, feature |
|
||||
| Restore | `restore.execute` | blocked | View restore details | no unless Spec364 proof allows safe blocked reconciliation | no | yes | yes if capability | Restore blocked; high-risk retry unavailable | unit, feature, browser |
|
||||
| Restore | `restore.execute` | failed | View restore details | no unless Spec364 proof allows safe terminal reconciliation | no | maybe | yes if capability | Restore failed; retry/re-execute/force-success unavailable | unit, feature, browser |
|
||||
| High-risk mutation | `promotion.execute` / tenant mutation | failed/blocked/unknown | View details | no unless explicit adapter proof exists | no | maybe | yes if capability | High-risk operation cannot be retried from this view | unit |
|
||||
| Unknown | unmapped operation type | any terminal/active state | View details | no | no | no unless existing link resolves | yes if capability | Unsupported operation type | unit, feature |
|
||||
| RBAC denied | any | otherwise eligible | none or disabled safe label | no direct execution | no direct execution | no direct execution | no if missing capability | User lacks required capability | feature, browser |
|
||||
| Cross-scope denied | any | otherwise eligible | none | no direct execution | no direct execution | no direct execution | no | Operation is outside permitted workspace/environment | feature |
|
||||
|
||||
## Forbidden Action Assertions
|
||||
|
||||
Tests must assert these labels/actions do not exist for restore/high-risk runs:
|
||||
|
||||
- Retry restore
|
||||
- Re-execute restore
|
||||
- Force complete
|
||||
- Mark succeeded
|
||||
- Ignore error and complete
|
||||
- Manually mark successful
|
||||
- Delete run
|
||||
- Purge run
|
||||
|
||||
## Retry Close-Out Template
|
||||
|
||||
Implementation must update this section before completion:
|
||||
|
||||
| Operation family | Safe retry seam found? | Implemented? | Disabled/deferred reason |
|
||||
|---|---|---|---|
|
||||
| Review compose | no generic retry seam verified; reconcile seam exists | no retry | Retry is deferred; stale runs use Reconcile only when adapter proof and RBAC allow it |
|
||||
| Review pack/report | no generic retry seam verified | no retry | Retry is deferred; related artifact links are safe when canonical metadata resolves |
|
||||
| Evidence snapshot | no generic retry seam verified | no retry | Retry is deferred; related evidence links are safe when canonical metadata resolves |
|
||||
| Sync/capture | no generic retry seam verified | no retry | Retry is deferred; partial/blocked runs open affected-family/details surfaces |
|
||||
| Backup capture | no generic retry seam verified | no retry | Retry is deferred; backup details are safe when backup truth resolves |
|
||||
| Restore | no by spec | no | High-risk operations cannot be retried from this view |
|
||||
|
||||
## Acknowledge Close-Out Template
|
||||
|
||||
| Seam checked | Existing clean seam? | Implemented? | Deferral reason |
|
||||
|---|---|---|---|
|
||||
| OperationRun acknowledge/note/audit | no clean OperationRun-specific acknowledge/note seam verified | no | Acknowledge would create a local success-like state without existing domain truth; defer to a future explicit workflow spec |
|
||||
@ -0,0 +1,125 @@
|
||||
# Spec 365 Regression Gate Matrix
|
||||
|
||||
This matrix is the final Operations UI/operator action gate for the OperationRun/Reconciliation program. It links representative states from Specs 358-364 to the Spec 365 UI/action expectations and test families.
|
||||
|
||||
## Matrix
|
||||
|
||||
| Family | State | Required visible decision truth | Required absent/default-hidden truth | Primary action expectation | Test family |
|
||||
|---|---|---|---|---|---|
|
||||
| Queue | fresh queued | Queued/running lifecycle is still fresh; status/outcome/freshness/scope visible | Retry, Reconcile, raw JSON, worker blame when not stale | View details or no mutation action | Unit, Browser |
|
||||
| Queue | stale queued | Longer-than-expected attention state; reason and scope visible | "Waiting for worker" as certain claim, raw queue payload | Reconcile only if adapter/proof exists, else View details | Unit, Feature, Browser |
|
||||
| Queue | stale running | Stale running attention state; lifecycle reconciliation guidance | stale plus fresh contradictory copy | Reconcile only if adapter/proof exists, else View details | Unit, Feature |
|
||||
| Reconciliation | review already available | Review already available; reconciled from adapter; related review proof | SQLSTATE, duplicate key, raw fingerprint/constraint | View review | Unit, Feature, Browser |
|
||||
| Reconciliation | report/review-pack already available | Report/review-pack artifact available; related artifact proof | raw report payload, signed URL in context | View report | Unit, Feature, Browser |
|
||||
| Reconciliation | evidence snapshot already available | Evidence snapshot available; related evidence proof | raw Graph payload, signed URL in context | View evidence | Unit, Feature, Browser |
|
||||
| Sync | partial | Completed with partial results; affected families or blocked family count | provider trace, raw job payload | View affected families/details | Unit, Feature, Browser |
|
||||
| Sync | blocked | Blocked reason and missing permission/precondition summary | access token, client secret, full provider trace | View missing permissions/details | Unit, Feature |
|
||||
| Backup | partial | Backup completed with partial results; safe scope | raw backup payload by default | View backup details | Unit, Feature |
|
||||
| Backup | blocked | Backup blocked; missing permission/precondition summary | raw provider exception by default | View missing permissions/details | Unit, Feature, Browser |
|
||||
| Restore | verification required | Restore verification required; provider accepted but target not verified | Retry restore, Re-execute restore, Force complete, Mark succeeded | View restore details | Unit, Feature, Browser |
|
||||
| Restore | partial | Restore partial; verified/unverified step summary | Force success copy, raw provider payload default | View restore details | Unit, Feature |
|
||||
| Restore | blocked | Restore blocked; approval/provider/access reason | Retry restore, force-success, stack trace | View restore details | Unit, Feature, Browser |
|
||||
| Restore | failed | Restore failed with high-risk guard | Retry restore, Re-execute restore, Force complete, Mark succeeded | View restore details | Unit, Feature, Browser |
|
||||
| RBAC | action denied | Action unavailable or disabled reason for missing capability | Direct action success, hidden capability names as primary copy | No action execution | Feature, Browser |
|
||||
| Scope | cross-workspace denied | No cross-scope hint for inaccessible run/action | Related cross-workspace object link | No action execution | Feature |
|
||||
| Raw leakage | customer-readable default surface | Calm summary, safe reason, diagnostics secondary | SQLSTATE, Guzzle, stack trace, access token, client secret, serialized job, internal constraint names | N/A | Browser |
|
||||
|
||||
## Implemented Test Files
|
||||
|
||||
Direct Spec 365 tests:
|
||||
|
||||
```text
|
||||
apps/platform/tests/Unit/Support/Operations/Spec365OperationRunActionEligibilityTest.php
|
||||
apps/platform/tests/Unit/Support/Operations/Spec365OperationRunPrimaryActionTest.php
|
||||
apps/platform/tests/Feature/Operations/Spec365OperationRunOperatorActionsTest.php
|
||||
apps/platform/tests/Browser/Spec365OperationsUiOperatorActionsSmokeTest.php
|
||||
```
|
||||
|
||||
High-risk guard, RBAC, audit, related-link, retry-unavailable, idempotency, and raw-leakage assertions are consolidated into the files above plus existing OperationRun presentation regressions listed below.
|
||||
|
||||
Existing regression files exercised during implementation:
|
||||
|
||||
```text
|
||||
apps/platform/tests/Feature/Operations/Spec359OperationRunAdapterReconciliationTest.php
|
||||
apps/platform/tests/Feature/EnvironmentReview/Spec359ReviewComposeReconciliationTest.php
|
||||
apps/platform/tests/Feature/Operations/Spec364RestoreExecuteReconciliationTest.php
|
||||
apps/platform/tests/Feature/Operations/TenantlessOperationRunViewerTest.php
|
||||
apps/platform/tests/Feature/Guards/OperationRunLinkContractGuardTest.php
|
||||
apps/platform/tests/Feature/Monitoring/MonitoringOperationsTest.php
|
||||
apps/platform/tests/Feature/Monitoring/OperationRunResolvedReferencePresentationTest.php
|
||||
apps/platform/tests/Feature/Monitoring/OperationLifecycleFreshnessPresentationTest.php
|
||||
apps/platform/tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php
|
||||
apps/platform/tests/Feature/Filament/OperationRunListFiltersTest.php
|
||||
apps/platform/tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php
|
||||
apps/platform/tests/Browser/Spec360OperationRunCanonicalCutoverSmokeTest.php
|
||||
```
|
||||
|
||||
## Regression Commands
|
||||
|
||||
Direct Spec 365:
|
||||
|
||||
```bash
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec365
|
||||
```
|
||||
|
||||
OperationRun program regressions:
|
||||
|
||||
```bash
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec358
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec359
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec360
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec361
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec362
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec363
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec364
|
||||
```
|
||||
|
||||
Review/report/customer regressions:
|
||||
|
||||
```bash
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/CustomerReviewWorkspaceSmokeTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Spec357ReportProfilesSmokeTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/Spec357RenderedReportProfileTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/ReviewPackDownloadTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/EnvironmentReviewDerivedReviewPackTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ReviewPack/EnvironmentReviewExecutivePackTest.php
|
||||
```
|
||||
|
||||
Operations/provider regressions:
|
||||
|
||||
```bash
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Operations
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/ProviderConnections
|
||||
```
|
||||
|
||||
Known external failures to check separately, not silently bundle:
|
||||
|
||||
```bash
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Spec347ReviewPackOutputReadinessSmokeTest.php
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/EnvironmentReviewHeaderDisciplineTest.php
|
||||
```
|
||||
|
||||
Quality:
|
||||
|
||||
```bash
|
||||
cd apps/platform && ./vendor/bin/pint --dirty
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Optional direct browser gate:
|
||||
|
||||
```bash
|
||||
cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Browser/Spec365OperationsUiOperatorActionsSmokeTest.php
|
||||
```
|
||||
|
||||
## Close-Out Template
|
||||
|
||||
Implementation must update this matrix or `tasks.md` with:
|
||||
|
||||
- Spec365 direct test result: 20 PHP tests / 118 assertions and 3 browser tests / 42 assertions passed locally after implementation.
|
||||
- Spec358-364 regression result: targeted Spec359, Spec360 browser, Spec364, OperationRun viewer/link, monitoring, and resource presentation regressions passed locally; final filter sweep is recorded in `tasks.md`.
|
||||
- Browser smoke result: `php artisan test --compact tests/Browser/Spec365OperationsUiOperatorActionsSmokeTest.php` passed.
|
||||
- Known external failures and whether they predate Spec365: none observed in the executed targeted lanes.
|
||||
- Implemented/deferred retry families: retry deferred for all families; Reconcile implemented only through adapter proof and existing OperationRun service writes.
|
||||
- Acknowledge implemented/deferred decision: deferred because no clean OperationRun acknowledge seam exists.
|
||||
- Raw leakage guard result: browser smoke asserts absence of `SQLSTATE`, `Guzzle`, `stack trace`, `access token`, `client secret`, `serialized job`, and the review fingerprint unique-index name on default surfaces.
|
||||
@ -0,0 +1,71 @@
|
||||
# Requirements Quality Checklist: Spec 365
|
||||
|
||||
**Feature**: Operations UI Operator Actions & Regression Gate
|
||||
**Created**: 2026-06-07
|
||||
**Purpose**: Validate that Spec 365 prep artifacts are clear, bounded, testable, and aligned with repository truth before implementation.
|
||||
|
||||
## Content Quality
|
||||
|
||||
- [x] No implementation details leak into the product problem statement beyond repo-seam constraints needed for safety.
|
||||
- [x] User value and operator/customer impact are explicit.
|
||||
- [x] Scope is bounded to existing Operations surfaces and existing OperationRun/Reconciliation truth.
|
||||
- [x] Out-of-scope items explicitly exclude new adapters, generic retry framework, restore retry, force success, delete/purge, and new top-level pages.
|
||||
- [x] Acceptance criteria are measurable.
|
||||
- [x] Localization and raw leakage expectations are stated.
|
||||
- [x] Filament v5 / Livewire v4 constraints are stated.
|
||||
|
||||
## Requirement Completeness
|
||||
|
||||
- [x] Action eligibility resolver inputs and outputs are specified.
|
||||
- [x] Reconcile action safety path is specified through existing registry/reconciler/service seams.
|
||||
- [x] Retry is bounded to repo-verified safe non-high-risk seams and is unavailable otherwise.
|
||||
- [x] High-risk restore/destructive guard is explicit.
|
||||
- [x] Related domain object actions use canonical metadata and existing link/policy seams.
|
||||
- [x] RBAC/capability and workspace/environment scope requirements are explicit.
|
||||
- [x] Audit/action metadata required fields are explicit.
|
||||
- [x] Outcome summary families and regression matrix states are explicit.
|
||||
- [x] No raw technical leakage criteria are explicit.
|
||||
- [x] No new top-level navigation/page sprawl criteria are explicit.
|
||||
- [x] Mutation-scope disclosure is explicit for state-changing operator actions.
|
||||
|
||||
## Constitution / Guardrail Alignment
|
||||
|
||||
- [x] UI Surface Impact is classified.
|
||||
- [x] UI/Productization coverage decision is recorded.
|
||||
- [x] Cross-cutting shared pattern reuse is described.
|
||||
- [x] OperationRun UX Impact is filled.
|
||||
- [x] Provider/platform boundary impact is bounded.
|
||||
- [x] Operator surface contract, decision-first role, and audience-aware disclosure are filled.
|
||||
- [x] Proportionality review is complete for the new resolver abstraction.
|
||||
- [x] Testing/lane/runtime impact is complete.
|
||||
- [x] Filament output contract is included.
|
||||
|
||||
## Ambiguity and Deferrals
|
||||
|
||||
- [x] Generic retry ambiguity is resolved by fail-closed repo-verification rule.
|
||||
- [x] Acknowledge ambiguity is resolved by optional/defer-if-no-clean-seam rule.
|
||||
- [x] Capability ambiguity is resolved by "existing capability registry first, add minimal constants only if needed".
|
||||
- [x] Report vs review-pack ambiguity is bounded to existing safe related metadata/link seams.
|
||||
- [x] UI audit doc update ambiguity is assigned to implementation close-out.
|
||||
- [x] Spec363 is included in the final Spec358-364 regression command set.
|
||||
- [x] Denied action audit/log behavior is covered by implementation tasks.
|
||||
|
||||
## Manual Prep Analysis Result
|
||||
|
||||
- **Result**: PASS after prep adjustments.
|
||||
- **Critical issues**: none.
|
||||
- **Non-critical implementation watch items**:
|
||||
- Verify retry seams before exposing retry.
|
||||
- Verify whether acknowledge has a clean existing seam before implementing it.
|
||||
- Keep new resolver derived and non-persisted.
|
||||
- Ensure direct action tests cover denial, not only hidden UI.
|
||||
- Update or explicitly close out UI audit coverage artifacts.
|
||||
|
||||
## Readiness
|
||||
|
||||
- [x] `spec.md` is ready for implementation planning.
|
||||
- [x] `plan.md` defines implementation path and stop conditions.
|
||||
- [x] `tasks.md` is dependency ordered and test-oriented.
|
||||
- [x] Action eligibility matrix exists.
|
||||
- [x] Regression gate matrix exists.
|
||||
- [x] No application code was implemented during prep.
|
||||
326
specs/365-operations-ui-operator-actions-regression-gate/plan.md
Normal file
326
specs/365-operations-ui-operator-actions-regression-gate/plan.md
Normal file
@ -0,0 +1,326 @@
|
||||
# Implementation Plan: Operations UI Operator Actions & Regression Gate
|
||||
|
||||
**Branch**: `365-operations-ui-operator-actions-regression-gate` | **Date**: 2026-06-07 | **Spec**: [spec.md](./spec.md)
|
||||
**Input**: Feature specification from `/specs/365-operations-ui-operator-actions-regression-gate/spec.md`
|
||||
|
||||
**Note**: This plan is a preparation artifact only. It defines the implementation path and validation gate; it does not implement application code.
|
||||
|
||||
## Summary
|
||||
|
||||
Spec 365 completes the OperationRun/Reconciliation program by making existing run truth operator-actionable in the existing Operations hub and OperationRun detail. The implementation approach is to add one central derived action eligibility resolver, reuse existing reconciliation and related-link seams, integrate safe actions into existing Filament surfaces, and add a final regression matrix across Specs 358-364.
|
||||
|
||||
The plan intentionally does not introduce a new adapter framework, a generic retry engine, a new persisted action table, or new top-level Operations pages. Retry is limited to operation families with a repo-verified safe retry/start seam; unsupported families fail closed with a disabled/deferred reason.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4.15, Laravel 12.52.0
|
||||
**Primary Dependencies**: Filament 5.2.1, Livewire 4.1.4, Pest 4.3.1, Laravel Sail 1.x
|
||||
**Storage**: PostgreSQL; existing `operation_runs` table/context JSON only. No new table planned.
|
||||
**Testing**: Pest 4 unit/feature/browser tests. Filament action tests must mount Livewire components/pages, not static resource classes.
|
||||
**Validation Lanes**: fast-feedback, confidence, browser.
|
||||
**Target Platform**: Laravel web application under `apps/platform`.
|
||||
**Project Type**: Laravel + Filament application.
|
||||
**Performance Goals**: Operations hub and detail remain DB-only render paths for run status; no Graph/provider calls during UI render.
|
||||
**Constraints**: Fail closed for uncertain actions; no high-risk retry; no force-success; no raw diagnostics by default; no asset changes unless proven necessary.
|
||||
**Scale/Scope**: Existing Operations hub/detail and related domain links. No new top-level IA.
|
||||
|
||||
## Repo Truth Captured During Prep
|
||||
|
||||
- Current branch at prep time: `365-operations-ui-operator-actions-regression-gate`
|
||||
- Baseline HEAD: `3ce1cae7 feat: implement restore high risk operation reconciliation (#435)`
|
||||
- Baseline status before artifacts: clean except the new Spec 365 directory.
|
||||
- Spec 364 baseline context: restore high-risk reconciliation has completed task/checklist artifacts and is treated as the immediate completed predecessor.
|
||||
- Current package baseline from Laravel Boost:
|
||||
- PHP 8.4.15
|
||||
- Laravel 12.52.0
|
||||
- Filament 5.2.1
|
||||
- Livewire 4.1.4
|
||||
- Pest 4.3.1
|
||||
|
||||
Relevant implementation files discovered:
|
||||
|
||||
```text
|
||||
apps/platform/app/Models/OperationRun.php
|
||||
apps/platform/app/Services/OperationRunService.php
|
||||
apps/platform/app/Services/AdapterRunReconciler.php
|
||||
apps/platform/app/Support/Operations/Reconciliation/OperationRunReconciliationRegistry.php
|
||||
apps/platform/app/Support/Operations/Reconciliation/*
|
||||
apps/platform/app/Support/OperationRunType.php
|
||||
apps/platform/app/Support/OperationRunStatus.php
|
||||
apps/platform/app/Support/OperationRunOutcome.php
|
||||
apps/platform/app/Support/OperationCatalog.php
|
||||
apps/platform/app/Support/OperationRunCapabilityResolver.php
|
||||
apps/platform/app/Support/Auth/Capabilities.php
|
||||
apps/platform/app/Policies/OperationRunPolicy.php
|
||||
apps/platform/app/Support/OpsUx/OperationUxPresenter.php
|
||||
apps/platform/app/Support/OpsUx/OperationRunProgressContract.php
|
||||
apps/platform/app/Support/OperationRunLinks.php
|
||||
apps/platform/app/Support/Navigation/RelatedNavigationResolver.php
|
||||
apps/platform/app/Support/Navigation/RelatedActionLabelCatalog.php
|
||||
apps/platform/app/Filament/Pages/Monitoring/Operations.php
|
||||
apps/platform/app/Filament/Resources/OperationRunResource.php
|
||||
apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php
|
||||
apps/platform/app/Services/Audit/AuditRecorder.php
|
||||
apps/platform/app/Services/Audit/WorkspaceAuditLogger.php
|
||||
```
|
||||
|
||||
Repo-specific decisions:
|
||||
|
||||
- `OperationRunService` remains the write seam for OperationRun state/outcome/reconciliation transitions.
|
||||
- `AdapterRunReconciler` and `OperationRunReconciliationRegistry` are the only approved reconciliation execution path.
|
||||
- `OperationRunLinks` / `RelatedNavigationResolver` are the preferred related-object navigation path.
|
||||
- `OperationRunPolicy` and `OperationRunCapabilityResolver` remain the authorization entry points; do not use raw capability strings.
|
||||
- `TenantlessOperationRunViewer::resumeCaptureAction()` is a narrow existing resume-like seam and must be reconciled into the central eligibility model if touched.
|
||||
- No repo-wide generic retry seam was found during prep; broad retry must remain unavailable/deferred unless implementation verifies or adds a bounded safe seam per operation family.
|
||||
|
||||
## UI / Surface Guardrail Plan
|
||||
|
||||
- **Guardrail scope**: changed surfaces.
|
||||
- **Affected routes/pages/actions/states/navigation/panel/provider surfaces**:
|
||||
- `/admin/workspaces/{workspace}/operations`
|
||||
- tenantless OperationRun detail page
|
||||
- OperationRun detail header action groups
|
||||
- related domain object action links
|
||||
- **No-impact class, if applicable**: N/A.
|
||||
- **Native vs custom classification summary**: mixed but Filament-native first. Reuse existing native table/detail actions and existing OperationRun detail sections; avoid new styling systems.
|
||||
- **Shared-family relevance**: status messaging, header actions, related navigation, evidence/report/restore links, diagnostics disclosure.
|
||||
- **State layers in scope**: page, table row, detail header, detail sections, action modal state.
|
||||
- **Audience modes in scope**: operator-MSP and support-platform; customer-readable defaults must remain calm and non-technical.
|
||||
- **Decision/diagnostic/raw hierarchy plan**: decision-first default, diagnostics second, support/raw third.
|
||||
- **Raw/support gating plan**: collapsed and capability-gated.
|
||||
- **One-primary-action / duplicate-truth control**: `OperationRunActionEligibility` output is the single source for primary action and disabled reasons consumed by list/detail/actions.
|
||||
- **Handling modes by drift class or surface**: review-mandatory for high-risk action surface and raw leakage guard.
|
||||
- **Repository-signal treatment**: report-only for existing Operations page audit docs unless implementation materially changes IA; review-mandatory for dangerous-action and customer-safe checks.
|
||||
- **Special surface test profiles**: monitoring-state-page, shared-detail-family.
|
||||
- **Required tests or manual smoke**: functional core + state-contract + browser smoke.
|
||||
- **Exception path and spread control**: none planned. Any generic retry exception must be documented in this feature and covered by tests.
|
||||
- **Active feature PR close-out entry**: Guardrail + Smoke Coverage.
|
||||
- **UI/Productization coverage decision**: Existing strategic Operations page is materially changed; update existing coverage docs or record proportional no-update rationale during implementation close-out.
|
||||
- **Coverage artifacts to update**: update `docs/ui-ux-enterprise-audit/page-reports/ui-003-operations.md` / design matrix when implementation changes layout, action hierarchy, state hierarchy, or screenshots materially. A no-update rationale is allowed only when changes are limited to existing pattern-compatible action/copy wiring, and that rationale must be recorded in `tasks.md` close-out.
|
||||
- **No-impact rationale**: N/A.
|
||||
- **Navigation / Filament provider-panel handling**: no panel provider or navigation change planned.
|
||||
- **Screenshot or page-report need**: screenshot/browser artifact recommended for the final smoke; no new page report required during prep.
|
||||
|
||||
## Shared Pattern & System Fit
|
||||
|
||||
- **Cross-cutting feature marker**: yes.
|
||||
- **Systems touched**: OperationRun UX presenter/progress, related navigation, reconciliation registry/service, authorization policy/capability resolver, audit logging, localization.
|
||||
- **Shared abstractions reused**: `OperationUxPresenter`, `OperationRunProgressContract`, `OperationRunLinks`, `RelatedNavigationResolver`, `AdapterRunReconciler`, `OperationRunService`, `OperationRunPolicy`.
|
||||
- **New abstraction introduced? why?**: Yes, a narrow action eligibility resolver because no existing shared layer combines status/outcome/freshness/risk/adapter/RBAC/scope/related metadata into a single action contract.
|
||||
- **Why the existing abstraction was sufficient or insufficient**: Existing presenters and services are sufficient for summaries, links, and writes, but insufficient for one-primary-action and action permission consistency.
|
||||
- **Bounded deviation / spread control**: The resolver must not create a new operation taxonomy, adapter registry, or persisted action model.
|
||||
|
||||
## OperationRun UX Impact
|
||||
|
||||
- **Touches OperationRun start/completion/link UX?**: yes.
|
||||
- **Central contract reused**: `OperationUxPresenter`, `OperationRunProgressContract`, `OperationRunLinks`, `OperationRunService`.
|
||||
- **Delegated UX behaviors**: artifact links, run detail links, reconciliation result handling, lifecycle result display, tenant/workspace-safe URLs.
|
||||
- **Surface-owned behavior kept local**: visible hierarchy and invocation of approved actions.
|
||||
- **Queued DB-notification policy**: no new policy.
|
||||
- **Terminal notification path**: existing central lifecycle mechanism.
|
||||
- **Exception path**: Generic retry is not approved by this plan. If implemented, it must be through an existing or bounded safe start seam with explicit tests and audit.
|
||||
|
||||
## Provider Boundary & Portability Fit
|
||||
|
||||
- **Shared provider/platform boundary touched?**: yes, bounded.
|
||||
- **Provider-owned seams**: existing provider reason codes and provider failure summaries in canonical OperationRun context.
|
||||
- **Platform-core seams**: OperationRun action eligibility, outcome summaries, high-risk classification, related action display.
|
||||
- **Neutral platform terms / contracts preserved**: operation, outcome, attention, reconcile, retry, verification, partial, blocked, related evidence.
|
||||
- **Retained provider-specific semantics and why**: Provider reason codes remain diagnostics only because they help operators/support understand blocked/partial states.
|
||||
- **Bounded extraction or follow-up path**: none planned.
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before implementation. Re-check after design and before code merge.*
|
||||
|
||||
- Inventory-first: PASS. This spec reads existing OperationRun and related artifact truth only.
|
||||
- Read/write separation: PASS with constraints. Reconcile/retry are explicit operator actions with authorization, confirmation where appropriate, and audit.
|
||||
- Graph contract path: PASS. No Graph calls in UI render or action eligibility. Retry seam, if any, must use existing service/jobs and Graph abstractions.
|
||||
- Deterministic capabilities: PASS. Use `Capabilities` constants and `OperationRunCapabilityResolver`/policies. Add no raw strings.
|
||||
- RBAC-UX: PASS. Preserve non-member/not-entitled 404 and member-missing-capability 403 semantics.
|
||||
- Workspace isolation: PASS. All actions must enforce workspace/environment scope.
|
||||
- Destructive-like actions: PASS. This spec forbids destructive and force-success actions. State-changing reconcile/retry actions require confirmation/audit as appropriate.
|
||||
- Global search: PASS. `OperationRunResource` remains not globally searchable unless separately changed with View/Edit contract.
|
||||
- Run observability: PASS. Reconcile uses existing run write seam; retry creates a run only through safe start seam.
|
||||
- OperationRun start UX: PASS with constraint. Retry must reuse central start UX if implemented.
|
||||
- Ops-UX 3-surface feedback: PASS. No new terminal DB notification behavior planned.
|
||||
- Ops-UX lifecycle: PASS. `OperationRunService` owns state/outcome/reconciliation writes.
|
||||
- Ops-UX summary counts: PASS. No new summary count keys planned; any touched keys must use `OperationSummaryKeys`.
|
||||
- Data minimization: PASS. Raw context hidden/gated; no secrets in audit.
|
||||
- Test governance: PASS. Unit, feature, and browser lane plans are explicit.
|
||||
- Proportionality: PASS. New resolver is justified by safety, RBAC, high-risk guard, and multiple current concrete run families.
|
||||
- No premature abstraction: PASS. One resolver replaces scattered action decision logic and is not a registry/framework.
|
||||
- Persisted truth: PASS. No new independent persisted truth.
|
||||
- Behavioral state: PASS. No new status/outcome family.
|
||||
- UI semantics: PASS. Derived summaries and actions map from existing domain/run truth.
|
||||
- Shared pattern first: PASS. Existing presenters/links/services are reused.
|
||||
- Provider boundary: PASS. Provider details remain diagnostics only.
|
||||
- V1 explicitness / few layers: PASS. One narrow derived layer.
|
||||
- Spec discipline / bloat check: PASS. Proportionality review complete.
|
||||
- Badge semantics: PASS. Implementation must reuse badge/shared status rendering if status-like badges change.
|
||||
- Filament-native UI: PASS. Use native Filament actions/sections and existing shared primitives.
|
||||
- UI/UX surface taxonomy: PASS. Surfaces classified.
|
||||
- Decision-first operating model: PASS. Required default-visible fields and hierarchy defined.
|
||||
- Audience-aware disclosure: PASS. Raw/support content hidden/gated.
|
||||
- Filament UI Action Surface Contract: PASS with implementation tasks.
|
||||
- UI/Productization coverage: PASS. Impact classified; coverage update/no-update rationale required at close-out.
|
||||
|
||||
## Test Governance Check
|
||||
|
||||
- **Test purpose / classification by changed surface**: Unit for resolver/presenter; Feature for actions/RBAC/scope/audit/related links; Browser for Operations UI decision-first and raw leakage guard.
|
||||
- **Affected validation lanes**: fast-feedback, confidence, browser.
|
||||
- **Why this lane mix is the narrowest sufficient proof**: Action safety is mostly deterministic logic plus server-side enforcement; browser coverage is limited to the user-visible matrix and leakage guard.
|
||||
- **Narrowest proving command(s)**:
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec365`
|
||||
- `cd apps/platform && ./vendor/bin/pint --dirty`
|
||||
- `git diff --check`
|
||||
- **Fixture / helper / factory / seed / context cost risks**: Need canonical OperationRun fixtures for each matrix state; keep as explicit Spec365 helpers only.
|
||||
- **Expensive defaults or shared helper growth introduced?**: no; browser fixtures must not become global defaults.
|
||||
- **Heavy-family additions, promotions, or visibility changes**: one explicit browser smoke file.
|
||||
- **Surface-class relief / special coverage rule**: monitoring-state-page/shared-detail-family special coverage applies.
|
||||
- **Closing validation and reviewer handoff**: Run Spec365 plus Spec358-364 regression filters and browser smoke. Review retry deferrals explicitly.
|
||||
- **Budget / baseline / trend follow-up**: none expected.
|
||||
- **Review-stop questions**: Does any action bypass resolver/policy? Does any retry path lack a safe start seam? Does high-risk restore expose unsafe copy/action? Are raw diagnostics default-visible? Does every state-changing action disclose TenantPilot-only, Microsoft-tenant, or simulation-only scope before execution?
|
||||
- **Escalation path**: document-in-feature for retry/acknowledge deferrals; follow-up-spec for a generic retry framework.
|
||||
- **Active feature PR close-out entry**: Guardrail + Smoke Coverage.
|
||||
- **Why no dedicated follow-up spec is needed**: This completes the current OperationRun/Reconciliation program without introducing a larger governance inbox.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/365-operations-ui-operator-actions-regression-gate/
|
||||
├── spec.md
|
||||
├── plan.md
|
||||
├── tasks.md
|
||||
├── checklists/
|
||||
│ └── requirements.md
|
||||
└── artifacts/
|
||||
├── spec365-action-eligibility-matrix.md
|
||||
└── spec365-regression-gate-matrix.md
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
Expected implementation paths:
|
||||
|
||||
```text
|
||||
apps/platform/app/
|
||||
├── Filament/
|
||||
│ ├── Pages/Monitoring/Operations.php
|
||||
│ ├── Pages/Operations/TenantlessOperationRunViewer.php
|
||||
│ └── Resources/OperationRunResource.php
|
||||
├── Policies/OperationRunPolicy.php
|
||||
├── Services/OperationRunService.php
|
||||
├── Services/AdapterRunReconciler.php
|
||||
├── Support/
|
||||
│ ├── Auth/Capabilities.php
|
||||
│ ├── OperationRunCapabilityResolver.php
|
||||
│ ├── OperationRunLinks.php
|
||||
│ ├── OpsUx/
|
||||
│ │ ├── OperationUxPresenter.php
|
||||
│ │ └── OperationRunProgressContract.php
|
||||
│ └── Operations/
|
||||
│ ├── OperationRunActionEligibility.php
|
||||
│ └── Reconciliation/
|
||||
│ └── OperationRunReconciliationRegistry.php
|
||||
└── Services/Audit/
|
||||
├── AuditRecorder.php
|
||||
└── WorkspaceAuditLogger.php
|
||||
|
||||
apps/platform/lang/
|
||||
├── en/localization.php
|
||||
└── de/localization.php
|
||||
|
||||
apps/platform/tests/
|
||||
├── Unit/Support/Operations/
|
||||
├── Unit/Support/OpsUx/
|
||||
├── Feature/Operations/
|
||||
└── Browser/
|
||||
```
|
||||
|
||||
## Phase 0 - Research
|
||||
|
||||
Completed during prep:
|
||||
|
||||
- Read repository governance and architecture docs required by `AGENTS.md`.
|
||||
- Verified installed Laravel/Filament/Livewire/Pest versions with Laravel Boost.
|
||||
- Searched current Filament/Livewire/Pest docs through Laravel Boost for action confirmation/testing/global search constraints.
|
||||
- Audited OperationRun model/service/reconciliation registry/link/policy/presenter/UI files.
|
||||
- Checked completed Specs 358-364 for baseline context and scope continuity.
|
||||
|
||||
Research conclusions:
|
||||
|
||||
- Central action eligibility is justified.
|
||||
- Reconcile can reuse existing registry/reconciler/service seams.
|
||||
- Related links should reuse existing link/navigation resolvers.
|
||||
- Generic retry is not repo-real yet and must be explicitly bounded.
|
||||
- OperationRun acknowledge should be deferred unless a clean existing seam is verified during implementation.
|
||||
|
||||
## Phase 1 - Design
|
||||
|
||||
Design artifacts:
|
||||
|
||||
- `artifacts/spec365-action-eligibility-matrix.md`
|
||||
- `artifacts/spec365-regression-gate-matrix.md`
|
||||
|
||||
Primary design decisions:
|
||||
|
||||
1. Use one derived resolver for action decisions.
|
||||
2. Keep writes service-owned.
|
||||
3. Keep related navigation canonical and scope-safe.
|
||||
4. Keep high-risk operations fail-closed.
|
||||
5. Keep raw/support diagnostics hidden and gated.
|
||||
6. Keep Operations as the central surface.
|
||||
|
||||
## Phase 2 - Implementation Approach
|
||||
|
||||
Implementation sequence:
|
||||
|
||||
1. Add resolver and DTO/presenter tests first.
|
||||
2. Add high-risk guard and forbidden-action tests.
|
||||
3. Wire related links and detail/list primary action display.
|
||||
4. Add safe reconcile Filament action with policy/audit/scope enforcement.
|
||||
5. Verify retry seams; implement only safe non-high-risk retry or document deferral.
|
||||
6. Add localization and summary presenter copy.
|
||||
7. Add feature tests for actions/RBAC/scope/audit.
|
||||
8. Add browser smoke for representative Operations UI states.
|
||||
9. Run Spec365 and Spec358-364 regression gate, including Spec363.
|
||||
|
||||
## Risk Register
|
||||
|
||||
| Risk | Impact | Mitigation |
|
||||
|---|---|---|
|
||||
| Generic retry grows into unsafe re-execution framework | High | Implement only repo-verified seams; otherwise disabled/deferred reason |
|
||||
| UI action visibility diverges from direct action authorization | High | Central resolver plus policy checks plus direct-action feature tests |
|
||||
| Restore/high-risk shows unsafe next action | High | High-risk guard unit/feature/browser tests |
|
||||
| Raw provider/SQL/queue leakage appears in default UI | High | Presenter sanitization and browser leakage guard |
|
||||
| Related links bypass scope checks | High | Reuse existing link/policy resolvers and add cross-scope tests |
|
||||
| New resolver becomes taxonomy framework | Medium | Keep derived, non-persisted, no new enum/status family |
|
||||
|
||||
## Deployment / Ops Impact
|
||||
|
||||
- Migrations: none planned.
|
||||
- Environment variables: none planned.
|
||||
- Queue/cron workers: no new workers planned; retry, if implemented, must use existing queues/jobs for that operation family.
|
||||
- Storage/volumes: none.
|
||||
- Assets: none planned. If implementation unexpectedly registers Filament assets, deploy must include `cd apps/platform && php artisan filament:assets`.
|
||||
- Staging validation: required before production because this touches operator action affordances and high-risk restore safety.
|
||||
|
||||
## Open Questions for Implementation
|
||||
|
||||
1. Which existing capability constants should govern `reconcile`, `retry`, and `view diagnostics` for each operation family?
|
||||
2. Is there a clean existing OperationRun acknowledge/note/audit seam? If not, acknowledge is deferred.
|
||||
3. Which non-high-risk operation families have a safe idempotent retry/start seam today?
|
||||
4. Should action metadata live only in existing audit logs, or also in bounded `context.operator_actions` for UI display?
|
||||
5. Does implementation materially change the Operations page enough to require updating the UI audit page report/design matrix?
|
||||
|
||||
## Implementation Stop Conditions
|
||||
|
||||
- Stop and update spec/plan before adding a generic retry framework.
|
||||
- Stop and update spec/plan before adding a new table/entity/status/outcome family.
|
||||
- Stop before introducing any restore retry/re-execute or force-success action.
|
||||
- Stop if action execution cannot be made server-side RBAC/scope-safe.
|
||||
- Stop if state-changing action copy cannot make mutation scope explicit before execution.
|
||||
- Stop if raw diagnostics cannot be gated/collapsed without broader UI redesign.
|
||||
753
specs/365-operations-ui-operator-actions-regression-gate/spec.md
Normal file
753
specs/365-operations-ui-operator-actions-regression-gate/spec.md
Normal file
@ -0,0 +1,753 @@
|
||||
# Feature Specification: Operations UI Operator Actions & Regression Gate
|
||||
|
||||
**Feature Branch**: `365-operations-ui-operator-actions-regression-gate`
|
||||
**Created**: 2026-06-07
|
||||
**Status**: Draft / Ready for implementation
|
||||
**Input**: User description: "Spec 365 - Operations UI Operator Actions & Regression Gate"
|
||||
|
||||
## Spec Candidate Check *(mandatory - SPEC-GATE-001)*
|
||||
|
||||
- **Problem**: OperationRun truth, reconciliation results, restore safety, and outcome semantics now exist, but operators still need a single decision-first surface that answers what happened, what needs attention, and what can be safely done next.
|
||||
- **Today's failure**: Operators can see run history and technical details, but safe action eligibility is not centralized. This creates risk of scattered UI conditions, unsafe retry affordances for high-risk operations, raw diagnostic leakage, and incomplete regression coverage across Specs 358-364.
|
||||
- **User-visible improvement**: The Operations hub and OperationRun detail become actionable and calm: one primary safe action, clear reason, scope, outcome, related domain links, and diagnostics only on demand.
|
||||
- **Smallest enterprise-capable version**: Add a central derived action eligibility resolver, wire safe reconcile/open-related/detail presentation on existing Operations surfaces, integrate retry only where a repo-verified safe retry seam exists, block high-risk retry/force-success, and add a regression gate matrix.
|
||||
- **Explicit non-goals**: No new adapter system, no generic OperationRun retry engine without an existing safe dispatch seam, no restore retry/re-execute, no force complete, no delete/purge, no new top-level Operations pages, no new persisted OperationRun status/outcome family, no raw JSON as the default detail experience.
|
||||
- **Permanent complexity imported**: One narrow action eligibility resolver/presenter family, localized action/summary copy, action metadata/audit convention for operator actions, and focused unit/feature/browser regression tests.
|
||||
- **Why now**: Specs 358-364 established the canonical run, reconciliation, outcome, and high-risk restore semantics. Without this final UI/action gate, the program remains technically correct but not operator-ready.
|
||||
- **Why not local**: Local Blade/Filament `if` conditions would duplicate safety logic and create drift between list/detail/action tests. Central eligibility is required by RBAC, scope isolation, high-risk guardrails, and one-primary-action consistency.
|
||||
- **Approval class**: Core Enterprise / Workflow Compression.
|
||||
- **Red flags triggered**: New resolver abstraction; operator-facing action surface; high-risk operation guard. Defense: the abstraction is derived, non-persisted, bounded to existing OperationRun truth, and has multiple concrete current-release cases across review, evidence, backup/sync, and restore.
|
||||
- **Score**: Nutzen: 3 | Dringlichkeit: 2 | Scope: 2 | Komplexitaet: 1 | Produktnaehe: 3 | Wiederverwendung: 1 | **Gesamt: 12/12**
|
||||
- **Decision**: approve as a bounded completion spec for the OperationRun/Reconciliation program.
|
||||
|
||||
## Spec Scope Fields *(mandatory)*
|
||||
|
||||
- **Scope**: workspace
|
||||
- **Primary Routes**:
|
||||
- `/admin/workspaces/{workspace}/operations`
|
||||
- tenantless OperationRun detail route resolved by `OperationRunLinks::tenantlessView()`
|
||||
- existing related domain detail routes resolved by `OperationRunLinks` / `RelatedNavigationResolver`
|
||||
- **Data Ownership**: Existing workspace-owned `operation_runs` and related domain records. No new table or independent persisted entity is introduced by this spec.
|
||||
- **RBAC**: Workspace membership and environment entitlement remain required. Capability checks must use existing capability registry/policies first; any new capability constant must be added only after repo verification proves no existing capability fits.
|
||||
|
||||
For this workspace-scope spec:
|
||||
|
||||
- **Default filter behavior when tenant-context is active**: Existing workspace Operations routes remain workspace-scoped. Tenant-context routes may deep-link to the same run only through existing tenant/workspace-safe URL resolution and entitlement checks.
|
||||
- **Explicit entitlement checks preventing cross-tenant leakage**: OperationRun view/action policy checks must preserve current 404 semantics for non-member or not-entitled scope and 403 semantics for members missing capability.
|
||||
|
||||
## UI Surface Impact *(mandatory - UI-COV-001)*
|
||||
|
||||
Does this spec add, remove, rename, or materially change any reachable UI surface?
|
||||
|
||||
- [ ] No UI surface impact
|
||||
- [x] Existing page changed
|
||||
- [ ] New page/route added
|
||||
- [ ] Navigation changed
|
||||
- [ ] Filament panel/provider surface changed
|
||||
- [x] New modal/drawer/wizard/action added
|
||||
- [x] New table/form/state added
|
||||
- [ ] Customer-facing surface changed
|
||||
- [x] Dangerous action changed
|
||||
- [x] Status/evidence/review presentation changed
|
||||
- [x] Workspace/environment context presentation changed
|
||||
|
||||
## UI/Productization Coverage *(mandatory when UI Surface Impact is not "No UI surface impact"; otherwise write `N/A - no reachable UI surface impact` plus rationale)*
|
||||
|
||||
- **Route/page/surface**:
|
||||
- `apps/platform/app/Filament/Pages/Monitoring/Operations.php`
|
||||
- `apps/platform/app/Filament/Resources/OperationRunResource.php`
|
||||
- `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`
|
||||
- OperationRun detail header actions, related action group, summary/evidence/technical detail sections.
|
||||
- **Current or new page archetype**: Existing Strategic Surface: Operations Hub / monitoring-state page.
|
||||
- **Design depth**: Strategic Surface.
|
||||
- **Repo-truth level**: repo-verified.
|
||||
- **Existing pattern reused**: `docs/ui-ux-enterprise-audit/page-reports/ui-003-operations.md`, `docs/ui-ux-enterprise-audit/target-experience-briefs/operations-hub.md`, `docs/ui/action-surface-contract.md`, existing Filament resource/page actions, existing OperationRun detail presenter sections.
|
||||
- **New pattern required**: none. Add a bounded action eligibility resolver and presenter extension; no new UI framework.
|
||||
- **Screenshot required**: yes during implementation for browser smoke. Store in `specs/365-operations-ui-operator-actions-regression-gate/artifacts/spec365-operations-ui-screenshots/` if screenshots are captured.
|
||||
- **Page audit required**: no new page audit required during prep; update the existing Operations page report only if implementation materially changes the page contract beyond action/state presentation.
|
||||
- **Customer-safe review required**: yes. Browser smoke must prove no default raw SQL/Graph/queue/secret/stack trace leakage.
|
||||
- **Dangerous-action review required**: yes. Restore/high-risk operations must not show retry/re-execute/force-success actions.
|
||||
- **Coverage files updated or explicitly not needed**:
|
||||
- [ ] `docs/ui-ux-enterprise-audit/route-inventory.md`
|
||||
- [ ] `docs/ui-ux-enterprise-audit/design-coverage-matrix.md`
|
||||
- [ ] `docs/ui-ux-enterprise-audit/page-reports/...`
|
||||
- [ ] `docs/ui-ux-enterprise-audit/strategic-surfaces.md`
|
||||
- [ ] `docs/ui-ux-enterprise-audit/grouped-follow-up-candidates.md`
|
||||
- [ ] `docs/ui-ux-enterprise-audit/unresolved-pages.md`
|
||||
- [ ] `N/A - no reachable UI surface impact`
|
||||
- **Coverage artifact decision**: Implementation must either update the existing Operations page report/design matrix or record a proportional no-update rationale in `tasks.md` close-out when visual structure remains pattern-compatible.
|
||||
- **No-impact rationale when applicable**: N/A.
|
||||
|
||||
## Cross-Cutting / Shared Pattern Reuse *(mandatory when the feature touches notifications, status messaging, action links, header actions, dashboard signals/cards, alerts, navigation entry points, evidence/report viewers, or any other existing shared operator interaction family; otherwise write `N/A - no shared interaction family touched`)*
|
||||
|
||||
- **Cross-cutting feature?**: yes
|
||||
- **Interaction class(es)**: status messaging, header actions, row/detail actions, related navigation, evidence/report links, audit/action metadata, diagnostic disclosure.
|
||||
- **Systems touched**:
|
||||
- `OperationUxPresenter`
|
||||
- `OperationRunProgressContract`
|
||||
- `OperationRunLinks`
|
||||
- `RelatedNavigationResolver`
|
||||
- `RelatedActionLabelCatalog`
|
||||
- `AdapterRunReconciler`
|
||||
- `OperationRunReconciliationRegistry`
|
||||
- `OperationRunService`
|
||||
- `OperationRunPolicy` / capability resolver
|
||||
- `AuditRecorder` / workspace audit services
|
||||
- **Existing pattern(s) to extend**: Existing Operations hub, OperationRun detail, related navigation/action label catalog, OperationRun reconciliation registry, central OperationRun service writes.
|
||||
- **Shared contract / presenter / builder / renderer to reuse**: Reuse existing presenters/link resolvers first. Add `OperationRunActionEligibility` only for action decision derivation where no central action model exists.
|
||||
- **Why the existing shared path is sufficient or insufficient**: Existing services own reconciliation writes, detail links, and UX summaries, but no single repo-verified layer answers "which operator action is allowed and primary for this run". That gap is safety-relevant across multiple run families.
|
||||
- **Allowed deviation and why**: Add one derived eligibility resolver and small action DTO/presenter if needed; do not create a new registry, state enum, or persisted action table unless implementation proves existing audit metadata is insufficient and the spec is amended.
|
||||
- **Consistency impact**: List/detail/browser tests must use the same primary action, disabled reason, high-risk guard, and related-link truth.
|
||||
- **Review focus**: Verify there is no parallel local action logic in Blade/Filament closures and no broad retry/re-execute system hidden behind UI actions.
|
||||
|
||||
## OperationRun UX Impact *(mandatory when the feature creates, queues, deduplicates, resumes, blocks, completes, or deep-links to an `OperationRun`; otherwise write `N/A - no OperationRun start or link semantics touched`)*
|
||||
|
||||
- **Touches OperationRun start/completion/link UX?**: yes
|
||||
- **Shared OperationRun UX contract/layer reused**: `OperationUxPresenter`, `OperationRunProgressContract`, `OperationRunLinks`, `OperationRunService`, `AdapterRunReconciler`.
|
||||
- **Delegated start/completion UX behaviors**: Existing progress/status/detail links remain central. Reconcile uses `AdapterRunReconciler` and `OperationRunService`. Retry may create a new OperationRun only through a repo-verified safe retry/start seam; otherwise retry is unavailable with a disabled reason.
|
||||
- **Local surface-owned behavior that remains**: Operation-specific inputs are out of scope. The Operations surface owns display hierarchy and invoking authorized actions only.
|
||||
- **Queued DB-notification policy**: No new queued/running DB notification policy. Retry, if implemented through an existing start seam, must follow that seam's notification policy.
|
||||
- **Terminal notification path**: Existing central lifecycle mechanism.
|
||||
- **Exception required?**: none for reconcile/open-related. Generic retry is explicitly not approved unless an existing safe dispatch seam is verified or added through a bounded implementation decision with tests.
|
||||
|
||||
## Provider Boundary / Platform Core Check *(mandatory when the feature changes shared provider/platform seams, identity scope, governed-subject taxonomy, compare strategy selection, provider connection descriptors, or operator vocabulary that may leak provider-specific semantics into platform-core truth; otherwise write `N/A - no shared provider/platform boundary touched`)*
|
||||
|
||||
- **Shared provider/platform boundary touched?**: yes, bounded to presentation of existing operation reason codes and provider failure summaries.
|
||||
- **Boundary classification**: mixed. OperationRun/action eligibility is platform-core; provider reason codes remain provider-owned diagnostic data.
|
||||
- **Seams affected**: Operator summaries, diagnostics, high-risk labels, related artifact labels, localization strings.
|
||||
- **Neutral platform terms preserved or introduced**: operation, run, outcome, attention, reconcile, retry, verification, partial, blocked, related artifact, diagnostics.
|
||||
- **Provider-specific semantics retained and why**: Existing Graph/provider reason codes may appear only in operator/support diagnostics, not customer-readable default copy.
|
||||
- **Why this does not deepen provider coupling accidentally**: The resolver reads canonical OperationRun context and operation type; it does not call Graph, inspect raw payloads, or add provider-specific branching except for existing high-risk operation classification.
|
||||
- **Follow-up path**: none unless implementation discovers provider-shaped UI copy that cannot be normalized without a separate provider guidance spec.
|
||||
|
||||
## UI / Surface Guardrail Impact *(mandatory when operator-facing surfaces are changed; otherwise write `N/A`)*
|
||||
|
||||
| Surface / Change | Operator-facing surface change? | Native vs Custom | Shared-Family Relevance | State Layers Touched | Exception Needed? | Low-Impact / `N/A` Note |
|
||||
|---|---|---|---|---|---|---|
|
||||
| Operations hub list decision/action columns | yes | Native Filament table + existing shared presenters | status messaging, row/detail actions | page, table, row, URL-safe links | no | Existing strategic surface; no new page |
|
||||
| OperationRun detail decision layout/header actions | yes | Native Filament resource/detail + existing custom detail sections | header actions, related links, diagnostics | detail, header, disclosure | no | Keep raw/support detail secondary/collapsed |
|
||||
| Reconcile/Retry action modals | yes | Filament `Action` with confirmation/authorization | action safety, audit | detail/header action | no | Retry only repo-real non-high-risk seams |
|
||||
| Related domain object action | yes | Existing link/action resolvers | navigation, evidence/report viewers | header/action group | no | Canonical metadata only |
|
||||
|
||||
## Decision-First Surface Role *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Decision Role | Human-in-the-loop Moment | Immediately Visible for First Decision | On-Demand Detail / Evidence | Why This Is Primary or Why Not | Workflow Alignment | Attention-load Reduction |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Operations hub list | Primary Decision Surface | Operator scans current and recent operations for attention states | operation label, scope, status/outcome, freshness, primary reason, one primary safe action | diagnostic references, technical context, raw context only if permitted | Primary because it is the central monitoring surface for run follow-up | Follows Operations hub target experience | Removes button sprawl and repeated raw-state inspection |
|
||||
| OperationRun detail | Primary Decision Surface for a selected run | Operator decides whether to reconcile, retry safely, open related result, or inspect details | status/outcome, scope, reason, summary, evidence, high-risk warning, primary action | reconciliation evidence, result coverage, provider reason codes, raw context behind support diagnostics | Primary for individual run decision; not a general governance inbox | Keeps one run decidable without cross-page reconstruction | Makes diagnostics secondary and prevents unsafe restore actions |
|
||||
|
||||
## Audience-Aware Disclosure *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Audience Modes In Scope | Decision-First Default-Visible Content | Operator Diagnostics | Support / Raw Evidence | One Dominant Next Action | Hidden / Gated By Default | Duplicate-Truth Prevention |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Operations hub list | operator-MSP, support-platform | Status, outcome, freshness, scope, primary reason, primary action | reason codes and affected families when compact | raw context not shown | action chosen by eligibility resolver | raw JSON, SQL/provider traces, queue payloads | Same resolver feeds primary action and disabled reason |
|
||||
| OperationRun detail | operator-MSP, support-platform | What happened, why it matters, current state, safe next action, evidence summary | reason codes, coverage, verification, lifecycle reconciliation | raw context collapsed and capability-gated | one primary action in header/decision section | raw payloads, stack traces, secrets, signed URLs | Summary states blocker once; evidence sections add proof only |
|
||||
|
||||
## UI/UX Surface Classification *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Action Surface Class | Surface Type | Likely Next Operator Action | Primary Inspect/Open Model | Row Click | Secondary Actions Placement | Destructive Actions Placement | Canonical Collection Route | Canonical Detail Route | Scope Signals | Canonical Noun | Critical Truth Visible by Default | Exception Type / Justification |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Operations hub list | List / Table / Monitoring | Monitoring-state page | Open run or invoke one safe primary action | Row click opens OperationRun detail | required | More/detail header | N/A - no destructive actions in this spec | `/admin/workspaces/{workspace}/operations` | tenantless OperationRun view link | workspace/environment chips | Operation runs / Operation run | status, outcome, freshness, reason, scope, safe action | none |
|
||||
| OperationRun detail | Detail / Workbench | Shared-detail-family | Reconcile, Retry when safe, or Open related object | Detail page itself | N/A | Header More/action group | N/A - no destructive actions in this spec | existing Operations hub | tenantless OperationRun view link | workspace/environment detail | Operation run | status, outcome, reason, evidence, high-risk guard | none |
|
||||
|
||||
## Operator Surface Contract *(mandatory when operator-facing surfaces are changed)*
|
||||
|
||||
| Surface | Primary Persona | Decision / Operator Action Supported | Surface Type | Primary Operator Question | Default-visible Information | Diagnostics-only Information | Status Dimensions Used | Mutation Scope | Primary Actions | Dangerous Actions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Operations hub list | Tenant/MSP operator | Decide which run needs attention and what can be safely done next | Monitoring list | What needs action right now? | operation, scope, status, outcome, freshness, reason, primary action | reason codes, raw context, internal metadata | lifecycle, outcome, freshness, reconciliation, risk | TenantPilot only for reconcile metadata; Microsoft tenant only for repo-real retry start seams that explicitly do so | View related, Reconcile, Retry when safe, View details | none |
|
||||
| OperationRun detail | Tenant/MSP operator and support operator | Decide final follow-up for one run | Decision detail | What happened, why does it matter, and what is the safe next step? | summary, scope, status/outcome, evidence, high-risk guard, primary action | provider codes, dispatch metadata, raw context gated/collapsed | lifecycle, outcome, data completeness, verification, risk | TenantPilot only for reconcile/action metadata; retry follows existing start seam | View related, Reconcile, Retry when safe, View diagnostics | Restore retry/re-execute and force success are forbidden |
|
||||
|
||||
## Proportionality Review *(mandatory when structural complexity is introduced)*
|
||||
|
||||
- **New source of truth?**: no. Eligibility is derived from existing OperationRun, policy, capability, adapter registry, and related metadata.
|
||||
- **New persisted entity/table/artifact?**: no. Audit/action metadata may be written into existing audit systems or existing OperationRun context only if no better existing seam exists.
|
||||
- **New abstraction?**: yes, a bounded `OperationRunActionEligibility` resolver and possibly a small action DTO/presenter.
|
||||
- **New enum/state/reason family?**: no new persisted status/outcome family. Derived action ids and disabled reason keys may be localized strings/constants only.
|
||||
- **New cross-domain UI framework/taxonomy?**: no. This extends the existing Operations/OperationRun UX.
|
||||
- **Current operator problem**: Action safety and primary-action choice are currently not a single testable decision. High-risk restore states must fail closed everywhere.
|
||||
- **Existing structure is insufficient because**: `OperationUxPresenter` and link resolvers can summarize and navigate, while `AdapterRunReconciler` can reconcile, but no existing layer combines operation type, status/outcome, freshness, adapter support, related metadata, RBAC, and scope into one action contract.
|
||||
- **Narrowest correct implementation**: One resolver that returns `primary_action`, `secondary_actions`, `disabled_actions`, `disabled_reasons`, and `attention_reason`; Filament pages consume that output.
|
||||
- **Ownership cost**: Unit tests for action decisions, feature tests for server-side action enforcement, browser smoke for representative states, and localization maintenance.
|
||||
- **Alternative intentionally rejected**: Scattered UI `if` blocks and page-local action conditions. They would be faster but unsafe because direct action execution, disabled reasons, and list/detail actions could diverge.
|
||||
- **Release truth**: Current-release truth. It uses existing Specs 358-364 behavior and does not speculate about a full governance inbox.
|
||||
|
||||
### Compatibility posture
|
||||
|
||||
This feature assumes a pre-production environment. Backward compatibility, legacy aliases, migration shims, historical fixtures, and compatibility-specific tests are out of scope unless explicitly required by this spec. Canonical replacement is preferred over preservation.
|
||||
|
||||
## Testing / Lane / Runtime Impact *(mandatory for runtime behavior changes)*
|
||||
|
||||
- **Test purpose / classification**: Unit, Feature, Browser.
|
||||
- **Validation lane(s)**: fast-feedback for resolver/presenter tests, confidence for Filament/action/RBAC/audit tests, browser for Operations UI smoke.
|
||||
- **Why this classification and these lanes are sufficient**: The highest risk is decision logic and server-side action enforcement, so unit and feature tests prove behavior. Browser smoke proves default-visible decision hierarchy and raw leakage guard on the real Filament surface.
|
||||
- **New or expanded test families**:
|
||||
- `Spec365OperationRunActionEligibilityTest`
|
||||
- `Spec365OperationRunPrimaryActionTest`
|
||||
- `Spec365HighRiskActionGuardTest`
|
||||
- `Spec365OperationRunSummaryPresenterTest`
|
||||
- `Spec365OperationRunActionsTest`
|
||||
- `Spec365OperationRunActionRbacTest`
|
||||
- `Spec365OperationRunActionAuditTest`
|
||||
- `Spec365OperationRunRelatedLinksTest`
|
||||
- `Spec365OperationRunRegressionGateTest`
|
||||
- `Spec365OperationsUiOperatorActionsSmokeTest`
|
||||
- **Fixture / helper cost impact**: Reuse existing OperationRun factories/helpers. Add only explicit Spec365 fixtures for canonical context states; no broad seeding defaults.
|
||||
- **Heavy-family visibility / justification**: Browser smoke is explicit and limited to the Operations UI representative matrix.
|
||||
- **Special surface test profile**: monitoring-state-page and shared-detail-family.
|
||||
- **Standard-native relief or required special coverage**: Special coverage required for one primary action, high-risk guard, collapsed/gated diagnostics, raw leakage absence, and action denial.
|
||||
- **Reviewer handoff**: Reviewers must verify that action visibility and direct action execution use the same resolver/policy rules, and that unsupported retry paths fail closed.
|
||||
- **Budget / baseline / trend impact**: No expected material budget drift. Browser smoke may add one bounded feature-specific file.
|
||||
- **Escalation needed**: document-in-feature if generic retry or OperationRun acknowledge is deferred because no repo-safe seam exists; follow-up-spec only if a generic retry framework is desired later.
|
||||
- **Active feature PR close-out entry**: Guardrail + Smoke Coverage.
|
||||
- **Planned validation commands**:
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec365`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec364`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec363`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec362`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec361`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec360`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec359`
|
||||
- `cd apps/platform && ./vendor/bin/sail artisan test --compact --filter=Spec358`
|
||||
- `cd apps/platform && ./vendor/bin/pint --dirty`
|
||||
- `git diff --check`
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Decide the safe next action for an OperationRun (Priority: P1)
|
||||
|
||||
As an operator, I can scan the Operations hub or open a run detail and immediately understand status, outcome, freshness, scope, primary reason, and the one safe next action.
|
||||
|
||||
**Why this priority**: This is the core productization step that makes Specs 358-364 usable and customer-safe.
|
||||
|
||||
**Independent Test**: Seed representative OperationRuns and verify the resolver, table/detail presentation, one primary action, and raw leakage guard.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a reconciled review-compose run with canonical related metadata, **When** the operator views it, **Then** the UI shows "Review already available" and "View review" as the primary action without "Waiting for worker" or duplicate-key leakage.
|
||||
2. **Given** a partial sync run, **When** the operator views it, **Then** the UI shows completed-with-partial-results copy and a details-oriented primary action rather than retry as the default.
|
||||
3. **Given** a fresh queued run, **When** the operator views it, **Then** no retry or reconcile action is offered.
|
||||
|
||||
### User Story 2 - Safely reconcile eligible stale or failed runs (Priority: P1)
|
||||
|
||||
As an authorized operator, I can trigger reconciliation only when an existing adapter, canonical proof, RBAC, and scope checks make the action safe.
|
||||
|
||||
**Why this priority**: Reconciliation exists but must be explicitly actionable without bypassing `OperationRunService`.
|
||||
|
||||
**Independent Test**: Directly call the Filament action/service path for eligible and unsupported runs and verify success/denial/audit metadata.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an eligible stale review-compose run with adapter proof and a user with the required capability, **When** the operator selects Reconcile, **Then** `AdapterRunReconciler` and `OperationRunService` write canonical reconciliation and action audit metadata.
|
||||
2. **Given** an unsupported operation type, **When** the user opens or directly calls Reconcile, **Then** the action is absent or unavailable and direct execution is denied.
|
||||
3. **Given** a cross-workspace OperationRun, **When** a user attempts Reconcile, **Then** server-side authorization denies access using existing workspace isolation semantics.
|
||||
|
||||
### User Story 3 - Prevent unsafe retry and force-success actions (Priority: P1)
|
||||
|
||||
As an operator, I never see retry, re-execute, force complete, or mark succeeded actions for high-risk restore/destructive operations.
|
||||
|
||||
**Why this priority**: Restore/high-risk safety from Spec 364 must remain visible and enforced in the UI.
|
||||
|
||||
**Independent Test**: Validate eligibility and browser state for restore verification-required, blocked, partial, and failed runs.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a failed `restore.execute` run, **When** the operator views it, **Then** no retry/re-execute/force-success action is visible and the disabled reason states that high-risk operations cannot be retried from this view.
|
||||
2. **Given** a restore verification-required run, **When** the operator views it, **Then** the primary action is "View restore details" and verification proof is visible before technical diagnostics.
|
||||
3. **Given** any operation, **When** the UI renders actions, **Then** no Force Complete or Mark Succeeded action exists.
|
||||
|
||||
### User Story 4 - Retry only repo-verifiable safe non-high-risk operations (Priority: P2)
|
||||
|
||||
As an operator, I can retry a safe non-high-risk operation only when the repository already has, or this spec implements, a bounded dispatch seam that creates a new OperationRun with canonical dispatch/retry metadata.
|
||||
|
||||
**Why this priority**: Retry is useful, but a generic re-execution layer is outside the safe minimum.
|
||||
|
||||
**Independent Test**: Use one repo-verified retry/resume/start seam and prove dispatch context, source/new run relationship, idempotency, and audit. If no seam exists for a family, verify disabled/deferred state.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a failed non-high-risk operation with a repo-verified retry seam and an authorized user, **When** the operator selects Retry, **Then** a new OperationRun is started with `context.dispatch`, source/new retry metadata, and audit/action metadata.
|
||||
2. **Given** a failed non-high-risk operation without a repo-verified retry seam, **When** the operator views it, **Then** Retry is unavailable with a clear disabled reason.
|
||||
3. **Given** a completed/succeeded run, **When** the operator views it, **Then** Retry is not offered.
|
||||
|
||||
### User Story 5 - Open related domain evidence safely (Priority: P2)
|
||||
|
||||
As an operator, I can open the related review, evidence snapshot, review pack/report, backup, sync, or restore detail when canonical related metadata and authorization allow it.
|
||||
|
||||
**Why this priority**: Operators need proof and domain context without raw context inspection.
|
||||
|
||||
**Independent Test**: Build canonical `context.reconciliation.related` cases and verify same-scope link resolution and cross-scope denial.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a reconciled evidence snapshot run with canonical related metadata, **When** the operator views it, **Then** the primary or secondary action opens the evidence detail using existing link policies.
|
||||
2. **Given** related metadata that points outside the workspace/environment scope, **When** the operator views it, **Then** the related action is hidden or disabled and direct access is denied.
|
||||
|
||||
### User Story 6 - Audit and localize operator actions (Priority: P3)
|
||||
|
||||
As an auditor or support operator, I can trace reconcile/retry action attempts and outcomes without exposing secrets, and labels/summaries are available in EN/DE.
|
||||
|
||||
**Why this priority**: Actionability must be audit-visible and customer-safe.
|
||||
|
||||
**Independent Test**: Execute actions and assert audit/action metadata includes actor, action, run id, workspace, environment, previous/new status/outcome, reason code, timestamp, and related new run id when retry starts a run.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** an authorized reconcile action, **When** it succeeds, **Then** audit/action metadata contains the required fields and no secrets.
|
||||
2. **Given** a denied action attempt, **When** the user lacks capability or scope, **Then** the attempt is denied and either audit-visible or safely logged without leaking secrets.
|
||||
3. **Given** EN and DE locale files, **When** the UI renders action labels and summary states, **Then** labels and disabled reasons are localized.
|
||||
|
||||
## Functional Requirements *(mandatory)*
|
||||
|
||||
### FR-001 - OperationRun Action Eligibility Resolver
|
||||
|
||||
The system MUST provide a central, unit-testable eligibility layer for OperationRun operator actions.
|
||||
|
||||
Inputs MUST include:
|
||||
|
||||
- operation type
|
||||
- status
|
||||
- outcome
|
||||
- freshness state
|
||||
- reconciliation state
|
||||
- dispatch context
|
||||
- results context
|
||||
- restore context
|
||||
- risk classification
|
||||
- user capabilities
|
||||
- workspace/environment scope
|
||||
- related metadata
|
||||
|
||||
Outputs MUST include:
|
||||
|
||||
- `primary_action`
|
||||
- `secondary_actions`
|
||||
- `disabled_actions`
|
||||
- `disabled_reasons`
|
||||
- `attention_reason`
|
||||
|
||||
Acceptance:
|
||||
|
||||
- No action visibility is decided through scattered Blade-only or page-local `if` logic.
|
||||
- High-risk restore is not retryable.
|
||||
- Completed/succeeded runs do not receive Retry.
|
||||
- Unsupported runs do not receive Reconcile.
|
||||
- Eligible stale runs may offer Reconcile when adapter proof exists.
|
||||
- Missing capability and cross-scope state fail closed.
|
||||
- Disabled reasons are visible in tooltip, modal description, or decision detail.
|
||||
|
||||
### FR-002 - Safe Reconcile Action
|
||||
|
||||
Reconcile MUST appear only when an existing adapter supports the operation, canonical proof is sufficient, RBAC and scope pass, and the action can route through existing reconciliation seams.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Reconcile uses `OperationRunReconciliationRegistry` / `AdapterRunReconciler`.
|
||||
- Reconcile writes through `OperationRunService`.
|
||||
- Reconcile is RBAC protected, scope-safe, and idempotent.
|
||||
- Review-compose, review-pack/report artifact, evidence snapshot, sync/capture/backup, and restore reconciliation are limited to the proof already defined by Specs 359, 361, 362, and 364.
|
||||
- Unsupported and cross-scope runs cannot be reconciled.
|
||||
- Action audit/metadata is written without secrets.
|
||||
|
||||
### FR-003 - Safe Retry Action for non-high-risk Runs
|
||||
|
||||
Retry MUST be offered only for non-high-risk operations with a repo-verified safe retry/start seam.
|
||||
|
||||
Candidate families MAY include review compose, evidence snapshot generation, review-pack/report generation, inventory/sync/capture, and backup capture only if implementation proves a safe, idempotent dispatch seam exists.
|
||||
|
||||
Retry MUST NOT be implemented for:
|
||||
|
||||
- `restore.execute`
|
||||
- tenant mutation
|
||||
- destructive mutation
|
||||
- high-risk operation
|
||||
- unknown operation
|
||||
- completed/succeeded run
|
||||
- unsupported operation without a safe retry seam
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Retry eligibility is resolver-controlled.
|
||||
- Retry starts a new OperationRun or uses an existing retry/resume seam with canonical `context.dispatch`.
|
||||
- Source/new retry metadata is visible.
|
||||
- Retry is audit-visible.
|
||||
- High-risk restore shows an unavailable reason instead of a retry action.
|
||||
- Unsupported retry paths fail closed and may be documented as deferred in `tasks.md`.
|
||||
|
||||
### FR-004 - No Force Complete Action
|
||||
|
||||
This spec MUST NOT introduce Force Complete, Mark Succeeded, Ignore Error and Complete, Manually Mark Restore Successful, Delete, Purge, or equivalent success-forcing actions.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- No force-success action exists in action code, labels, localization, or tests.
|
||||
- Restore/high-risk tests assert absence of force-success and retry/re-execute actions.
|
||||
|
||||
### FR-005 - Acknowledge / Mark Reviewed Action
|
||||
|
||||
Acknowledge is optional and MUST be implemented only if a clean existing audit/note seam exists.
|
||||
|
||||
Acceptance if implemented:
|
||||
|
||||
- Does not change `status` or `outcome`.
|
||||
- Writes audit-visible note/action metadata.
|
||||
- Is RBAC protected.
|
||||
- UI distinguishes reviewed from resolved/succeeded.
|
||||
|
||||
If no clean seam exists, implementation MUST document deferral in `tasks.md` and not implement a local substitute.
|
||||
|
||||
### FR-006 - Open Related Domain Object Action
|
||||
|
||||
The system MUST offer safe related-object actions when canonical metadata supports them.
|
||||
|
||||
Sources:
|
||||
|
||||
- `context.reconciliation.related`
|
||||
- other canonical related metadata already exposed by OperationRun helpers
|
||||
- existing `OperationRunLinks` / `RelatedNavigationResolver` paths
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Reconciled review opens Review.
|
||||
- Evidence snapshot opens Evidence.
|
||||
- Review-pack/report artifact opens the existing safe report/review-pack seam when canonical metadata exists.
|
||||
- Restore opens restore details when the existing route/policy resolves same-scope proof.
|
||||
- Cross-scope related objects are hidden/disabled and direct access is denied.
|
||||
- No signed URL or raw payload is read from context.
|
||||
|
||||
### FR-007 - OperationRun Details Decision Layout
|
||||
|
||||
OperationRun detail MUST be decision-first:
|
||||
|
||||
- Header: operation label, status/outcome, scope, primary reason, primary action.
|
||||
- Summary: what happened, why it matters, current state, next safe action.
|
||||
- Evidence: reconciliation proof, result coverage, restore verification, provider reason codes where operator-safe.
|
||||
- Details: dispatch metadata, duration, technical diagnostics.
|
||||
- Raw context: internal/support capability only, collapsed by default.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Raw JSON is not the default experience.
|
||||
- High-risk warnings are visible.
|
||||
- Technical details are progressively disclosed.
|
||||
- Customer-readable paths remain calm and free of raw implementation detail.
|
||||
|
||||
### FR-008 - Outcome-Specific Summary Cards
|
||||
|
||||
The UI/presenter MUST cover the main OperationRun outcome families:
|
||||
|
||||
- Queued fresh
|
||||
- Queued longer than expected
|
||||
- Running
|
||||
- Running longer than expected
|
||||
- Reconciled from adapter
|
||||
- Report/review-pack already available
|
||||
- Evidence snapshot already available
|
||||
- Sync completed with partial results
|
||||
- Sync blocked
|
||||
- Backup completed with partial results
|
||||
- Backup blocked
|
||||
- Restore verification required
|
||||
- Restore completed with partial results
|
||||
- Restore blocked
|
||||
- Failed by lifecycle reconciliation
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Each family has EN/DE copy.
|
||||
- Summary cards use presenter/contract output, not Blade special cases.
|
||||
- Browser smoke covers representative states.
|
||||
- Customer-facing defaults do not show raw technical errors.
|
||||
|
||||
### FR-009 - High-Risk Guard in UI
|
||||
|
||||
High-risk operations MUST be visually and functionally protected.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Restore, tenant mutation, destructive mutation, unknown/high-risk operations have no Retry/Re-execute/Force Complete primary action.
|
||||
- Verification-required state emphasizes verification proof and "View restore details".
|
||||
- Disabled reasons are visible.
|
||||
- Browser and feature tests assert action absence/disabled state.
|
||||
|
||||
### FR-010 - RBAC / Capability Enforcement
|
||||
|
||||
All actions MUST be capability-first and server-side enforced.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Use existing policies, `OperationRunCapabilityResolver`, and `Capabilities` constants first.
|
||||
- Do not use raw capability strings or role-string checks in feature code.
|
||||
- If new capability constants are required, add the smallest number and test positive/negative behavior.
|
||||
- User without capability cannot see or execute the action.
|
||||
- Direct action execution without capability fails.
|
||||
- Cross-workspace and cross-environment execution fails.
|
||||
|
||||
### FR-011 - Auditability for Operator Actions
|
||||
|
||||
Each operator action and relevant denied attempt MUST be audit-visible or safely logged.
|
||||
|
||||
Required fields:
|
||||
|
||||
- action
|
||||
- operation_run_id
|
||||
- workspace_id
|
||||
- managed_environment_id when present
|
||||
- actor_id
|
||||
- previous status/outcome
|
||||
- resulting status/outcome
|
||||
- reason_code
|
||||
- timestamp
|
||||
- related new operation_run_id when retry starts one
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Reconcile action is audit-visible.
|
||||
- Retry action is audit-visible when implemented.
|
||||
- Acknowledge action is audit-visible if implemented.
|
||||
- Failed/denied action attempts are audit-visible or safely logged.
|
||||
- No secrets, access tokens, raw payloads, or signed URLs are stored.
|
||||
|
||||
### FR-012 - Retry Metadata Contract
|
||||
|
||||
When retry starts a run, the source and new run relationship MUST be visible using canonical context/action metadata.
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"retry": {
|
||||
"source_operation_run_id": 123,
|
||||
"retry_operation_run_id": 124,
|
||||
"requested_by_user_id": 5,
|
||||
"requested_at": "2026-06-06T16:00:00+02:00",
|
||||
"reason_code": "operator_retry_requested"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Old and new runs link to each other or one canonical direction is documented.
|
||||
- New run has `context.dispatch`.
|
||||
- UI can show "Retry started".
|
||||
- No uncontrolled retry loops.
|
||||
- High-risk runs do not receive retry metadata.
|
||||
|
||||
### FR-013 - Regression Gate Matrix
|
||||
|
||||
This spec MUST define and cover a final OperationRun state matrix.
|
||||
|
||||
Minimum matrix:
|
||||
|
||||
| Family | State |
|
||||
|---|---|
|
||||
| Queue | fresh queued |
|
||||
| Queue | stale queued |
|
||||
| Queue | stale running |
|
||||
| Reconciliation | review already available |
|
||||
| Reconciliation | report/review-pack already available |
|
||||
| Reconciliation | evidence snapshot already available |
|
||||
| Sync | partial |
|
||||
| Sync | blocked |
|
||||
| Backup | partial |
|
||||
| Backup | blocked |
|
||||
| Restore | verification required |
|
||||
| Restore | partial |
|
||||
| Restore | blocked |
|
||||
| Restore | failed |
|
||||
| RBAC | action denied |
|
||||
| Scope | cross-workspace denied |
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Matrix exists at `artifacts/spec365-regression-gate-matrix.md`.
|
||||
- Browser smoke covers representative states.
|
||||
- Feature tests cover action eligibility/enforcement.
|
||||
- Spec 358-364 regressions remain green.
|
||||
|
||||
### FR-014 - No Raw Technical Leakage
|
||||
|
||||
Customer-readable and shared default surfaces MUST NOT show raw technical details.
|
||||
|
||||
Forbidden by default:
|
||||
|
||||
- `SQLSTATE`
|
||||
- `Guzzle`
|
||||
- stack trace
|
||||
- Graph raw payload
|
||||
- access token
|
||||
- client secret
|
||||
- queue payload
|
||||
- serialized job
|
||||
- internal constraint names such as `environment_reviews_fingerprint_mutable_unique`
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Browser tests assert absence of forbidden strings.
|
||||
- Operator-safe reason codes may appear in diagnostics.
|
||||
- Raw context JSON is collapsed and capability-gated.
|
||||
|
||||
### FR-015 - No New Top-Level Sprawl
|
||||
|
||||
This spec MUST use existing Operations and related domain surfaces.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- No new top-level page for stale, retry, reconcile, partial sync, restore verification, or similar states.
|
||||
- Operations remains the central run surface.
|
||||
- Related domain pages are linked.
|
||||
- Navigation remains calm.
|
||||
|
||||
### FR-016 - Mutation Scope Disclosure
|
||||
|
||||
Every state-changing operator action MUST communicate before execution whether it affects TenantPilot only, the Microsoft tenant, or simulation only.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Reconcile copy/confirmation states that reconciliation writes TenantPilot OperationRun/action metadata only, unless an adapter/service explicitly performs a broader mutation that this spec permits.
|
||||
- Retry copy/confirmation states the scope of the underlying safe retry/start seam before execution.
|
||||
- High-risk or unknown mutation scope fails closed and does not expose the action.
|
||||
- Feature or browser coverage proves the mutation-scope text is visible near the action, helper text, preview, or confirmation.
|
||||
|
||||
## Canonical Cutover Rules
|
||||
|
||||
- New UI/action code MUST read canonical OperationRun truth structures.
|
||||
- New tests MUST be canonical-only.
|
||||
- New action writes MUST use canonical audit/action metadata.
|
||||
- Do not add new alias/fallback paths in Blade or presenters.
|
||||
- Existing compatibility helpers may remain untouched, but Spec 365 code must not depend on legacy context paths.
|
||||
|
||||
## Action Eligibility Contract
|
||||
|
||||
The resolver SHOULD use a repo-conform name such as `OperationRunActionEligibility`.
|
||||
|
||||
It SHOULD expose derived actions such as:
|
||||
|
||||
- `view_related`
|
||||
- `reconcile`
|
||||
- `retry`
|
||||
- `view_details`
|
||||
- `view_diagnostics`
|
||||
- `acknowledge` only if a clean existing seam exists
|
||||
|
||||
It MUST always treat the following as unavailable:
|
||||
|
||||
- `force_complete`
|
||||
- `mark_succeeded`
|
||||
- `delete`
|
||||
- `purge`
|
||||
- `retry_high_risk`
|
||||
- `reexecute_restore`
|
||||
|
||||
## Localization Requirements
|
||||
|
||||
Add EN/DE localization keys for:
|
||||
|
||||
- action labels
|
||||
- action descriptions
|
||||
- success/error/unavailable messages
|
||||
- disabled reasons
|
||||
- high-risk guard messages
|
||||
- outcome summary titles/descriptions
|
||||
- audit/action metadata labels when surfaced
|
||||
|
||||
Example key families:
|
||||
|
||||
- `operations.actions.reconcile.*`
|
||||
- `operations.actions.retry.*`
|
||||
- `operations.actions.view_related.*`
|
||||
- `operations.actions.disabled.*`
|
||||
- `operations.summary.restore_verification_required.*`
|
||||
- `operations.summary.partial.*`
|
||||
|
||||
## Nonfunctional Requirements
|
||||
|
||||
- **NFR-001 - Decision-first UX**: Every OperationRun detail answers what happened, why it matters, safe next action, and evidence.
|
||||
- **NFR-002 - Fail closed**: If eligibility is uncertain, do not offer the action.
|
||||
- **NFR-003 - No high-risk retry**: Restore/tenant mutation/destructive mutation are not retryable in this spec.
|
||||
- **NFR-004 - No destructive actions**: No delete/purge/force-success actions.
|
||||
- **NFR-005 - Idempotency**: Reconcile and retry actions must be safe against double execution. Buttons must be disabled/debounced while executing.
|
||||
- **NFR-006 - Auditability**: Actions and relevant attempts must be traceable.
|
||||
- **NFR-007 - Capability-first RBAC**: UI hiding is never the only control.
|
||||
- **NFR-008 - Workspace/environment isolation**: No cross-scope action.
|
||||
- **NFR-009 - Livewire v4 compatibility**: No Livewire v3 APIs such as `emit`, `emitTo`, or `dispatchBrowserEvent`.
|
||||
- **NFR-010 - No asset changes**: No Filament/Tailwind/build asset changes unless repo verification proves they are required.
|
||||
- **NFR-011 - Explicit mutation scope**: State-changing actions must show TenantPilot-only, Microsoft-tenant, or simulation-only scope before execution.
|
||||
|
||||
## Entities / Derived Contracts *(include if feature involves data)*
|
||||
|
||||
No new persisted entity is required.
|
||||
|
||||
Derived contracts:
|
||||
|
||||
- **OperationRunActionEligibilityResult**: primary action, secondary actions, disabled actions, disabled reasons, attention reason, diagnostics visibility.
|
||||
- **OperationRunOperatorActionMetadata**: audit/action metadata written through existing audit or OperationRun context only when needed.
|
||||
- **OperationRunDecisionSummary**: presenter output for default-visible summary/evidence/high-risk state.
|
||||
|
||||
Existing canonical context keys in scope:
|
||||
|
||||
- `context.dispatch`
|
||||
- `context.reconciliation`
|
||||
- `context.results`
|
||||
- `context.coverage`
|
||||
- `context.restore`
|
||||
- `context.operator_actions` only if existing audit seam is insufficient and implementation records a bounded decision.
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Filament v5.2.1 with Livewire v4.1.4 is the target UI stack.
|
||||
- Laravel panel providers remain registered in `apps/platform/bootstrap/providers.php`; this spec does not add a panel provider.
|
||||
- `OperationRunResource` remains not globally searchable unless a future implementation explicitly adds a View/Edit page compliant global-search contract. Current prep assumes global search stays disabled for OperationRun resource.
|
||||
- Reconcile uses existing adapter registry and `OperationRunService`.
|
||||
- Generic retry is not assumed. It is available only per operation family when implementation verifies or creates a safe, idempotent, audited start seam.
|
||||
- OperationRun acknowledge is deferred unless implementation verifies a clean existing audit/note seam.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- New adapter framework or adapter families.
|
||||
- New report/evidence reconciliation.
|
||||
- New sync/backup/restore semantics.
|
||||
- Restore retry/re-execute.
|
||||
- Generic retry framework.
|
||||
- Force complete, mark succeeded, delete, purge, cancel.
|
||||
- Full decision-based governance inbox.
|
||||
- New notification routing architecture.
|
||||
- New customer review workspace.
|
||||
- New top-level Operations pages.
|
||||
- New assets or frontend build strategy.
|
||||
|
||||
## Success Metrics *(mandatory)*
|
||||
|
||||
- **SM-001**: For every covered matrix state, resolver returns at most one primary action.
|
||||
- **SM-002**: High-risk restore/destructive operations expose zero retry/re-execute/force-success actions in unit, feature, and browser tests.
|
||||
- **SM-003**: Eligible reconcile actions execute only through the existing reconciliation registry and `OperationRunService`.
|
||||
- **SM-004**: Direct action execution without capability or scope fails server-side.
|
||||
- **SM-005**: Browser smoke proves no raw technical leakage in default Operations UI.
|
||||
- **SM-006**: Spec 358-364 regression tests remain green in the planned validation lane.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- **AC-001**: Action eligibility resolver exists.
|
||||
- **AC-002**: Each relevant run state has at most one primary action.
|
||||
- **AC-003**: Reconcile is only available when adapter, proof, RBAC, and scope pass.
|
||||
- **AC-004**: Retry is only available for safe non-high-risk operations with a repo-verified seam.
|
||||
- **AC-005**: Restore/high-risk operations are not retryable.
|
||||
- **AC-006**: No Force Complete or Mark Succeeded action exists.
|
||||
- **AC-007**: Related domain links use canonical metadata and existing link/policy seams.
|
||||
- **AC-008**: RBAC is enforced server-side.
|
||||
- **AC-009**: Scope isolation is enforced.
|
||||
- **AC-010**: Operator actions are audit-visible.
|
||||
- **AC-011**: Operation detail is decision-first.
|
||||
- **AC-012**: Raw technical leakage is absent by default.
|
||||
- **AC-013**: Regression gate matrix exists and is test-covered.
|
||||
- **AC-014**: Specs 358-364 remain stable.
|
||||
- **AC-015**: State-changing operator actions disclose mutation scope before execution.
|
||||
|
||||
## Filament v5 Output Contract
|
||||
|
||||
1. **Livewire v4.0+ compliance**: Required. This project uses Livewire v4.1.4. No Livewire v3 APIs.
|
||||
2. **Provider registration location**: No panel provider changes. If any future provider change becomes necessary, Laravel 12 panel providers belong in `apps/platform/bootstrap/providers.php`.
|
||||
3. **Globally searchable resources**: `OperationRunResource` currently disables global search. If changed later, it must have an Edit or View page and record title contract.
|
||||
4. **Destructive actions**: This spec adds no destructive actions. Reconcile/retry are state-changing and must execute through `Action::make(...)->action(...)`, with confirmation where risk or current Filament action rules require it, explicit mutation-scope copy, plus server-side authorization and audit.
|
||||
5. **Asset strategy**: No new assets. Deploy process only needs `cd apps/platform && php artisan filament:assets` if implementation registers Filament assets, which this spec does not plan.
|
||||
6. **Testing plan**: Unit tests for resolver/primary action/high-risk guard/presenter; feature tests for actions/RBAC/scope/audit/related links; browser smoke for Operations list/detail representative states and raw leakage guard.
|
||||
@ -0,0 +1,243 @@
|
||||
# Tasks: Operations UI Operator Actions & Regression Gate
|
||||
|
||||
**Input**: Design documents from `/specs/365-operations-ui-operator-actions-regression-gate/`
|
||||
**Prerequisites**: [plan.md](./plan.md), [spec.md](./spec.md), [artifacts/spec365-action-eligibility-matrix.md](./artifacts/spec365-action-eligibility-matrix.md), [artifacts/spec365-regression-gate-matrix.md](./artifacts/spec365-regression-gate-matrix.md)
|
||||
|
||||
**Tests**: Required. Runtime changes must use Pest 4 unit/feature/browser coverage.
|
||||
|
||||
## Repository State Captured During Prep
|
||||
|
||||
- **Branch**: `365-operations-ui-operator-actions-regression-gate`
|
||||
- **HEAD**: `3ce1cae7 feat: implement restore high risk operation reconciliation (#435)`
|
||||
- **git status at prep start**: clean on `platform-dev` before branch creation; after Spec Kit branch creation only `specs/365-operations-ui-operator-actions-regression-gate/` was untracked.
|
||||
- **Spec 364 baseline status**: treated as completed immediate predecessor; implementation must keep Spec364 restore/high-risk tests green.
|
||||
- **Relevant Operations UI / action files**:
|
||||
- `apps/platform/app/Filament/Pages/Monitoring/Operations.php`
|
||||
- `apps/platform/app/Filament/Resources/OperationRunResource.php`
|
||||
- `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`
|
||||
- `apps/platform/app/Support/OpsUx/OperationUxPresenter.php`
|
||||
- `apps/platform/app/Support/OpsUx/OperationRunProgressContract.php`
|
||||
- `apps/platform/app/Support/OperationRunLinks.php`
|
||||
- `apps/platform/app/Support/Navigation/RelatedNavigationResolver.php`
|
||||
- `apps/platform/app/Support/Navigation/RelatedActionLabelCatalog.php`
|
||||
- `apps/platform/app/Policies/OperationRunPolicy.php`
|
||||
- `apps/platform/app/Support/OperationRunCapabilityResolver.php`
|
||||
- `apps/platform/app/Support/Auth/Capabilities.php`
|
||||
- `apps/platform/app/Services/AdapterRunReconciler.php`
|
||||
- `apps/platform/app/Support/Operations/Reconciliation/OperationRunReconciliationRegistry.php`
|
||||
- `apps/platform/app/Services/OperationRunService.php`
|
||||
|
||||
## Implementation Decisions to Record During Close-Out
|
||||
|
||||
- **Implemented actions**: Reconcile for eligible stale adapter-backed OperationRuns; safe related navigation for review, evidence snapshot, review pack/report, inventory affected-family/details, backup details, and restore details; support diagnostics remains secondary/capability-gated.
|
||||
- **Deferred actions**: generic Retry for all families because no repo-verified generic safe retry/start seam was found; OperationRun Acknowledge because no clean existing acknowledge/note seam exists.
|
||||
- **Unsupported/forbidden actions**: Force Complete, Mark Succeeded, Retry Restore, Re-execute Restore, Delete, Purge.
|
||||
- **Coverage artifact decision**: updated `docs/ui-ux-enterprise-audit/page-reports/ui-003-operations.md` because the existing Operations strategic surface gained visible safe-next-action hierarchy.
|
||||
- **Spec 358-364 regression result**: targeted Spec359/Spec360 browser/Spec364 plus OperationRun viewer/link/monitoring/resource presentation regressions passed locally; final filter sweep recorded under Phase 13.
|
||||
- **Mutation scope disclosure result**: Reconcile confirmation discloses TenantPilot-only OperationRun/action metadata and explicitly states no Microsoft tenant retry/change. Retry was not implemented.
|
||||
|
||||
## Test Governance Checklist
|
||||
|
||||
- [x] Lane assignment is named and is the narrowest sufficient proof for the changed behavior.
|
||||
- [x] New or changed tests stay in the smallest honest family, and any browser addition is explicit.
|
||||
- [x] Shared helpers, factories, seeds, fixtures, and context defaults stay cheap by default; any widening is isolated or documented.
|
||||
- [x] Planned validation commands cover the change without pulling in unrelated lane cost.
|
||||
- [x] The declared surface test profile (`monitoring-state-page`, `shared-detail-family`) is explicit.
|
||||
- [x] Any material budget, baseline, trend, or escalation note is recorded in the active spec or PR.
|
||||
|
||||
## Format: `[ID] [P?] [Story] Description`
|
||||
|
||||
- **[P]**: Can run in parallel after prerequisites.
|
||||
- **[Story]**: US1, US2, US3, US4, US5, US6.
|
||||
|
||||
## Phase 1: Setup and Audit (Shared)
|
||||
|
||||
**Purpose**: Confirm repo seams, docs, capabilities, and existing UI before implementation.
|
||||
|
||||
- [x] T001 [P] Re-read `specs/365-operations-ui-operator-actions-regression-gate/spec.md`, `plan.md`, both matrix artifacts, and `.specify/memory/constitution.md` before code changes.
|
||||
- [x] T002 [P] Audit current OperationRun UI/action code in `apps/platform/app/Filament/Pages/Monitoring/Operations.php`, `apps/platform/app/Filament/Resources/OperationRunResource.php`, and `apps/platform/app/Filament/Pages/Operations/TenantlessOperationRunViewer.php`.
|
||||
- [x] T003 [P] Audit current shared OperationRun UX/link seams in `OperationUxPresenter`, `OperationRunProgressContract`, `OperationRunLinks`, `RelatedNavigationResolver`, and `RelatedActionLabelCatalog`.
|
||||
- [x] T004 [P] Audit action authorization seams in `OperationRunPolicy`, `OperationRunCapabilityResolver`, and `Capabilities` to decide whether existing capabilities cover reconcile/retry/diagnostics.
|
||||
- [x] T005 [P] Audit existing audit/action metadata seams in `AuditRecorder`, `WorkspaceAuditLogger`, and OperationRun context writes to decide whether `context.operator_actions` is needed.
|
||||
- [x] T006 [P] Audit safe retry/resume/start seams, including `TenantlessOperationRunViewer::resumeCaptureAction()` and any operation-family start services; record unsupported families in the close-out section above.
|
||||
|
||||
## Phase 2: Foundational Resolver and Contracts (Blocking)
|
||||
|
||||
**Purpose**: Add the single action decision path before UI wiring.
|
||||
|
||||
- [x] T007 [US1] Add a narrow resolver such as `apps/platform/app/Support/Operations/OperationRunActionEligibility.php` that derives primary/secondary/disabled actions from canonical OperationRun truth, user, workspace, and environment scope.
|
||||
- [x] T008 [US1] Add a small derived result object or array contract for `primary_action`, `secondary_actions`, `disabled_actions`, `disabled_reasons`, and `attention_reason`; keep it non-persisted and avoid new status/outcome enums.
|
||||
- [x] T009 [US1] Ensure resolver reads canonical `context.dispatch`, `context.reconciliation`, `context.results`, `context.coverage`, and `context.restore` only; do not add new legacy fallback paths.
|
||||
- [x] T010 [US3] Encode high-risk classification for restore/tenant mutation/destructive/unknown operations so retry/re-execute/force-success always fail closed.
|
||||
- [x] T011 [US2] Encode reconcile eligibility through `OperationRunReconciliationRegistry` support and current run state/freshness/proof rules.
|
||||
- [x] T012 [US4] Encode retry eligibility as unavailable by default unless a repo-verified safe non-high-risk retry/start seam exists for the operation family.
|
||||
- [x] T013 [US5] Encode related action eligibility using canonical related metadata and existing link/navigation resolvers.
|
||||
- [x] T014 [US6] Encode diagnostics visibility through existing support/operator capability checks.
|
||||
|
||||
## Phase 3: Unit Tests First (Resolver, Presenter, Guard)
|
||||
|
||||
**Purpose**: Lock action decisions before Filament UI changes.
|
||||
|
||||
- [x] T015 [P] [US1] Add `apps/platform/tests/Unit/Support/Operations/Spec365OperationRunActionEligibilityTest.php` covering fresh queued, stale queued, stale running, unsupported, missing capability, and cross-workspace inputs.
|
||||
- [x] T016 [P] [US1] Add `apps/platform/tests/Unit/Support/Operations/Spec365OperationRunPrimaryActionTest.php` covering one primary action for review, evidence, review-pack/report, sync partial, backup blocked, restore verification-required, and restore failed.
|
||||
- [x] T017 [P] [US3] Cover high-risk action guard assertions in the Spec365 unit tests, proving restore/high-risk has no retry, re-execute, force complete, mark succeeded, delete, or purge action.
|
||||
- [x] T018 [P] [US1] Update OperationRun detail/presentation/browser coverage for outcome-specific decision summaries and raw-leakage sanitization.
|
||||
- [x] T019 [P] [US4] Add unit coverage proving safe retry returns unavailable/deferred for operation families without a repo-verified retry seam.
|
||||
|
||||
## Phase 4: Existing Operations UI Integration
|
||||
|
||||
**Purpose**: Make the existing surfaces decision-first without creating new pages.
|
||||
|
||||
- [x] T020 [US1] Update `Operations.php` table/list presentation to surface status/outcome, freshness, scope, primary reason, and one resolver-provided primary action or action label.
|
||||
- [x] T021 [US1] Update `OperationRunResource.php` detail sections to place decision summary, evidence, and next action before technical diagnostics.
|
||||
- [x] T022 [US1] Update `TenantlessOperationRunViewer.php` header/action groups to consume resolver output for primary and secondary actions.
|
||||
- [x] T023 [US1] Ensure technical/raw context sections are collapsed and capability-gated by default.
|
||||
- [x] T024 [US1] Remove or demote any redundant "View" action when row click/detail link already provides the primary inspect model.
|
||||
- [x] T025 [US3] Ensure high-risk restore detail uses "View restore details" or equivalent safe navigation and never presents retry/re-execute/force-success copy.
|
||||
|
||||
## Phase 5: Safe Reconcile Action
|
||||
|
||||
**Purpose**: Integrate reconciliation only through existing canonical seams.
|
||||
|
||||
- [x] T026 [US2] Add a Filament reconcile action on the appropriate OperationRun detail/header surface using `Action::make(...)->action(...)` and confirmation/description copy where appropriate.
|
||||
- [x] T078 [US2] Ensure Reconcile action helper text, modal description, preview, or confirmation communicates mutation scope before execution, normally TenantPilot-only OperationRun/action metadata.
|
||||
- [x] T027 [US2] Enforce server-side authorization and scope in `OperationRunPolicy` or a central action policy/helper; preserve non-member 404 and missing-capability 403 semantics.
|
||||
- [x] T028 [US2] Execute reconcile through `AdapterRunReconciler` and `OperationRunService`; do not mutate OperationRun state directly in the UI action.
|
||||
- [x] T029 [US2] Make reconcile idempotent for already reconciled or no-op adapter outcomes.
|
||||
- [x] T030 [US2] Write audit/action metadata for reconcile with action, run id, workspace, environment, actor, previous/new status/outcome, reason code, timestamp, and no secrets.
|
||||
- [x] T031 [US2] Add disabled/unavailable reason copy for unsupported, missing capability, cross-scope, insufficient proof, and already terminal/succeeded states.
|
||||
|
||||
## Phase 6: Safe Retry Action or Explicit Deferral
|
||||
|
||||
**Purpose**: Offer retry only where safe and repo-real.
|
||||
|
||||
- [x] T032 [US4] For each candidate non-high-risk operation family, verify whether a safe idempotent retry/start seam exists; document results in the close-out section.
|
||||
- [x] T033 [US4] No safe generic seam exists; retry action not implemented.
|
||||
- [x] T079 [US4] No Retry implemented; mutation-scope disclosure not applicable.
|
||||
- [x] T034 [US4] If no safe seam exists for a family, keep retry unavailable and show a localized disabled/deferred reason.
|
||||
- [x] T035 [US4] No Retry implemented; no new run-creation flood path added.
|
||||
- [x] T036 [US4] No Retry implemented; no retry audit metadata required.
|
||||
- [x] T037 [US4] Ensure completed/succeeded, unknown, restore, tenant mutation, destructive, and high-risk runs never become retryable.
|
||||
|
||||
## Phase 7: Related Domain Actions
|
||||
|
||||
**Purpose**: Let operators open proof without raw context inspection.
|
||||
|
||||
- [x] T038 [US5] Wire related actions through existing `OperationRunLinks` / `RelatedNavigationResolver` where canonical metadata and policy checks pass.
|
||||
- [x] T039 [US5] Support canonical related actions for review, evidence snapshot, review-pack/report artifact, backup set, sync/details if existing route exists, and restore details.
|
||||
- [x] T040 [US5] Hide or disable related actions when metadata is absent, capability is missing, or the target is cross-workspace/cross-environment.
|
||||
- [x] T041 [US5] Ensure no action reads signed URLs, raw payloads, or legacy fallback context from OperationRun context.
|
||||
|
||||
## Phase 8: Localization and Copy
|
||||
|
||||
**Purpose**: Keep labels and summaries customer-safe in EN/DE.
|
||||
|
||||
- [x] T042 [P] [US6] Add EN localization keys in `apps/platform/lang/en/localization.php` for reconcile, retry, related actions, disabled reasons, high-risk guard, and summary states.
|
||||
- [x] T043 [P] [US6] Add DE localization keys in `apps/platform/lang/de/localization.php` for the same key families.
|
||||
- [x] T044 [US6] Ensure primary action labels use Verb + Object and avoid implementation-first terms.
|
||||
- [x] T045 [US6] Ensure customer-readable copy does not expose SQL, Guzzle, stack trace, access token, client secret, queue payload, serialized job, or internal constraint names.
|
||||
|
||||
## Phase 9: Feature Tests
|
||||
|
||||
**Purpose**: Prove direct action behavior, RBAC, scope, audit, and related links.
|
||||
|
||||
- [x] T046 [P] [US2] Add `apps/platform/tests/Feature/Operations/Spec365OperationRunOperatorActionsTest.php` for reconcile success, unsupported reconcile denial, and idempotency.
|
||||
- [x] T047 [P] [US4] Add retry-unavailable tests in focused Spec365 unit/feature coverage; no Retry implemented.
|
||||
- [x] T048 [P] [US2,US4] Add `apps/platform/tests/Feature/Operations/Spec365OperationRunOperatorActionsTest.php` for missing capability and direct action denial.
|
||||
- [x] T049 [P] [US2,US5] Add cross-workspace and cross-environment denial tests.
|
||||
- [x] T050 [P] [US6] Add `apps/platform/tests/Feature/Operations/Spec365OperationRunOperatorActionsTest.php` for reconcile metadata and no-secret assertions.
|
||||
- [x] T080 [P] [US6] Extend denied-action coverage to assert failed/denied Reconcile attempts are audit-visible or safely logged without secrets; Retry not implemented.
|
||||
- [x] T051 [P] [US5] Cover same-scope related links and cross-scope denial in Spec365 unit/feature tests plus existing OperationRun link contract tests.
|
||||
- [x] T052 [P] [US1] Cover regression matrix states that do not require browser coverage in Spec365 unit tests and existing OperationRun presentation regressions.
|
||||
|
||||
## Phase 10: Browser Smoke
|
||||
|
||||
**Purpose**: Prove the actual Operations UI is decision-first and safe.
|
||||
|
||||
- [x] T053 [US1] Add `apps/platform/tests/Browser/Spec365OperationsUiOperatorActionsSmokeTest.php` for Operations list decision-first fields and no raw JSON by default.
|
||||
- [x] T054 [US2] Cover review reconcile state, confirmation modal, and absence of SQL/constraint leakage.
|
||||
- [x] T055 [US5] Cover review-pack/report and evidence snapshot available states with safe related actions.
|
||||
- [x] T056 [US1] Cover sync partial and backup blocked summary states.
|
||||
- [x] T057 [US3] Cover restore verification-required/high-risk state with safe restore details action and absence of Retry Restore, Force Complete, and Mark Succeeded.
|
||||
- [x] T058 [US6] Cover RBAC-denied user where reconcile/retry actions are unavailable in feature coverage; Retry not implemented.
|
||||
- [x] T059 [US6] Cover raw leakage guard for `SQLSTATE`, `Guzzle`, `stack trace`, `access token`, `client secret`, `environment_reviews_fingerprint_mutable_unique`, and `serialized job`.
|
||||
- [x] T060 [US1] Screenshots were not saved because the automated browser assertions were sufficient and no visual defect remained after smoke.
|
||||
|
||||
## Phase 11: Optional Acknowledge Decision
|
||||
|
||||
**Purpose**: Avoid a local "reviewed" substitute unless the repo already supports it cleanly.
|
||||
|
||||
- [x] T061 [US6] Verify whether a clean existing OperationRun acknowledge/note/audit seam exists.
|
||||
- [x] T062 [US6] No clean seam exists; Acknowledge not implemented.
|
||||
- [x] T063 [US6] Document Acknowledge as deferred in the close-out section and do not implement local context-only success-like state.
|
||||
|
||||
## Phase 12: Coverage Artifacts and Documentation Close-Out
|
||||
|
||||
**Purpose**: Keep Spec Kit and UI coverage aligned.
|
||||
|
||||
- [x] T064 [P] Update `artifacts/spec365-action-eligibility-matrix.md` if implementation changes eligible actions or disabled reasons.
|
||||
- [x] T065 [P] Update `artifacts/spec365-regression-gate-matrix.md` with actual test file names/statuses after implementation.
|
||||
- [x] T066 Update `docs/ui-ux-enterprise-audit/page-reports/ui-003-operations.md` and related design coverage files if implementation changes layout, action hierarchy, state hierarchy, or screenshots materially; record a no-update rationale only for pattern-compatible action/copy wiring.
|
||||
- [x] T067 Update this `tasks.md` close-out section with implemented/deferred/unsupported actions and validation outcomes.
|
||||
|
||||
## Phase 13: Validation
|
||||
|
||||
**Purpose**: Run the final Spec365 and regression gate.
|
||||
|
||||
- [x] T068 Run `cd apps/platform && php artisan test --compact --filter=Spec365` or targeted direct Spec365 lanes.
|
||||
- [x] T069 Run `cd apps/platform && php artisan test --compact --filter=Spec364`.
|
||||
- [x] T070 Run `cd apps/platform && php artisan test --compact --filter=Spec363`.
|
||||
- [x] T071 Run `cd apps/platform && php artisan test --compact --filter=Spec362`.
|
||||
- [x] T072 Run `cd apps/platform && php artisan test --compact --filter=Spec361`.
|
||||
- [x] T073 Run `cd apps/platform && php artisan test --compact --filter=Spec360`.
|
||||
- [x] T074 Run `cd apps/platform && php artisan test --compact --filter=Spec359`.
|
||||
- [x] T075 Run `cd apps/platform && php artisan test --compact --filter=Spec358`.
|
||||
- [x] T076 Run `cd apps/platform && php vendor/bin/pint --dirty`.
|
||||
- [x] T077 Run `git diff --check`.
|
||||
- [x] T081 Run a static scan over changed application files for Livewire v3 APIs: `emit`, `emitTo`, and `dispatchBrowserEvent`.
|
||||
- [x] T082 Review the final diff for Filament/Tailwind/build asset changes; if any are required, update `spec.md` and `plan.md` before merge.
|
||||
- [x] T083 If browser lane was not included in the Spec365 filter, run `cd apps/platform && php artisan test --compact tests/Browser/Spec365OperationsUiOperatorActionsSmokeTest.php`.
|
||||
|
||||
## Validation Close-Out
|
||||
|
||||
- `php artisan test --compact --filter=Spec365`: 23 passed, 160 assertions.
|
||||
- `php artisan test --compact --filter=Spec364`: 10 passed, 59 assertions.
|
||||
- `php artisan test --compact --filter=Spec363`: no tests found.
|
||||
- `php artisan test --compact --filter=Spec362`: 27 passed, 238 assertions.
|
||||
- `php artisan test --compact --filter=Spec361`: 16 passed, 123 assertions.
|
||||
- `php artisan test --compact --filter=Spec360`: 9 passed, 79 assertions.
|
||||
- `php artisan test --compact --filter=Spec359`: 25 passed, 150 assertions.
|
||||
- `php artisan test --compact --filter=Spec358`: no tests found.
|
||||
- `php vendor/bin/pest tests/Feature/Operations/Spec359OperationRunAdapterReconciliationTest.php tests/Feature/EnvironmentReview/Spec359ReviewComposeReconciliationTest.php tests/Feature/Operations/Spec364RestoreExecuteReconciliationTest.php tests/Feature/Operations/TenantlessOperationRunViewerTest.php tests/Feature/Guards/OperationRunLinkContractGuardTest.php`: 42 passed, 194 assertions.
|
||||
- `php vendor/bin/pest tests/Feature/Monitoring/MonitoringOperationsTest.php tests/Feature/Monitoring/OperationRunResolvedReferencePresentationTest.php tests/Feature/Monitoring/OperationLifecycleFreshnessPresentationTest.php tests/Feature/Filament/OperationRunEnterpriseDetailPageTest.php tests/Feature/Filament/OperationRunListFiltersTest.php tests/Feature/Filament/OperationRunBaselineTruthSurfaceTest.php`: 37 passed, 271 assertions.
|
||||
- `php artisan test --compact tests/Browser/Spec365OperationsUiOperatorActionsSmokeTest.php`: 3 passed, 42 assertions.
|
||||
- `php artisan test --compact tests/Browser/Spec360OperationRunCanonicalCutoverSmokeTest.php`: 2 passed, 21 assertions.
|
||||
- `php vendor/bin/pint --dirty`: passed after formatting dirty PHP files.
|
||||
- Livewire v3 API scan over changed app files for `emit`, `emitTo`, and `dispatchBrowserEvent`: no matches.
|
||||
- `git diff --check`: passed.
|
||||
- Final diff review: no Filament panel/provider registration changes, no Tailwind/build asset changes, no new migrations, no env var changes, no queue/cron/storage changes.
|
||||
|
||||
## Dependencies and Ordering
|
||||
|
||||
- Phase 1 must complete before runtime implementation.
|
||||
- Phase 2 must complete before Phases 4-7.
|
||||
- Phase 3 should be written before or alongside Phase 2.
|
||||
- Reconcile and retry feature tests depend on resolver and authorization decisions.
|
||||
- Browser smoke depends on visible UI wiring and localization.
|
||||
- Validation runs last.
|
||||
|
||||
## Parallel Execution Examples
|
||||
|
||||
- T002-T006 can run in parallel during audit.
|
||||
- T015-T019 can run in parallel after resolver contract is sketched.
|
||||
- T042-T043 can run in parallel with feature test implementation.
|
||||
- T046-T052 can be split by action family after shared factories/fixtures exist.
|
||||
|
||||
## Notes
|
||||
|
||||
- Do not add a new generic retry framework in this spec.
|
||||
- Do not add any restore retry/re-execute path.
|
||||
- Do not add Force Complete, Mark Succeeded, Delete, Purge, or equivalent copy.
|
||||
- Do not add new top-level Operations navigation.
|
||||
- Do not expose raw technical diagnostics by default.
|
||||
- Do not add new Filament/Tailwind assets unless implementation proves they are required and the spec/plan are updated first.
|
||||
Loading…
Reference in New Issue
Block a user