1021 lines
50 KiB
PHP
1021 lines
50 KiB
PHP
<?php
|
|
|
|
namespace classes;
|
|
|
|
require_once WD . '/classes/xlvask_usage_logs_schema_bootstrap.php';
|
|
|
|
use Exception;
|
|
use Throwable;
|
|
|
|
final class xlvask_automation_control_stop extends Exception
|
|
{
|
|
public function __construct(public readonly string $reasonCode, string $message)
|
|
{
|
|
parent::__construct($message);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Server-authoritative activation, halt, soak and rolling-budget policy for XL Vask automation.
|
|
*
|
|
* Module configuration remains an immediate kill switch. It is deliberately not sufficient to
|
|
* enable automatic actions: an exact policy transition must also have been previewed and applied.
|
|
*/
|
|
class xlvask_automation_policy_service
|
|
{
|
|
public const ATTACH_DAILY_CAP = 100;
|
|
public const ATTACH_PER_HALL_DAILY_CAP = 10;
|
|
public const CREATE_DAILY_CAP = 20;
|
|
public const CREATE_PER_HALL_DAILY_CAP = 3;
|
|
public const LINK_SOAK_TARGET = 200;
|
|
public const CREATE_SOAK_TARGET = 50;
|
|
|
|
private const PREVIEW_TTL_SECONDS = 900;
|
|
private const POLICY_ROW_ID = 1;
|
|
public const STAGES = [
|
|
'off', 'advisory', 'ai_attach_canary', 'ai_attach_verified',
|
|
'ai_create_canary', 'verified_capped', 'halted',
|
|
];
|
|
|
|
public function capabilitiesReadOnly(?string $dateFrom = null, ?string $dateTo = null, array $hallIds = []): array
|
|
{
|
|
$readiness = $this->readinessReadOnly($dateFrom, $dateTo, $hallIds);
|
|
$policy = (array)($readiness['policy'] ?? []);
|
|
$blocked = (array)($readiness['blocked_reasons'] ?? []);
|
|
$executeEnabled = in_array((string)($policy['effective_stage'] ?? 'off'), [
|
|
'ai_attach_canary', 'ai_attach_verified', 'ai_create_canary', 'verified_capped',
|
|
], true) && !(bool)($policy['halted'] ?? false) && (bool)($readiness['ready'] ?? false);
|
|
|
|
return [
|
|
'effective_stage' => (string)($policy['effective_stage'] ?? 'halted'),
|
|
'allowed_modes' => ['dry_run', 'replay', ...($executeEnabled ? ['execute'] : [])],
|
|
'effective_action_sources' => $executeEnabled
|
|
&& ((bool)($policy['attach_enabled'] ?? false) || (bool)($policy['create_enabled'] ?? false))
|
|
? ['openai']
|
|
: [],
|
|
'blocked_reasons' => $blocked,
|
|
'readiness' => $readiness,
|
|
'active_run' => $this->firstActiveRunReadOnly($hallIds),
|
|
];
|
|
}
|
|
|
|
public function readinessReadOnly(?string $dateFrom = null, ?string $dateTo = null, array $hallIds = []): array
|
|
{
|
|
$identity = xlvask_automation_service::automationIdentityForAutomation();
|
|
$state = $this->policyStateReadOnly();
|
|
$calibrations = $this->activeCalibrationSnapshotReadOnly();
|
|
$budgets = $this->budgetSnapshotReadOnly($hallIds, $state);
|
|
$runtime = $this->runtimeConfigReadOnly();
|
|
$activeRun = $this->firstActiveRunReadOnly($hallIds);
|
|
$scopeValid = self::scopeDatesAreValid($dateFrom, $dateTo);
|
|
$eligibleCounts = $scopeValid
|
|
? $this->eligibleCountsReadOnly($dateFrom, $dateTo, $hallIds)
|
|
: ['attach_order' => 0, 'create_order' => 0, 'total' => 0];
|
|
$blocked = [];
|
|
$migration = xlvask_usage_logs_schema_bootstrap::migrationStatus();
|
|
$stage = $state === null ? 'off' : ((bool)($state['halted'] ?? false) ? 'halted' : (string)($state['stage'] ?? 'off'));
|
|
|
|
if (!$migration['ready']) {
|
|
$blocked[] = 'automation_schema_not_ready';
|
|
}
|
|
if (!$scopeValid) {
|
|
$blocked[] = 'invalid_invoice_period_scope';
|
|
}
|
|
if (!xlvask_usage_logs_schema_bootstrap::washIdUniquenessReady()) {
|
|
$blocked[] = 'wash_id_uniqueness_not_ready';
|
|
}
|
|
if (!$runtime['xlvask_enabled']) {
|
|
$blocked[] = 'xlvask_module_disabled';
|
|
}
|
|
if (!$runtime['openai_enabled']) {
|
|
$blocked[] = 'openai_disabled';
|
|
}
|
|
if ($state !== null && !hash_equals((string)$state['planner_identity_hash'], (string)$identity['identity_hash'])) {
|
|
$blocked[] = 'planner_identity_changed';
|
|
}
|
|
$policy = $this->formatPolicy($state, $runtime, $budgets, $calibrations, $identity);
|
|
if ($stage === 'halted') {
|
|
$blocked[] = 'automation_halted';
|
|
}
|
|
|
|
$requiredSegments = [];
|
|
if (in_array($stage, ['ai_attach_canary', 'ai_attach_verified', 'ai_create_canary', 'verified_capped'], true)) {
|
|
$requiredSegments[] = 'openai:attach_order';
|
|
}
|
|
if (in_array($stage, ['ai_create_canary', 'verified_capped'], true)) {
|
|
$requiredSegments[] = 'openai:create_order';
|
|
}
|
|
foreach ($requiredSegments as $segment) {
|
|
if (!isset($calibrations[$segment])) {
|
|
$blocked[] = 'calibration_missing:' . $segment;
|
|
}
|
|
}
|
|
|
|
$blocked = array_values(array_unique($blocked));
|
|
return [
|
|
'ready' => $blocked === [],
|
|
'blocked_reasons' => $blocked,
|
|
'policy' => $policy,
|
|
'workers' => [
|
|
'active_execute_run' => $activeRun,
|
|
'queue_contract' => 'xlvask_autopilot_queue_every_60_seconds',
|
|
'health' => $activeRun === null ? 'idle' : 'active',
|
|
],
|
|
'worker_healthy' => (bool)$migration['ready'] && !$this->hasExpiredRunLeaseReadOnly(),
|
|
'calibrations' => array_values($calibrations),
|
|
'budgets' => $budgets,
|
|
'review_progress' => [
|
|
'attach_order' => [
|
|
'reviewed' => (int)$budgets['soak']['reviewed_correct_links'],
|
|
'target' => self::LINK_SOAK_TARGET,
|
|
'since' => $state['attach_activated_at'] ?? null,
|
|
],
|
|
'create_order' => [
|
|
'reviewed' => (int)$budgets['soak']['reviewed_correct_creations'],
|
|
'target' => self::CREATE_SOAK_TARGET,
|
|
'since' => $state['create_activated_at'] ?? null,
|
|
],
|
|
],
|
|
'planner_identity' => $identity,
|
|
'policy_version' => (string)$identity['policy_version'],
|
|
'model' => (string)$identity['model'],
|
|
'wash_id_uniqueness_ready' => xlvask_usage_logs_schema_bootstrap::washIdUniquenessReady(),
|
|
'migration' => $migration,
|
|
'eligible_counts' => $eligibleCounts,
|
|
];
|
|
}
|
|
|
|
public function activeRunReadOnly(array $hallIds = []): ?array
|
|
{
|
|
return $this->firstActiveRunReadOnly($hallIds);
|
|
}
|
|
|
|
public function createPolicyPreview(string $targetStage, string $reason, ?int $actorId): array
|
|
{
|
|
global $db;
|
|
if ($actorId === null) {
|
|
throw new Exception('An authenticated administrator is required.');
|
|
}
|
|
$targetStage = strtolower(trim($targetStage));
|
|
$reason = mb_substr(trim($reason), 0, 1000);
|
|
if (!in_array($targetStage, array_values(array_diff(self::STAGES, ['halted'])), true)) {
|
|
throw new Exception('Invalid XL Vask automation policy transition.');
|
|
}
|
|
if ($reason === '') {
|
|
throw new Exception('A reason is required for the XL Vask automation policy transition.');
|
|
}
|
|
|
|
xlvask_usage_logs_schema_bootstrap::ensureTables();
|
|
$this->ensurePolicyState();
|
|
$readiness = $this->readinessReadOnly();
|
|
$this->assertTransitionAllowed($targetStage, $readiness);
|
|
$state = (array)$this->policyStateReadOnly();
|
|
$snapshot = $this->policySnapshotForTransition($targetStage, $state, $readiness, $reason);
|
|
$id = self::uuidV4();
|
|
$selectionHash = hash('sha256', xlvask_automation_service::stableJsonForAutomation($snapshot));
|
|
$confirmation = 'APPLY-XLVASK-' . strtoupper(str_replace('_', '-', $targetStage)) . '-' . $id;
|
|
$payload = $snapshot + ['confirmation_phrase' => $confirmation];
|
|
$payloadJson = $db->escape_string(xlvask_automation_service::stableJsonForAutomation($payload));
|
|
$idSql = $db->escape_string($id);
|
|
$hashSql = $db->escape_string($selectionHash);
|
|
$transitionSql = $db->escape_string($targetStage);
|
|
if ($db->query(
|
|
"INSERT INTO xlvask_automation_policy_previews
|
|
(id, selection_hash, requested_transition, payload_json, created_by, expires_at)
|
|
VALUES ('{$idSql}', '{$hashSql}', '{$transitionSql}', '{$payloadJson}', {$actorId},
|
|
DATE_ADD(NOW(), INTERVAL " . self::PREVIEW_TTL_SECONDS . " SECOND))"
|
|
) === false || $db->conn()->affected_rows !== 1) {
|
|
throw new Exception('The XL Vask automation policy preview could not be stored.');
|
|
}
|
|
|
|
return [
|
|
'id' => $id,
|
|
'selection_hash' => $selectionHash,
|
|
'confirmation_phrase' => $confirmation,
|
|
'requires_confirmation' => true,
|
|
'expires_at' => date('c', time() + self::PREVIEW_TTL_SECONDS),
|
|
'requested_transition' => $targetStage,
|
|
'reason' => $reason,
|
|
'expected_policy_version' => (int)($state['expected_version'] ?? 0),
|
|
'readiness_snapshot' => $readiness,
|
|
];
|
|
}
|
|
|
|
public function applyPolicyPreview(array $input, ?int $actorId): array
|
|
{
|
|
global $db;
|
|
$previewId = trim((string)($input['preview_id'] ?? ''));
|
|
$selectionHash = trim((string)($input['selection_hash'] ?? ''));
|
|
$confirmation = (string)($input['confirmation_text'] ?? '');
|
|
if ($actorId === null || !preg_match('/^[0-9a-f-]{36}$/i', $previewId) || !preg_match('/^[0-9a-f]{64}$/', $selectionHash)) {
|
|
throw new Exception('Invalid XL Vask automation policy preview identifiers.');
|
|
}
|
|
|
|
xlvask_usage_logs_schema_bootstrap::ensureTables();
|
|
$this->ensurePolicyState();
|
|
$connection = $db->conn();
|
|
$connection->begin_transaction();
|
|
try {
|
|
$idSql = $db->escape_string($previewId);
|
|
$result = $db->query("SELECT * FROM xlvask_automation_policy_previews WHERE id = '{$idSql}' FOR UPDATE");
|
|
$preview = $result !== false && $result->num_rows > 0 ? $db->fetch_assoc($result) : null;
|
|
if ($preview === null
|
|
|| (int)$preview['created_by'] !== $actorId
|
|
|| !hash_equals((string)$preview['selection_hash'], $selectionHash)
|
|
|| !empty($preview['applied_at'])
|
|
|| strtotime((string)$preview['expires_at']) <= time()) {
|
|
throw new Exception('XL Vask automation policy preview is invalid, expired, or already applied.');
|
|
}
|
|
$payload = json_decode((string)$preview['payload_json'], true);
|
|
if (!is_array($payload) || !hash_equals((string)($payload['confirmation_phrase'] ?? ''), trim($confirmation))) {
|
|
throw new Exception('XL Vask automation policy confirmation text is invalid.');
|
|
}
|
|
|
|
$stateResult = $db->query('SELECT * FROM xlvask_automation_policy_state WHERE id = 1 FOR UPDATE');
|
|
$state = $stateResult !== false && $stateResult->num_rows > 0 ? $db->fetch_assoc($stateResult) : null;
|
|
if ($state === null || (int)$state['expected_version'] !== (int)($payload['expected_policy_version'] ?? -1)) {
|
|
throw new Exception('XL Vask automation policy changed after preview.');
|
|
}
|
|
$transition = (string)$preview['requested_transition'];
|
|
$readiness = $this->readinessReadOnly();
|
|
$this->assertTransitionAllowed($transition, $readiness);
|
|
$reason = mb_substr(trim((string)($payload['reason'] ?? '')), 0, 1000);
|
|
if ($reason === '') {
|
|
throw new Exception('The XL Vask automation policy transition reason is missing.');
|
|
}
|
|
$currentSnapshot = $this->policySnapshotForTransition($transition, $state, $readiness, $reason);
|
|
if (!hash_equals($selectionHash, hash('sha256', xlvask_automation_service::stableJsonForAutomation($currentSnapshot)))) {
|
|
throw new Exception('XL Vask automation readiness changed after preview.');
|
|
}
|
|
|
|
$assignments = $this->assignmentsForTransition($transition, $actorId);
|
|
if ($db->query(
|
|
'UPDATE xlvask_automation_policy_state SET ' . implode(', ', $assignments) .
|
|
', expected_version = expected_version + 1 WHERE id = 1 AND expected_version = ' . (int)$state['expected_version']
|
|
) === false || $db->conn()->affected_rows !== 1) {
|
|
throw new Exception('XL Vask automation policy could not be updated atomically.');
|
|
}
|
|
$this->syncLegacyKillSwitchesForStage($transition);
|
|
$this->recordPolicyEvent($transition, $actorId, [
|
|
'selection_hash' => $selectionHash,
|
|
'reason' => $reason,
|
|
]);
|
|
if ($db->query("UPDATE xlvask_automation_policy_previews SET applied_at = NOW() WHERE id = '{$idSql}' AND applied_at IS NULL") === false
|
|
|| $db->conn()->affected_rows !== 1) {
|
|
throw new Exception('XL Vask automation policy preview could not be finalized.');
|
|
}
|
|
$connection->commit();
|
|
} catch (Throwable $throwable) {
|
|
$connection->rollback();
|
|
throw $throwable;
|
|
}
|
|
|
|
return ['policy' => $this->readinessReadOnly()['policy'], 'readiness' => $this->readinessReadOnly()];
|
|
}
|
|
|
|
public function halt(?int $actorId, string $reason): array
|
|
{
|
|
global $db;
|
|
if ($actorId === null) {
|
|
throw new Exception('An authenticated administrator is required.');
|
|
}
|
|
xlvask_usage_logs_schema_bootstrap::ensureTables();
|
|
$this->ensurePolicyState();
|
|
$connection = $db->conn();
|
|
$connection->begin_transaction();
|
|
try {
|
|
$result = $db->query('SELECT expected_version FROM xlvask_automation_policy_state WHERE id = 1 FOR UPDATE');
|
|
if ($result === false || $result->num_rows < 1) {
|
|
throw new Exception('XL Vask automation policy is unavailable.');
|
|
}
|
|
$reason = mb_substr(trim($reason), 0, 1000);
|
|
$reasonSql = $reason === '' ? 'NULL' : "'" . $db->escape_string($reason) . "'";
|
|
if ($db->query(
|
|
"UPDATE xlvask_automation_policy_state
|
|
SET stage = 'halted', halted = 1, attach_enabled = 0, create_enabled = 0, halt_reason = {$reasonSql},
|
|
halted_at = NOW(), halted_by = {$actorId}, expected_version = expected_version + 1
|
|
WHERE id = 1"
|
|
) === false || $db->conn()->affected_rows !== 1) {
|
|
throw new Exception('XL Vask automation could not be halted atomically.');
|
|
}
|
|
$this->syncLegacyKillSwitchesForStage('halted');
|
|
$this->recordPolicyEvent('halt', $actorId, [
|
|
'reason' => $reason === '' ? null : $reason,
|
|
'reason_present' => $reason !== '',
|
|
]);
|
|
$connection->commit();
|
|
} catch (Throwable $throwable) {
|
|
$connection->rollback();
|
|
throw $throwable;
|
|
}
|
|
return ['policy' => $this->readinessReadOnly()['policy'], 'readiness' => $this->readinessReadOnly()];
|
|
}
|
|
|
|
/** Called inside the order mutation transaction; rollback also releases the cap reservation. */
|
|
public function reserveAutomaticAction(array $suggestion, array $context): void
|
|
{
|
|
global $db;
|
|
$action = (string)($suggestion['action'] ?? '');
|
|
if (!in_array($action, ['attach_order', 'create_order'], true)) {
|
|
throw new Exception('Unsupported XL Vask automatic action.');
|
|
}
|
|
$stateResult = $db->query('SELECT * FROM xlvask_automation_policy_state WHERE id = 1 FOR UPDATE');
|
|
$state = $stateResult !== false && $stateResult->num_rows > 0 ? $db->fetch_assoc($stateResult) : null;
|
|
$identity = xlvask_automation_service::automationIdentityForAutomation();
|
|
if ($state === null || (bool)$state['halted']
|
|
|| !hash_equals((string)$state['policy_version'], (string)$identity['policy_version'])
|
|
|| !hash_equals((string)$state['planner_identity_hash'], (string)$identity['identity_hash'])) {
|
|
throw new xlvask_automation_control_stop(
|
|
'policy_halted_or_identity_changed',
|
|
'XL Vask automatic actions are halted or the planner identity changed.'
|
|
);
|
|
}
|
|
if (($action === 'attach_order' && !(bool)$state['attach_enabled'])
|
|
|| ($action === 'create_order' && (!(bool)$state['create_enabled'] || !(bool)$state['attach_enabled']))) {
|
|
throw new xlvask_automation_control_stop(
|
|
'policy_action_disabled',
|
|
'The XL Vask automatic action is not activated by server policy.'
|
|
);
|
|
}
|
|
if (!hash_equals((string)($suggestion['planner_identity_hash'] ?? ''), (string)$identity['identity_hash'])) {
|
|
throw new xlvask_automation_control_stop(
|
|
'planner_identity_changed',
|
|
'The XL Vask suggestion planner identity is stale.'
|
|
);
|
|
}
|
|
if ((string)($suggestion['source'] ?? '') !== 'openai'
|
|
|| !hash_equals((string)($suggestion['model'] ?? ''), (string)$identity['model'])) {
|
|
throw new xlvask_automation_control_stop(
|
|
'planner_identity_changed',
|
|
'The XL Vask suggestion resolved model does not match the calibrated planner identity.'
|
|
);
|
|
}
|
|
$segment = (string)($suggestion['source'] ?? '') . ':' . $action;
|
|
if (!$this->activeCalibrationMatchesIdentity($segment, (string)$identity['identity_hash'])) {
|
|
throw new xlvask_automation_control_stop(
|
|
'calibration_revoked',
|
|
'The XL Vask automatic action lacks an exact active calibration artifact.'
|
|
);
|
|
}
|
|
|
|
$runtime = $this->runtimeConfigReadOnly();
|
|
if (!$runtime['xlvask_enabled'] || !$runtime['openai_enabled']
|
|
|| ($action === 'attach_order' && !$runtime['attachment_config_enabled'])
|
|
|| ($action === 'create_order' && !$runtime['creation_config_enabled'])) {
|
|
throw new xlvask_automation_control_stop(
|
|
'runtime_disabled',
|
|
'The XL Vask automatic action was disabled by runtime configuration.'
|
|
);
|
|
}
|
|
|
|
$hallId = trim((string)($context['hall_id'] ?? ''));
|
|
if ($hallId === '' || strlen($hallId) > 191) {
|
|
throw new Exception('The XL Vask automatic action lacks a valid hall scope.');
|
|
}
|
|
$budget = $this->budgetCountsForAction($action, $hallId, [$hallId]);
|
|
[$dailyCap, $perHallDailyCap] = $action === 'attach_order'
|
|
? [self::ATTACH_DAILY_CAP, self::ATTACH_PER_HALL_DAILY_CAP]
|
|
: [self::CREATE_DAILY_CAP, self::CREATE_PER_HALL_DAILY_CAP];
|
|
if ($budget['global_last_24_hours'] >= $dailyCap || $budget['hall_last_24_hours'] >= $perHallDailyCap) {
|
|
throw new xlvask_automation_control_stop(
|
|
'budget_exhausted',
|
|
'The XL Vask automatic action rolling budget is exhausted.'
|
|
);
|
|
}
|
|
if ($action === 'create_order'
|
|
&& $this->reviewedCorrectCount('attach_order', $state['attach_activated_at'] ?? null) < self::LINK_SOAK_TARGET) {
|
|
throw new xlvask_automation_control_stop(
|
|
'attach_soak_incomplete',
|
|
'Automatic order creation is blocked until the link soak target is complete.'
|
|
);
|
|
}
|
|
|
|
$suggestionId = (int)($suggestion['id'] ?? 0);
|
|
$runSql = isset($suggestion['run_id']) && $suggestion['run_id'] !== null ? (string)(int)$suggestion['run_id'] : 'NULL';
|
|
$sourceSql = $db->escape_string((string)($suggestion['source'] ?? ''));
|
|
if ($suggestionId < 1 || $db->query(
|
|
"INSERT INTO xlvask_automation_action_events (suggestion_id, run_id, hall_id, action, source, policy_version, planner_identity_hash)
|
|
VALUES ({$suggestionId}, {$runSql}, '" . $db->escape_string($hallId) . "', '" . $db->escape_string($action) . "', '{$sourceSql}', '" .
|
|
$db->escape_string((string)$identity['policy_version']) . "', '" . $db->escape_string((string)$identity['identity_hash']) . "')"
|
|
) === false || $db->conn()->affected_rows !== 1) {
|
|
throw new Exception('The XL Vask automatic action budget could not be reserved atomically.');
|
|
}
|
|
}
|
|
|
|
public function policyAllowsActionReadOnly(string $action): bool
|
|
{
|
|
$readiness = $this->readinessReadOnly();
|
|
$policy = (array)($readiness['policy'] ?? []);
|
|
if (!(bool)($readiness['ready'] ?? false)) {
|
|
return false;
|
|
}
|
|
if ($action === 'attach_order') {
|
|
return !(bool)($policy['halted'] ?? true) && (bool)($policy['attach_enabled'] ?? false);
|
|
}
|
|
if ($action === 'create_order') {
|
|
return !(bool)($policy['halted'] ?? true) && (bool)($policy['attach_enabled'] ?? false)
|
|
&& (bool)($policy['create_enabled'] ?? false)
|
|
&& (int)($readiness['review_progress']['attach_order']['reviewed'] ?? 0) >= self::LINK_SOAK_TARGET;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
public function reviewAutomaticActionBySuggestion(
|
|
int $suggestionId,
|
|
string $outcome,
|
|
int $actorId,
|
|
bool $joinExistingTransaction = false
|
|
): array
|
|
{
|
|
global $db;
|
|
if ($suggestionId < 1 || $actorId < 1
|
|
|| !in_array($outcome, ['correct', 'incorrect', 'duplicate', 'cross_hall', 'unaudited'], true)) {
|
|
throw new Exception('Invalid XL Vask automatic action review.');
|
|
}
|
|
xlvask_usage_logs_schema_bootstrap::ensureTables();
|
|
$connection = $db->conn();
|
|
if (!$joinExistingTransaction) {
|
|
$connection->begin_transaction();
|
|
}
|
|
try {
|
|
$result = $db->query(
|
|
"SELECT * FROM xlvask_automation_action_events WHERE suggestion_id = {$suggestionId} FOR UPDATE"
|
|
);
|
|
if ($result === false || $result->num_rows < 1) {
|
|
if (!$joinExistingTransaction) {
|
|
$connection->commit();
|
|
}
|
|
return [
|
|
'suggestion_id' => $suggestionId,
|
|
'automatic_action_reviewed' => false,
|
|
'action_halted' => false,
|
|
'affected_action' => null,
|
|
];
|
|
}
|
|
$event = $db->fetch_assoc($result);
|
|
if (!empty($event['reviewed_at'])) {
|
|
if (self::adjudicationRetryMatches((string)($event['review_outcome'] ?? ''), $outcome)) {
|
|
if (!$joinExistingTransaction) {
|
|
$connection->commit();
|
|
}
|
|
return [
|
|
'suggestion_id' => $suggestionId,
|
|
'automatic_action_reviewed' => true,
|
|
'outcome' => $outcome,
|
|
'action' => (string)$event['action'],
|
|
'idempotent' => true,
|
|
'action_halted' => $outcome !== 'correct',
|
|
'affected_action' => $outcome !== 'correct' ? (string)$event['action'] : null,
|
|
];
|
|
}
|
|
throw new Exception('The XL Vask automatic action outcome was already reviewed differently.');
|
|
}
|
|
if ($db->query(
|
|
"UPDATE xlvask_automation_action_events SET review_outcome = '" . $db->escape_string($outcome) . "',
|
|
reviewed_by = {$actorId}, reviewed_at = NOW()
|
|
WHERE id = " . (int)$event['id'] . " AND reviewed_at IS NULL"
|
|
) === false || $db->conn()->affected_rows !== 1) {
|
|
throw new Exception('The XL Vask automatic action review could not be stored atomically.');
|
|
}
|
|
if ($outcome !== 'correct') {
|
|
$this->haltActionLatchWithinTransaction((string)$event['action'], $outcome, $actorId);
|
|
}
|
|
if (!$joinExistingTransaction) {
|
|
$connection->commit();
|
|
}
|
|
return [
|
|
'suggestion_id' => $suggestionId,
|
|
'automatic_action_reviewed' => true,
|
|
'outcome' => $outcome,
|
|
'action' => (string)$event['action'],
|
|
'action_halted' => $outcome !== 'correct',
|
|
'affected_action' => $outcome !== 'correct' ? (string)$event['action'] : null,
|
|
];
|
|
} catch (Throwable $throwable) {
|
|
if (!$joinExistingTransaction) {
|
|
$connection->rollback();
|
|
}
|
|
throw $throwable;
|
|
}
|
|
}
|
|
|
|
public static function adjudicationRetryMatches(?string $existingOutcome, string $requestedOutcome): bool
|
|
{
|
|
return $existingOutcome !== null
|
|
&& $existingOutcome !== ''
|
|
&& hash_equals($existingOutcome, $requestedOutcome);
|
|
}
|
|
|
|
public function haltActionForCriticalInvariant(string $action, string $reason): void
|
|
{
|
|
global $db;
|
|
if (!in_array($action, ['attach_order', 'create_order'], true)) {
|
|
return;
|
|
}
|
|
try {
|
|
$connection = $db->conn();
|
|
$connection->begin_transaction();
|
|
$this->haltActionLatchWithinTransaction($action, 'critical_invariant:' . mb_substr($reason, 0, 120), 0);
|
|
$connection->commit();
|
|
} catch (Throwable $throwable) {
|
|
try {
|
|
$db->conn()->rollback();
|
|
} catch (Throwable) {
|
|
}
|
|
try {
|
|
$xlvask = new xlvask();
|
|
if ($action === 'attach_order') {
|
|
$xlvask->config->automatic_order_attachment_enabled->setVariableValue(false);
|
|
}
|
|
$xlvask->config->automatic_order_creation_enabled->setVariableValue(false);
|
|
} catch (Throwable) {
|
|
}
|
|
error_log('[xlvask-automation] Failed to persist critical invariant latch.');
|
|
}
|
|
}
|
|
|
|
private function haltActionLatchWithinTransaction(string $action, string $reason, int $actorId): void
|
|
{
|
|
global $db;
|
|
$stateResult = $db->query('SELECT * FROM xlvask_automation_policy_state WHERE id = 1 FOR UPDATE');
|
|
if ($stateResult === false || $stateResult->num_rows < 1) {
|
|
throw new Exception('XL Vask automation policy is unavailable.');
|
|
}
|
|
$reasonSql = $db->escape_string(mb_substr($reason, 0, 1000));
|
|
if ($action === 'attach_order') {
|
|
$assignments = "stage = 'advisory', attach_enabled = 0, create_enabled = 0, attach_halt_reason = '{$reasonSql}'";
|
|
$stage = 'advisory';
|
|
} else {
|
|
$assignments = "stage = 'ai_attach_verified', create_enabled = 0, create_halt_reason = '{$reasonSql}'";
|
|
$stage = 'ai_attach_verified';
|
|
}
|
|
if ($db->query(
|
|
"UPDATE xlvask_automation_policy_state SET {$assignments}, expected_version = expected_version + 1 WHERE id = 1"
|
|
) === false || $db->conn()->affected_rows !== 1) {
|
|
throw new Exception('The XL Vask action latch could not be halted atomically.');
|
|
}
|
|
$segment = 'openai:' . $action;
|
|
if ($db->query(
|
|
"UPDATE xlvask_automation_calibrations SET active = 0, invalidated_at = NOW()
|
|
WHERE active = 1 AND segment_key = '" . $db->escape_string($segment) . "'"
|
|
) === false) {
|
|
throw new Exception('The stale XL Vask calibration could not be invalidated atomically.');
|
|
}
|
|
$this->syncLegacyKillSwitchesForStage($stage);
|
|
$this->recordPolicyEvent('action_latch_halted', $actorId, [
|
|
'action' => $action,
|
|
'reason' => $reason,
|
|
'invalidated_calibration_segment' => $segment,
|
|
]);
|
|
}
|
|
|
|
private function assertTransitionAllowed(string $targetStage, array $readiness): void
|
|
{
|
|
$policy = (array)($readiness['policy'] ?? []);
|
|
$currentStage = (string)($policy['effective_stage'] ?? 'off');
|
|
if (in_array($targetStage, ['off', 'advisory'], true)) {
|
|
return;
|
|
}
|
|
$allowedNext = [
|
|
'advisory' => 'ai_attach_canary',
|
|
'ai_attach_canary' => 'ai_attach_verified',
|
|
'ai_attach_verified' => 'ai_create_canary',
|
|
'ai_create_canary' => 'verified_capped',
|
|
];
|
|
$blocking = [];
|
|
if (($allowedNext[$currentStage] ?? null) !== $targetStage) {
|
|
$blocking[] = 'invalid_stage_transition:' . $currentStage . ':' . $targetStage;
|
|
}
|
|
if (($readiness['workers']['active_execute_run'] ?? null) !== null) {
|
|
$blocking[] = 'active_execute_run';
|
|
}
|
|
foreach (['automation_schema_not_ready', 'wash_id_uniqueness_not_ready', 'xlvask_module_disabled', 'openai_disabled', 'planner_identity_changed'] as $reason) {
|
|
if (in_array($reason, (array)($readiness['blocked_reasons'] ?? []), true)) {
|
|
$blocking[] = $reason;
|
|
}
|
|
}
|
|
$calibrationSegments = array_column((array)($readiness['calibrations'] ?? []), 'segment_key');
|
|
if (in_array($targetStage, ['ai_attach_canary', 'ai_attach_verified', 'ai_create_canary', 'verified_capped'], true)
|
|
&& !in_array('openai:attach_order', $calibrationSegments, true)) {
|
|
$blocking[] = 'calibration_missing:openai:attach_order';
|
|
}
|
|
if (in_array($targetStage, ['ai_create_canary', 'verified_capped'], true)
|
|
&& !in_array('openai:create_order', $calibrationSegments, true)) {
|
|
$blocking[] = 'calibration_missing:openai:create_order';
|
|
}
|
|
if ($targetStage === 'ai_attach_verified'
|
|
&& (int)($readiness['review_progress']['attach_order']['reviewed'] ?? 0) < self::LINK_SOAK_TARGET) {
|
|
$blocking[] = 'attach_soak_incomplete';
|
|
}
|
|
if ($targetStage === 'ai_create_canary'
|
|
&& (int)($readiness['review_progress']['attach_order']['reviewed'] ?? 0) < self::LINK_SOAK_TARGET) {
|
|
$blocking[] = 'attach_soak_incomplete';
|
|
}
|
|
if ($targetStage === 'verified_capped'
|
|
&& (int)($readiness['review_progress']['create_order']['reviewed'] ?? 0) < self::CREATE_SOAK_TARGET) {
|
|
$blocking[] = 'create_soak_incomplete';
|
|
}
|
|
if ($blocking !== []) {
|
|
throw new Exception('XL Vask policy transition is blocked: ' . implode(', ', array_unique($blocking)));
|
|
}
|
|
}
|
|
|
|
private function assignmentsForTransition(string $targetStage, int $actorId): array
|
|
{
|
|
global $db;
|
|
$stageSql = "stage = '" . $targetStage . "'";
|
|
$identity = xlvask_automation_service::automationIdentityForAutomation();
|
|
$identityAssignments = [
|
|
"policy_version = '" . $db->escape_string((string)$identity['policy_version']) . "'",
|
|
"planner_identity_hash = '" . $db->escape_string((string)$identity['identity_hash']) . "'",
|
|
];
|
|
return match ($targetStage) {
|
|
'off', 'advisory' => [
|
|
$stageSql, ...$identityAssignments, 'halted = 0', 'attach_enabled = 0', 'create_enabled = 0',
|
|
'halt_reason = NULL', 'halted_at = NULL', 'halted_by = NULL',
|
|
],
|
|
'ai_attach_canary' => [
|
|
$stageSql, 'halted = 0', 'attach_enabled = 1', 'create_enabled = 0',
|
|
'attach_activated_at = NOW()', "attach_activated_by = {$actorId}", 'attach_halt_reason = NULL',
|
|
],
|
|
'ai_attach_verified' => [
|
|
$stageSql, 'halted = 0', 'attach_enabled = 1', 'create_enabled = 0', 'attach_halt_reason = NULL',
|
|
],
|
|
'ai_create_canary' => [
|
|
$stageSql, 'halted = 0', 'attach_enabled = 1', 'create_enabled = 1',
|
|
'create_activated_at = NOW()', "create_activated_by = {$actorId}", 'create_halt_reason = NULL',
|
|
],
|
|
'verified_capped' => [
|
|
$stageSql, 'halted = 0', 'attach_enabled = 1', 'create_enabled = 1', 'create_halt_reason = NULL',
|
|
],
|
|
default => throw new Exception('Invalid XL Vask policy transition.'),
|
|
};
|
|
}
|
|
|
|
private function policySnapshotForTransition(string $transition, array $state, array $readiness, string $reason): array
|
|
{
|
|
return [
|
|
'requested_transition' => $transition,
|
|
'reason' => $reason,
|
|
'expected_policy_version' => (int)($state['expected_version'] ?? 0),
|
|
'planner_identity_hash' => (string)($readiness['planner_identity']['identity_hash'] ?? ''),
|
|
'wash_id_uniqueness_ready' => (bool)($readiness['wash_id_uniqueness_ready'] ?? false),
|
|
'active_execute_run_id' => $readiness['workers']['active_execute_run']['id'] ?? null,
|
|
'calibration_artifacts' => array_map(static fn(array $item): array => [
|
|
'segment_key' => (string)$item['segment_key'],
|
|
'artifact_hash' => (string)$item['artifact_hash'],
|
|
'automation_identity_hash' => (string)($item['automation_identity_hash'] ?? ''),
|
|
], (array)($readiness['calibrations'] ?? [])),
|
|
'review_progress' => $readiness['review_progress'] ?? [],
|
|
];
|
|
}
|
|
|
|
private function ensurePolicyState(): void
|
|
{
|
|
global $db;
|
|
$identity = xlvask_automation_service::automationIdentityForAutomation();
|
|
$db->query(
|
|
"INSERT IGNORE INTO xlvask_automation_policy_state
|
|
(id, policy_version, planner_identity_hash, stage, halted, attach_enabled, create_enabled)
|
|
VALUES (1, '" . $db->escape_string((string)$identity['policy_version']) . "', '" .
|
|
$db->escape_string((string)$identity['identity_hash']) . "', 'off', 0, 0, 0)"
|
|
);
|
|
}
|
|
|
|
private function policyStateReadOnly(): ?array
|
|
{
|
|
global $db;
|
|
try {
|
|
$result = $db->query('SELECT * FROM xlvask_automation_policy_state WHERE id = 1 LIMIT 1');
|
|
return $result !== false && $result->num_rows > 0 ? $db->fetch_assoc($result) : null;
|
|
} catch (Throwable) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private function activeCalibrationSnapshotReadOnly(): array
|
|
{
|
|
global $db;
|
|
$identity = xlvask_automation_service::automationIdentityForAutomation();
|
|
$verified = [];
|
|
try {
|
|
$result = $db->query(
|
|
"SELECT * FROM xlvask_automation_calibrations
|
|
WHERE active = 1 AND invalidated_at IS NULL
|
|
AND policy_version = '" . $db->escape_string((string)$identity['policy_version']) . "'
|
|
ORDER BY segment_key"
|
|
);
|
|
foreach ($result === false ? [] : $db->fetch_all($result) as $row) {
|
|
$artifact = json_decode((string)($row['backtest_json'] ?? ''), true);
|
|
if (!is_array($artifact)
|
|
|| !hash_equals((string)($row['artifact_hash'] ?? ''), hash('sha256', xlvask_automation_service::stableJsonForAutomation($artifact)))
|
|
|| !hash_equals((string)($artifact['automation_identity_hash'] ?? ''), (string)$identity['identity_hash'])
|
|
|| !hash_equals((string)($artifact['resolved_model'] ?? ''), (string)$identity['model'])
|
|
|| xlvask_automation_service::classifyCertaintyForAutomation([...$artifact, 'active' => true]) !== 'certain') {
|
|
continue;
|
|
}
|
|
$verified[(string)$row['segment_key']] = [
|
|
'id' => (int)$row['id'],
|
|
'segment_key' => (string)$row['segment_key'],
|
|
'artifact_hash' => (string)$row['artifact_hash'],
|
|
'automation_identity_hash' => (string)$artifact['automation_identity_hash'],
|
|
'resolved_model' => (string)$artifact['resolved_model'],
|
|
'precision_value' => (float)$row['precision_value'],
|
|
'wilson_lower_bound' => (float)$row['wilson_lower_bound'],
|
|
'holdout_examples' => (int)$row['holdout_examples'],
|
|
'segment_examples' => (int)$row['segment_examples'],
|
|
'contradictions' => (int)$row['contradictions'],
|
|
'activated_at' => $row['activated_at'] ?? null,
|
|
];
|
|
}
|
|
} catch (Throwable) {
|
|
return [];
|
|
}
|
|
return $verified;
|
|
}
|
|
|
|
private function activeCalibrationMatchesIdentity(string $segment, string $identityHash): bool
|
|
{
|
|
$calibrations = $this->activeCalibrationSnapshotReadOnly();
|
|
return isset($calibrations[$segment])
|
|
&& hash_equals((string)$calibrations[$segment]['automation_identity_hash'], $identityHash);
|
|
}
|
|
|
|
private function firstActiveRunReadOnly(array $hallIds = []): ?array
|
|
{
|
|
global $db;
|
|
try {
|
|
$result = $db->query(
|
|
"SELECT id, mode, status, phase, processed, total, created_by, created_at, started_at, scope_hall_ids_json
|
|
FROM xlvask_autopilot_runs
|
|
WHERE mode = 'execute' AND status IN ('queued', 'running', 'retry_wait')
|
|
ORDER BY created_at ASC, id ASC LIMIT 100"
|
|
);
|
|
if ($result === false || $result->num_rows < 1) {
|
|
return null;
|
|
}
|
|
foreach ($db->fetch_all($result) as $row) {
|
|
$scope = json_decode((string)($row['scope_hall_ids_json'] ?? '[]'), true);
|
|
$scope = is_array($scope) ? array_map('strval', $scope) : [];
|
|
if ($hallIds !== [] && array_intersect($scope, array_map('strval', $hallIds)) === []) {
|
|
continue;
|
|
}
|
|
return [
|
|
'id' => (int)$row['id'], 'mode' => (string)$row['mode'], 'status' => (string)$row['status'],
|
|
'phase' => (string)$row['phase'], 'processed' => (int)$row['processed'], 'total' => (int)$row['total'],
|
|
'created_at' => $row['created_at'] ?? null, 'started_at' => $row['started_at'] ?? null,
|
|
];
|
|
}
|
|
return null;
|
|
} catch (Throwable) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private function hasExpiredRunLeaseReadOnly(): bool
|
|
{
|
|
global $db;
|
|
try {
|
|
$result = $db->query(
|
|
"SELECT id FROM xlvask_autopilot_runs
|
|
WHERE status = 'running' AND lease_expires_at IS NOT NULL AND lease_expires_at < NOW() LIMIT 1"
|
|
);
|
|
return $result === false || $result->num_rows > 0;
|
|
} catch (Throwable) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
private function eligibleCountsReadOnly(?string $dateFrom, ?string $dateTo, array $hallIds): array
|
|
{
|
|
global $db;
|
|
$hallIds = array_values(array_unique(array_filter(array_map(
|
|
static fn(mixed $value): string => trim((string)$value),
|
|
$hallIds
|
|
), static fn(string $value): bool => $value !== '' && strlen($value) <= 191)));
|
|
if ($hallIds === []) {
|
|
return ['attach_order' => 0, 'create_order' => 0, 'total' => 0];
|
|
}
|
|
try {
|
|
$identity = xlvask_automation_service::automationIdentityForAutomation();
|
|
$state = $this->policyStateReadOnly();
|
|
$runtime = $this->runtimeConfigReadOnly();
|
|
$stage = $state === null || (bool)($state['halted'] ?? false) ? 'halted' : (string)($state['stage'] ?? 'off');
|
|
$attachEnabled = in_array($stage, ['ai_attach_canary', 'ai_attach_verified', 'ai_create_canary', 'verified_capped'], true)
|
|
&& (bool)($state['attach_enabled'] ?? false) && $runtime['attachment_config_enabled'];
|
|
$createEnabled = in_array($stage, ['ai_create_canary', 'verified_capped'], true)
|
|
&& (bool)($state['create_enabled'] ?? false) && $runtime['creation_config_enabled'];
|
|
if (!$attachEnabled && !$createEnabled) {
|
|
return ['attach_order' => 0, 'create_order' => 0, 'total' => 0];
|
|
}
|
|
$allowedActions = array_filter([
|
|
$attachEnabled ? 'attach_order' : null,
|
|
$createEnabled ? 'create_order' : null,
|
|
]);
|
|
$where = [
|
|
"u.resolution_state = 'needs_review'", "u.import_state <> 'invalid'", 'u.ignored_at IS NULL',
|
|
'u.FinishStatus = 1', 'u.source_hash IS NOT NULL', 'u.source_hash <> \'\'',
|
|
'u.source_observation_count >= 2', 'u.source_observed_at IS NOT NULL',
|
|
'u.source_stable_since <= DATE_SUB(NOW(), INTERVAL 6 HOUR)',
|
|
"s.status = 'suggested'", "s.source = 'openai'", "s.certainty = 'certain'",
|
|
"s.policy_version = '" . $db->escape_string((string)$identity['policy_version']) . "'",
|
|
"s.planner_identity_hash = '" . $db->escape_string((string)$identity['identity_hash']) . "'",
|
|
"s.model = '" . $db->escape_string((string)$identity['model']) . "'",
|
|
's.expected_version = u.expected_version',
|
|
's.input_hash = u.source_hash',
|
|
"s.action IN ('" . implode("','", array_map([$db, 'escape_string'], $allowedActions)) . "')",
|
|
'NOT EXISTS (SELECT 1 FROM xlvask_automation_suggestions newer WHERE newer.usage_log_id = s.usage_log_id AND newer.id > s.id)',
|
|
'u.HallId IN (' . implode(',', array_map(
|
|
static fn(string $hallId): string => "'" . $db->escape_string($hallId) . "'",
|
|
$hallIds
|
|
)) . ')',
|
|
];
|
|
if ($dateFrom !== null && preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateFrom)) {
|
|
$where[] = "STR_TO_DATE(REPLACE(SUBSTRING(u.StartTime, 1, 19), 'T', ' '), '%Y-%m-%d %H:%i:%s') >= '" . $db->escape_string($dateFrom) . " 00:00:00'";
|
|
}
|
|
if ($dateTo !== null && preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateTo)) {
|
|
$where[] = "STR_TO_DATE(REPLACE(SUBSTRING(u.StartTime, 1, 19), 'T', ' '), '%Y-%m-%d %H:%i:%s') <= '" . $db->escape_string($dateTo) . " 23:59:59'";
|
|
}
|
|
$row = $db->fetch_assoc($db->query(
|
|
"SELECT SUM(s.action = 'attach_order') attach_order,
|
|
SUM(s.action = 'create_order') create_order
|
|
FROM xlvask_usage_logs u
|
|
INNER JOIN xlvask_automation_suggestions s ON s.usage_log_id = u.id
|
|
WHERE " . implode(' AND ', $where)
|
|
));
|
|
$attach = (int)($row['attach_order'] ?? 0);
|
|
$create = (int)($row['create_order'] ?? 0);
|
|
return ['attach_order' => $attach, 'create_order' => $create, 'total' => $attach + $create];
|
|
} catch (Throwable) {
|
|
return ['attach_order' => 0, 'create_order' => 0, 'total' => 0];
|
|
}
|
|
}
|
|
|
|
public static function scopeDatesAreValid(?string $dateFrom, ?string $dateTo): bool
|
|
{
|
|
$validDate = static function (?string $value): bool {
|
|
if ($value === null) {
|
|
return true;
|
|
}
|
|
$parsed = \DateTimeImmutable::createFromFormat('!Y-m-d', $value);
|
|
return $parsed !== false && $parsed->format('Y-m-d') === $value;
|
|
};
|
|
return $validDate($dateFrom) && $validDate($dateTo)
|
|
&& ($dateFrom === null || $dateTo === null || $dateFrom <= $dateTo);
|
|
}
|
|
|
|
private function budgetSnapshotReadOnly(array $hallIds, ?array $state): array
|
|
{
|
|
$attach = $this->budgetCountsForAction('attach_order', null, $hallIds);
|
|
$create = $this->budgetCountsForAction('create_order', null, $hallIds);
|
|
$attachBudget = $attach + [
|
|
'global_rolling_24h_cap' => self::ATTACH_DAILY_CAP,
|
|
'per_hall_rolling_24h_cap' => self::ATTACH_PER_HALL_DAILY_CAP,
|
|
'remaining_global' => max(0, self::ATTACH_DAILY_CAP - (int)$attach['global_last_24_hours']),
|
|
'remaining_hall' => max(0, self::ATTACH_PER_HALL_DAILY_CAP - (int)$attach['max_hall_last_24_hours']),
|
|
];
|
|
$createBudget = $create + [
|
|
'global_rolling_24h_cap' => self::CREATE_DAILY_CAP,
|
|
'per_hall_rolling_24h_cap' => self::CREATE_PER_HALL_DAILY_CAP,
|
|
'remaining_global' => max(0, self::CREATE_DAILY_CAP - (int)$create['global_last_24_hours']),
|
|
'remaining_hall' => max(0, self::CREATE_PER_HALL_DAILY_CAP - (int)$create['max_hall_last_24_hours']),
|
|
];
|
|
return [
|
|
'attach_order' => $attachBudget,
|
|
'create_order' => $createBudget,
|
|
'soak' => [
|
|
'reviewed_correct_links' => $this->reviewedCorrectCount('attach_order', $state['attach_activated_at'] ?? null),
|
|
'reviewed_correct_creations' => $this->reviewedCorrectCount('create_order', $state['create_activated_at'] ?? null),
|
|
],
|
|
];
|
|
}
|
|
|
|
private function budgetCountsForAction(string $action, ?string $hallId = null, array $visibleHallIds = []): array
|
|
{
|
|
global $db;
|
|
try {
|
|
$actionSql = $db->escape_string($action);
|
|
$hallSql = $hallId === null ? null : $db->escape_string($hallId);
|
|
$visibleHallIds = array_values(array_unique(array_filter(array_map(
|
|
static fn(mixed $value): string => trim((string)$value),
|
|
$visibleHallIds
|
|
), static fn(string $value): bool => $value !== '' && strlen($value) <= 191)));
|
|
$visibleHallWhere = $visibleHallIds === [] ? '' : ' AND hall_id IN (' . implode(',', array_map(
|
|
static fn(string $value): string => "'" . $db->escape_string($value) . "'",
|
|
$visibleHallIds
|
|
)) . ')';
|
|
$row = $db->fetch_assoc($db->query(
|
|
"SELECT COUNT(*) global_last_24_hours,
|
|
SUM(" . ($hallSql === null ? '0' : "hall_id = '{$hallSql}'") . ") hall_last_24_hours
|
|
FROM xlvask_automation_action_events
|
|
WHERE action = '{$actionSql}' AND created_at >= DATE_SUB(NOW(), INTERVAL 24 HOUR)"
|
|
));
|
|
$maxHallRow = $db->fetch_assoc($db->query(
|
|
"SELECT COALESCE(MAX(hall_total), 0) max_hall_last_24_hours FROM (
|
|
SELECT hall_id, COUNT(*) hall_total FROM xlvask_automation_action_events
|
|
WHERE action = '{$actionSql}' AND created_at >= DATE_SUB(NOW(), INTERVAL 24 HOUR){$visibleHallWhere}
|
|
GROUP BY hall_id
|
|
) hall_budgets"
|
|
));
|
|
return [
|
|
'global_last_24_hours' => (int)($row['global_last_24_hours'] ?? 0),
|
|
'hall_last_24_hours' => (int)($row['hall_last_24_hours'] ?? 0),
|
|
'max_hall_last_24_hours' => (int)($maxHallRow['max_hall_last_24_hours'] ?? 0),
|
|
];
|
|
} catch (Throwable) {
|
|
return ['global_last_24_hours' => PHP_INT_MAX, 'hall_last_24_hours' => PHP_INT_MAX, 'max_hall_last_24_hours' => PHP_INT_MAX];
|
|
}
|
|
}
|
|
|
|
private function reviewedCorrectCount(string $action, ?string $since): int
|
|
{
|
|
global $db;
|
|
try {
|
|
$actionSql = $db->escape_string($action);
|
|
if ($since === null || trim($since) === '') {
|
|
return 0;
|
|
}
|
|
$sinceSql = $db->escape_string($since);
|
|
$row = $db->fetch_assoc($db->query(
|
|
"SELECT COUNT(*) total FROM xlvask_automation_action_events
|
|
WHERE action = '{$actionSql}' AND source = 'openai'
|
|
AND review_outcome = 'correct' AND reviewed_at IS NOT NULL
|
|
AND created_at >= '{$sinceSql}'"
|
|
));
|
|
return (int)($row['total'] ?? 0);
|
|
} catch (Throwable) {
|
|
return 0;
|
|
}
|
|
}
|
|
|
|
private function runtimeConfigReadOnly(): array
|
|
{
|
|
try {
|
|
$xlvask = new xlvask();
|
|
$openai = new openai();
|
|
return [
|
|
'xlvask_enabled' => $xlvask->config->enabled->isTrue(),
|
|
'openai_enabled' => $xlvask->config->openai_integration_enabled->isTrue() && $openai->config->enabled->isTrue(),
|
|
'attachment_config_enabled' => $xlvask->config->automatic_order_attachment_enabled->isTrue(),
|
|
'creation_config_enabled' => $xlvask->config->automatic_order_creation_enabled->isTrue(),
|
|
];
|
|
} catch (Throwable) {
|
|
return [
|
|
'xlvask_enabled' => false, 'openai_enabled' => false,
|
|
'attachment_config_enabled' => false, 'creation_config_enabled' => false,
|
|
];
|
|
}
|
|
}
|
|
|
|
private function syncLegacyKillSwitchesForStage(string $stage): void
|
|
{
|
|
$xlvask = new xlvask();
|
|
$attach = in_array($stage, ['ai_attach_canary', 'ai_attach_verified', 'ai_create_canary', 'verified_capped'], true);
|
|
$create = in_array($stage, ['ai_create_canary', 'verified_capped'], true);
|
|
$xlvask->config->automatic_order_attachment_enabled->setFromAutomationPolicy($attach);
|
|
$xlvask->config->automatic_order_creation_enabled->setFromAutomationPolicy($create);
|
|
}
|
|
|
|
private function formatPolicy(?array $state, array $runtime, array $budgets, array $calibrations, array $identity): array
|
|
{
|
|
$halted = $state !== null && (bool)($state['halted'] ?? false);
|
|
$attachEnabled = !$halted && (bool)($state['attach_enabled'] ?? false) && $runtime['attachment_config_enabled'];
|
|
$createEnabled = $attachEnabled && (bool)($state['create_enabled'] ?? false) && $runtime['creation_config_enabled'];
|
|
$stage = $halted ? 'halted' : (string)($state['stage'] ?? 'off');
|
|
return [
|
|
'policy_version' => (string)$identity['policy_version'],
|
|
'expected_version' => (int)($state['expected_version'] ?? 0),
|
|
'planner_identity_hash' => (string)$identity['identity_hash'],
|
|
'halted' => $halted,
|
|
'halt_reason' => $state['halt_reason'] ?? null,
|
|
'attach_halt_reason' => $state['attach_halt_reason'] ?? null,
|
|
'create_halt_reason' => $state['create_halt_reason'] ?? null,
|
|
'attach_enabled' => $attachEnabled,
|
|
'create_enabled' => $createEnabled,
|
|
'effective_stage' => $stage,
|
|
'runtime_kill_switches' => $runtime,
|
|
'calibration_segments' => array_keys($calibrations),
|
|
];
|
|
}
|
|
|
|
private function recordPolicyEvent(string $eventType, int $actorId, array $details): void
|
|
{
|
|
global $db;
|
|
$detailsJson = $db->escape_string(xlvask_automation_service::stableJsonForAutomation($details));
|
|
if ($db->query(
|
|
"INSERT INTO xlvask_automation_policy_events (event_type, actor_id, details_json)
|
|
VALUES ('" . $db->escape_string($eventType) . "', {$actorId}, '{$detailsJson}')"
|
|
) === false || $db->conn()->affected_rows !== 1) {
|
|
throw new Exception('The XL Vask automation policy event could not be recorded.');
|
|
}
|
|
}
|
|
|
|
private static function uuidV4(): string
|
|
{
|
|
$data = random_bytes(16);
|
|
$data[6] = chr((ord($data[6]) & 0x0f) | 0x40);
|
|
$data[8] = chr((ord($data[8]) & 0x3f) | 0x80);
|
|
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($data), 4));
|
|
}
|
|
}
|