ability_name, $decided_by, $ran ? Outcome::SUCCESS : Outcome::ERROR, self::CODE_APPROVED, $ran ? 'Approved by a person and run.' : 'Approved by a person, but the action failed.' ); } /** * Record a rejection. * * A `warning`, not an `error`: nothing broke, a person said no. That is the * same statement the gate's own hold makes. * * @param PendingAction $action The action that was rejected. * @param int $decided_by The rejecting user. * * @return void * @since 1.5.0 */ public static function rejected( PendingAction $action, int $decided_by ): void { /** * Fires when a person rejects a held action. * * @since 1.5.0 * * @param PendingAction $action The rejected action. * @param int $decided_by The rejecting user. */ do_action( 'albert/safe_mode/rejected', $action, $decided_by ); self::record( $action->ability_name, $decided_by, Outcome::WARNING, self::CODE_REJECTED, 'Rejected by a person. The action never ran.' ); } /** * Record that a held call lapsed undecided. * * Attributed to the user the call would have run as, not to whoever the * cron happens to be: nobody decided, so there is no decider to name. * * @param PendingAction $action The action that lapsed. * * @return void * @since 1.5.0 */ public static function expired( PendingAction $action ): void { /** * Fires when a held action lapses without a decision. * * @since 1.5.0 * * @param PendingAction $action The lapsed action. */ do_action( 'albert/safe_mode/expired', $action ); self::record( $action->ability_name, $action->user_id, Outcome::WARNING, self::CODE_EXPIRED, 'Nobody decided in time. The action never ran.' ); } /** * Record an approved run that never reported back. * * An `error`, unlike the rest: something broke. And the honest part, which * the message says out loud, is that the work may or may not have completed * before it died. * * @param PendingAction $action The abandoned action. * * @return void * @since 1.5.0 */ public static function abandoned( PendingAction $action ): void { /** * Fires when an approved run is found abandoned. * * @since 1.5.0 * * @param PendingAction $action The abandoned action. */ do_action( 'albert/safe_mode/abandoned', $action ); self::record( $action->ability_name, $action->decided_by ?? $action->user_id, Outcome::ERROR, self::CODE_ABANDONED, 'Approved, but the run never reported back. It may or may not have completed.' ); } /** * Record a refused write to one of Albert's control options. * * The ability name is the option, because no ability id is knowable here: * the guard is on the resource and fires for whatever tried to write it, * including code that called `update_option()` directly. * * @param string $option The option whose write was refused. * @param string $operation What was attempted: `write` or `delete`. * * @return void * @since 1.5.0 */ public static function option_blocked( string $option, string $operation = 'write' ): void { $message = $operation === 'delete' ? sprintf( 'An assistant attempted to delete the protected option %s. Refused; the option is unchanged.', $option ) : sprintf( 'An assistant attempted to write the protected option %s. Refused; the stored value is unchanged.', $option ); self::record( 'albert/protected-option:' . $option, get_current_user_id(), Outcome::WARNING, self::CODE_OPTION_BLOCKED, $message ); } /** * Record a protected option put back after a write that skipped the option API. * * @param string $option The protected option. * @param bool $restored Whether putting it back succeeded. * * @return void * @since 1.5.0 */ public static function option_restored( string $option, bool $restored ): void { $message = $restored ? sprintf( 'The protected option %s was changed directly in the database during an assistant request. It has been put back.', $option ) : sprintf( 'The protected option %s may have been changed directly in the database during an assistant request, and could not be checked or put back. Check it in Albert\'s settings.', $option ); self::record( 'albert/protected-option:' . $option, get_current_user_id(), Outcome::ERROR, self::CODE_OPTION_RESTORED, $message ); } /** * Write one activity-log row, if Free is the writer. * * Gated on `albert/logging/enabled` exactly like {@see \Albert\Logging\ObservabilityHandler}: * Premium returns false there and owns the table itself, and it hears every * one of these through the actions above regardless. * * @param string $ability_name Ability id, or a resource identifier for the guard. * @param int $user_id Who the row is attributed to. * @param string $status An {@see Outcome} status. * @param string $code The event code. * @param string $message Human-readable detail. * * @return void * @since 1.5.0 */ private static function record( string $ability_name, int $user_id, string $status, string $code, string $message ): void { try { if ( ! apply_filters( 'albert/logging/enabled', true ) ) { return; } ( new LogRepository() )->insert( $ability_name, $user_id, $status, $code, [ 'error_message' => $message ] ); } catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch // Recording that something happened must never break anything else. } } }