679 lines
35 KiB
PHP
679 lines
35 KiB
PHP
<?php
|
|
|
|
namespace classes;
|
|
|
|
/**
|
|
* Ensures additive schema for XL Vask usage-log review state.
|
|
*/
|
|
class xlvask_usage_logs_schema_bootstrap
|
|
{
|
|
public const MIGRATION_VERSION = '20260804_xlvask_ai_auto_policy_v2';
|
|
private static bool $initialized = false;
|
|
|
|
public static function ensureTables(): void
|
|
{
|
|
if (self::$initialized) {
|
|
return;
|
|
}
|
|
$status = self::migrationStatus();
|
|
if (!$status['ready']) {
|
|
throw new \RuntimeException(
|
|
'XL Vask automation schema is not ready. Apply migration ' . self::MIGRATION_VERSION . ' explicitly.'
|
|
);
|
|
}
|
|
self::$initialized = true;
|
|
}
|
|
|
|
/**
|
|
* Explicit operator-invoked migration entrypoint. Request handlers and workers must never call this method.
|
|
*/
|
|
public static function applyExplicitMigration(): array
|
|
{
|
|
global $db;
|
|
|
|
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
|
|
throw new \RuntimeException('The database connection is unavailable.');
|
|
}
|
|
|
|
if (!self::tableExists($db, 'xlvask_usage_logs')) {
|
|
throw new \RuntimeException('The xlvask_usage_logs table is unavailable.');
|
|
}
|
|
$conflicts = self::activeExecuteRunConflicts($db);
|
|
if ($conflicts !== []) {
|
|
throw new \RuntimeException(
|
|
'XL Vask automation migration is blocked by existing active execute runs: ' . implode(', ', $conflicts)
|
|
);
|
|
}
|
|
|
|
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'ignored_at', 'DATETIME NULL AFTER WashItems');
|
|
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'ignored_by', 'INT NULL AFTER ignored_at');
|
|
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'ignored_reason', 'TEXT NULL AFTER ignored_by');
|
|
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'cached_total_net_amount', 'DECIMAL(12,2) NULL AFTER ignored_reason');
|
|
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'cached_primary_product_name', 'VARCHAR(255) NULL AFTER cached_total_net_amount');
|
|
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'cached_amount_at', 'DATETIME NULL AFTER cached_primary_product_name');
|
|
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'source_hash', 'CHAR(64) NULL AFTER cached_amount_at');
|
|
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'source_revision', 'VARCHAR(128) NULL AFTER source_hash');
|
|
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'source_observed_at', 'DATETIME NULL AFTER source_revision');
|
|
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'source_stable_since', 'DATETIME NULL AFTER source_observed_at');
|
|
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'source_observation_count', 'INT NOT NULL DEFAULT 0 AFTER source_stable_since');
|
|
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'import_state', "VARCHAR(24) NOT NULL DEFAULT 'unchanged' AFTER source_observation_count");
|
|
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'resolution_state', "VARCHAR(32) NOT NULL DEFAULT 'needs_review' AFTER import_state");
|
|
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'certainty', "VARCHAR(16) NOT NULL DEFAULT 'none' AFTER resolution_state");
|
|
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'planned_action', "VARCHAR(32) NOT NULL DEFAULT 'none' AFTER certainty");
|
|
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'state_reason', 'TEXT NULL AFTER planned_action');
|
|
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'expected_version', 'INT NOT NULL DEFAULT 1 AFTER state_reason');
|
|
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'last_run_id', 'BIGINT NULL AFTER expected_version');
|
|
self::addColumnIfMissing($db, 'xlvask_usage_logs', 'last_evaluated_at', 'DATETIME NULL AFTER last_run_id');
|
|
self::ensureAutomationTables($db);
|
|
|
|
self::$initialized = false;
|
|
$status = self::migrationStatus();
|
|
if (!$status['ready']) {
|
|
throw new \RuntimeException('XL Vask automation migration did not reach a ready state.');
|
|
}
|
|
return $status;
|
|
}
|
|
|
|
/** Read-only preflight used by readiness endpoints and normal request/worker entrypoints. */
|
|
public static function migrationStatus(): array
|
|
{
|
|
global $db;
|
|
$missingTables = [];
|
|
$missingColumns = [];
|
|
$requiredIndexes = [
|
|
'xlvask_autopilot_runs.uniq_xlvask_active_execute_run',
|
|
'xlvask_autopilot_runs.uniq_xlvask_autopilot_run_idempotency',
|
|
'xlvask_automation_action_events.uniq_xlvask_action_event_suggestion',
|
|
];
|
|
$missingIndexes = [];
|
|
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
|
|
return [
|
|
'version' => self::MIGRATION_VERSION,
|
|
'ready' => false,
|
|
'missing_tables' => ['database'],
|
|
'missing_columns' => [],
|
|
'required_indexes' => $requiredIndexes,
|
|
'missing_indexes' => $requiredIndexes,
|
|
'preflight_conflicts' => ['database_unavailable'],
|
|
];
|
|
}
|
|
|
|
foreach ([
|
|
'xlvask_usage_logs', 'xlvask_automation_suggestions', 'xlvask_automation_feedback',
|
|
'xlvask_automation_openai_cache', 'xlvask_autopilot_runs', 'xlvask_autopilot_run_items',
|
|
'xlvask_automation_audit', 'xlvask_automation_calibrations',
|
|
'xlvask_automation_calibration_label_events', 'xlvask_automation_decision_previews',
|
|
'xlvask_automation_policy_state', 'xlvask_automation_policy_previews',
|
|
'xlvask_automation_policy_events', 'xlvask_automation_action_events',
|
|
] as $table) {
|
|
if (!self::tableExists($db, $table)) {
|
|
$missingTables[] = $table;
|
|
}
|
|
}
|
|
|
|
$requiredColumns = [
|
|
'xlvask_usage_logs' => [
|
|
'ignored_at', 'ignored_by', 'ignored_reason', 'cached_total_net_amount',
|
|
'cached_primary_product_name', 'cached_amount_at', 'source_hash', 'source_revision',
|
|
'source_observed_at', 'source_stable_since', 'source_observation_count', 'import_state',
|
|
'resolution_state', 'certainty', 'planned_action', 'state_reason', 'expected_version',
|
|
'last_run_id', 'last_evaluated_at',
|
|
],
|
|
'xlvask_automation_suggestions' => [
|
|
'run_id', 'policy_version', 'planner_identity_hash', 'model', 'model_confidence',
|
|
'calibrated_probability', 'certainty', 'evidence_json', 'contradictions_json',
|
|
'risk_flags_json', 'plan_steps_json', 'expected_version', 'input_hash',
|
|
],
|
|
'xlvask_autopilot_runs' => [
|
|
'idempotency_key', 'mode', 'status', 'phase', 'date_from', 'date_to', 'force_refetch',
|
|
'requested_ids_json', 'requested_limit', 'request_hash', 'scope_hall_ids_json',
|
|
'processed', 'total', 'summary_json', 'warning', 'error', 'lease_token',
|
|
'lease_expires_at', 'attempt_count', 'max_attempts', 'next_attempt_at', 'created_by',
|
|
'created_at', 'updated_at', 'started_at', 'finished_at', 'active_execute_slot',
|
|
],
|
|
'xlvask_autopilot_run_items' => [
|
|
'run_id', 'usage_log_id', 'wash_id', 'import_state', 'resolution_state', 'certainty',
|
|
'planned_action', 'source_hash', 'expected_version', 'result_json', 'error', 'created_at', 'updated_at',
|
|
],
|
|
'xlvask_automation_calibrations' => [
|
|
'policy_version', 'segment_key', 'automation_identity_hash', 'precision_value',
|
|
'wilson_lower_bound', 'holdout_examples', 'segment_examples', 'contradictions',
|
|
'calibrated_probability', 'artifact_hash', 'active', 'backtest_json', 'created_by',
|
|
'activated_by', 'activated_at', 'invalidated_at', 'created_at',
|
|
],
|
|
'xlvask_automation_calibration_label_events' => [
|
|
'suggestion_id', 'outcome', 'adjudication_outcome', 'adjudicated_by',
|
|
'adjudicated_at', 'legacy_label_id',
|
|
],
|
|
'xlvask_automation_policy_state' => [
|
|
'policy_version', 'planner_identity_hash', 'stage', 'halted', 'attach_enabled',
|
|
'create_enabled', 'halt_reason', 'attach_halt_reason', 'create_halt_reason',
|
|
'halted_at', 'halted_by', 'attach_activated_at', 'attach_activated_by',
|
|
'create_activated_at', 'create_activated_by', 'expected_version', 'created_at', 'updated_at',
|
|
],
|
|
'xlvask_automation_policy_previews' => [
|
|
'selection_hash', 'requested_transition', 'payload_json', 'created_by',
|
|
'expires_at', 'applied_at', 'created_at',
|
|
],
|
|
'xlvask_automation_policy_events' => ['event_type', 'actor_id', 'details_json', 'created_at'],
|
|
'xlvask_automation_action_events' => [
|
|
'suggestion_id', 'run_id', 'hall_id', 'action', 'source', 'policy_version',
|
|
'planner_identity_hash', 'review_outcome', 'reviewed_by', 'reviewed_at', 'created_at',
|
|
],
|
|
];
|
|
foreach ($requiredColumns as $table => $columns) {
|
|
if (!self::tableExists($db, $table)) {
|
|
continue;
|
|
}
|
|
foreach ($columns as $column) {
|
|
if (!self::columnExists($db, $table, $column)) {
|
|
$missingColumns[] = $table . '.' . $column;
|
|
}
|
|
}
|
|
}
|
|
|
|
foreach ($requiredIndexes as $requiredIndex) {
|
|
[$table, $index] = explode('.', $requiredIndex, 2);
|
|
if (!self::indexExists($db, $table, $index)) {
|
|
$missingIndexes[] = $requiredIndex;
|
|
}
|
|
}
|
|
$conflicts = self::activeExecuteRunConflicts($db);
|
|
|
|
return [
|
|
'version' => self::MIGRATION_VERSION,
|
|
'ready' => $missingTables === [] && $missingColumns === [] && $missingIndexes === [] && $conflicts === [],
|
|
'missing_tables' => $missingTables,
|
|
'missing_columns' => $missingColumns,
|
|
'required_indexes' => $requiredIndexes,
|
|
'missing_indexes' => $missingIndexes,
|
|
'preflight_conflicts' => $conflicts,
|
|
];
|
|
}
|
|
|
|
private static function ensureAutomationTables(object $db): void
|
|
{
|
|
$db->query(
|
|
"CREATE TABLE IF NOT EXISTS `xlvask_automation_suggestions` (
|
|
`id` INT NOT NULL AUTO_INCREMENT,
|
|
`usage_log_id` INT NOT NULL,
|
|
`wash_id` VARCHAR(128) NOT NULL,
|
|
`signature_hash` CHAR(64) NOT NULL,
|
|
`signature_json` LONGTEXT NULL,
|
|
`action` VARCHAR(32) NOT NULL,
|
|
`status` VARCHAR(32) NOT NULL DEFAULT 'suggested',
|
|
`confidence` DECIMAL(5,4) NOT NULL DEFAULT 0.0000,
|
|
`source` VARCHAR(32) NOT NULL DEFAULT 'deterministic',
|
|
`matched_order_id` INT NULL,
|
|
`created_order_id` INT NULL,
|
|
`proposed_order_json` LONGTEXT NULL,
|
|
`candidate_order_json` LONGTEXT NULL,
|
|
`reason` TEXT NULL,
|
|
`created_by` INT NULL,
|
|
`decided_by` INT NULL,
|
|
`decided_at` DATETIME NULL,
|
|
`executed_at` DATETIME NULL,
|
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (`id`),
|
|
KEY `idx_xlvask_automation_usage` (`usage_log_id`),
|
|
KEY `idx_xlvask_automation_wash` (`wash_id`),
|
|
KEY `idx_xlvask_automation_signature` (`signature_hash`),
|
|
KEY `idx_xlvask_automation_status` (`status`)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
|
);
|
|
|
|
foreach ([
|
|
'run_id' => 'BIGINT NULL AFTER usage_log_id',
|
|
'policy_version' => "VARCHAR(64) NOT NULL DEFAULT 'xlvask-autopilot-v1' AFTER source",
|
|
'planner_identity_hash' => 'CHAR(64) NULL AFTER policy_version',
|
|
'model' => 'VARCHAR(96) NULL AFTER planner_identity_hash',
|
|
'model_confidence' => 'DECIMAL(5,4) NULL AFTER model',
|
|
'calibrated_probability' => 'DECIMAL(5,4) NULL AFTER model_confidence',
|
|
'certainty' => "VARCHAR(16) NOT NULL DEFAULT 'uncertain' AFTER calibrated_probability",
|
|
'evidence_json' => 'LONGTEXT NULL AFTER certainty',
|
|
'contradictions_json' => 'LONGTEXT NULL AFTER evidence_json',
|
|
'risk_flags_json' => 'LONGTEXT NULL AFTER contradictions_json',
|
|
'plan_steps_json' => 'LONGTEXT NULL AFTER risk_flags_json',
|
|
'expected_version' => 'INT NULL AFTER plan_steps_json',
|
|
'input_hash' => 'CHAR(64) NULL AFTER expected_version',
|
|
] as $column => $definition) {
|
|
self::addColumnIfMissing($db, 'xlvask_automation_suggestions', $column, $definition);
|
|
}
|
|
|
|
$db->query(
|
|
"CREATE TABLE IF NOT EXISTS `xlvask_automation_feedback` (
|
|
`id` INT NOT NULL AUTO_INCREMENT,
|
|
`usage_log_id` INT NULL,
|
|
`wash_id` VARCHAR(128) NULL,
|
|
`signature_hash` CHAR(64) NOT NULL,
|
|
`signature_json` LONGTEXT NULL,
|
|
`action` VARCHAR(32) NOT NULL,
|
|
`decision` VARCHAR(32) NOT NULL,
|
|
`order_id` INT NULL,
|
|
`reason` TEXT NULL,
|
|
`created_by` INT NULL,
|
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (`id`),
|
|
KEY `idx_xlvask_feedback_signature_action` (`signature_hash`, `action`),
|
|
KEY `idx_xlvask_feedback_usage` (`usage_log_id`),
|
|
KEY `idx_xlvask_feedback_decision` (`decision`)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
|
);
|
|
|
|
$db->query(
|
|
"CREATE TABLE IF NOT EXISTS `xlvask_automation_openai_cache` (
|
|
`id` INT NOT NULL AUTO_INCREMENT,
|
|
`cache_key` CHAR(64) NOT NULL,
|
|
`schema_name` VARCHAR(96) NOT NULL,
|
|
`input_json` LONGTEXT NOT NULL,
|
|
`result_json` LONGTEXT NOT NULL,
|
|
`hits` INT NOT NULL DEFAULT 0,
|
|
`last_hit_at` DATETIME NULL,
|
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (`id`),
|
|
UNIQUE KEY `uniq_xlvask_openai_cache_key` (`cache_key`),
|
|
KEY `idx_xlvask_openai_cache_schema` (`schema_name`)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
|
);
|
|
|
|
$db->query(
|
|
"CREATE TABLE IF NOT EXISTS `xlvask_autopilot_runs` (
|
|
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
|
`idempotency_key` CHAR(64) NOT NULL,
|
|
`mode` VARCHAR(16) NOT NULL,
|
|
`status` VARCHAR(24) NOT NULL DEFAULT 'queued',
|
|
`phase` VARCHAR(32) NOT NULL DEFAULT 'queued',
|
|
`date_from` DATE NULL,
|
|
`date_to` DATE NULL,
|
|
`force_refetch` TINYINT(1) NOT NULL DEFAULT 0,
|
|
`requested_ids_json` LONGTEXT NULL,
|
|
`requested_limit` INT NOT NULL DEFAULT 500,
|
|
`request_hash` CHAR(64) NOT NULL,
|
|
`scope_hall_ids_json` LONGTEXT NOT NULL,
|
|
`processed` INT NOT NULL DEFAULT 0,
|
|
`total` INT NOT NULL DEFAULT 0,
|
|
`summary_json` LONGTEXT NULL,
|
|
`warning` TEXT NULL,
|
|
`error` TEXT NULL,
|
|
`lease_token` CHAR(36) NULL,
|
|
`lease_expires_at` DATETIME NULL,
|
|
`attempt_count` INT NOT NULL DEFAULT 0,
|
|
`max_attempts` INT NOT NULL DEFAULT 3,
|
|
`next_attempt_at` DATETIME NULL,
|
|
`created_by` INT NULL,
|
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
`started_at` DATETIME NULL,
|
|
`finished_at` DATETIME NULL,
|
|
`active_execute_slot` TINYINT GENERATED ALWAYS AS (
|
|
CASE WHEN `mode` = 'execute' AND `status` IN ('queued', 'running', 'retry_wait') THEN 1 ELSE NULL END
|
|
) STORED,
|
|
PRIMARY KEY (`id`),
|
|
UNIQUE KEY `uniq_xlvask_autopilot_run_idempotency` (`idempotency_key`),
|
|
UNIQUE KEY `uniq_xlvask_active_execute_run` (`active_execute_slot`),
|
|
KEY `idx_xlvask_autopilot_run_status` (`status`, `created_at`)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
|
);
|
|
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'lease_token', 'CHAR(36) NULL AFTER error');
|
|
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'lease_expires_at', 'DATETIME NULL AFTER lease_token');
|
|
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'scope_hall_ids_json', "LONGTEXT NULL AFTER requested_ids_json");
|
|
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'requested_limit', 'INT NOT NULL DEFAULT 500 AFTER requested_ids_json');
|
|
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'request_hash', "CHAR(64) NOT NULL DEFAULT '' AFTER requested_limit");
|
|
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'attempt_count', 'INT NOT NULL DEFAULT 0 AFTER lease_expires_at');
|
|
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'max_attempts', 'INT NOT NULL DEFAULT 3 AFTER attempt_count');
|
|
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'next_attempt_at', 'DATETIME NULL AFTER max_attempts');
|
|
self::addColumnIfMissing(
|
|
$db,
|
|
'xlvask_autopilot_runs',
|
|
'active_execute_slot',
|
|
"TINYINT GENERATED ALWAYS AS (CASE WHEN `mode` = 'execute' AND `status` IN ('queued', 'running', 'retry_wait') THEN 1 ELSE NULL END) STORED AFTER finished_at"
|
|
);
|
|
if (!self::indexExists($db, 'xlvask_autopilot_runs', 'uniq_xlvask_active_execute_run')) {
|
|
if ($db->query('ALTER TABLE `xlvask_autopilot_runs` ADD UNIQUE KEY `uniq_xlvask_active_execute_run` (`active_execute_slot`)') === false) {
|
|
throw new \RuntimeException('The unique active XL Vask execute-run index could not be created.');
|
|
}
|
|
}
|
|
if (!self::indexExists($db, 'xlvask_autopilot_runs', 'uniq_xlvask_autopilot_run_idempotency')
|
|
&& $db->query('ALTER TABLE `xlvask_autopilot_runs` ADD UNIQUE KEY `uniq_xlvask_autopilot_run_idempotency` (`idempotency_key`)') === false) {
|
|
throw new \RuntimeException('The unique XL Vask run idempotency index could not be created.');
|
|
}
|
|
|
|
$db->query(
|
|
"CREATE TABLE IF NOT EXISTS `xlvask_autopilot_run_items` (
|
|
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
|
`run_id` BIGINT NOT NULL,
|
|
`usage_log_id` INT NULL,
|
|
`wash_id` VARCHAR(128) NULL,
|
|
`import_state` VARCHAR(24) NOT NULL DEFAULT 'unchanged',
|
|
`resolution_state` VARCHAR(32) NOT NULL DEFAULT 'needs_review',
|
|
`certainty` VARCHAR(16) NOT NULL DEFAULT 'none',
|
|
`planned_action` VARCHAR(32) NOT NULL DEFAULT 'none',
|
|
`source_hash` CHAR(64) NULL,
|
|
`expected_version` INT NULL,
|
|
`result_json` LONGTEXT NULL,
|
|
`error` TEXT NULL,
|
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (`id`),
|
|
UNIQUE KEY `uniq_xlvask_autopilot_run_usage` (`run_id`, `usage_log_id`),
|
|
KEY `idx_xlvask_autopilot_run_item_state` (`run_id`, `resolution_state`, `certainty`)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
|
);
|
|
self::addColumnIfMissing($db, 'xlvask_autopilot_runs', 'updated_at', 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP AFTER created_at');
|
|
|
|
$db->query(
|
|
"CREATE TABLE IF NOT EXISTS `xlvask_automation_audit` (
|
|
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
|
`run_id` BIGINT NULL,
|
|
`usage_log_id` INT NULL,
|
|
`wash_id` VARCHAR(128) NULL,
|
|
`event_type` VARCHAR(48) NOT NULL,
|
|
`action` VARCHAR(32) NULL,
|
|
`policy_version` VARCHAR(64) NOT NULL,
|
|
`input_hash` CHAR(64) NULL,
|
|
`source_revision` VARCHAR(128) NULL,
|
|
`expected_version` INT NULL,
|
|
`before_json` LONGTEXT NULL,
|
|
`after_json` LONGTEXT NULL,
|
|
`evidence_json` LONGTEXT NULL,
|
|
`actor_id` INT NULL,
|
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (`id`),
|
|
KEY `idx_xlvask_audit_usage` (`usage_log_id`, `created_at`),
|
|
KEY `idx_xlvask_audit_run` (`run_id`, `created_at`)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
|
);
|
|
|
|
$db->query(
|
|
"CREATE TABLE IF NOT EXISTS `xlvask_automation_calibrations` (
|
|
`id` INT NOT NULL AUTO_INCREMENT,
|
|
`policy_version` VARCHAR(64) NOT NULL,
|
|
`segment_key` VARCHAR(191) NOT NULL,
|
|
`precision_value` DECIMAL(7,6) NOT NULL,
|
|
`wilson_lower_bound` DECIMAL(7,6) NOT NULL,
|
|
`holdout_examples` INT NOT NULL,
|
|
`segment_examples` INT NOT NULL,
|
|
`contradictions` INT NOT NULL DEFAULT 0,
|
|
`calibrated_probability` DECIMAL(7,6) NOT NULL,
|
|
`artifact_hash` CHAR(64) NOT NULL,
|
|
`active` TINYINT(1) NOT NULL DEFAULT 0,
|
|
`backtest_json` LONGTEXT NULL,
|
|
`created_by` INT NULL,
|
|
`activated_by` INT NULL,
|
|
`activated_at` DATETIME NULL,
|
|
`invalidated_at` DATETIME NULL,
|
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (`id`),
|
|
UNIQUE KEY `uniq_xlvask_calibration_artifact` (`artifact_hash`),
|
|
KEY `idx_xlvask_calibration_lookup` (`policy_version`, `segment_key`, `active`)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
|
);
|
|
self::addColumnIfMissing($db, 'xlvask_automation_calibrations', 'backtest_json', 'LONGTEXT NULL AFTER active');
|
|
self::addColumnIfMissing($db, 'xlvask_automation_calibrations', 'created_by', 'INT NULL AFTER backtest_json');
|
|
self::addColumnIfMissing($db, 'xlvask_automation_calibrations', 'activated_by', 'INT NULL AFTER created_by');
|
|
self::addColumnIfMissing($db, 'xlvask_automation_calibrations', 'activated_at', 'DATETIME NULL AFTER activated_by');
|
|
self::addColumnIfMissing($db, 'xlvask_automation_calibrations', 'automation_identity_hash', 'CHAR(64) NULL AFTER segment_key');
|
|
self::addColumnIfMissing($db, 'xlvask_automation_calibrations', 'invalidated_at', 'DATETIME NULL AFTER activated_at');
|
|
|
|
$db->query(
|
|
"CREATE TABLE IF NOT EXISTS `xlvask_automation_calibration_labels` (
|
|
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
|
`suggestion_id` INT NOT NULL,
|
|
`outcome` VARCHAR(16) NOT NULL,
|
|
`adjudication_outcome` VARCHAR(32) NULL,
|
|
`adjudicated_by` INT NOT NULL,
|
|
`adjudicated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (`id`),
|
|
UNIQUE KEY `uniq_xlvask_calibration_label_suggestion` (`suggestion_id`),
|
|
KEY `idx_xlvask_calibration_label_time` (`adjudicated_at`, `id`)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
|
);
|
|
// Immutable adjudication events supersede the legacy one-row-per-suggestion table.
|
|
// The nullable legacy id supports an idempotent, non-destructive backfill.
|
|
$db->query(
|
|
"CREATE TABLE IF NOT EXISTS `xlvask_automation_calibration_label_events` (
|
|
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
|
`suggestion_id` INT NOT NULL,
|
|
`outcome` VARCHAR(16) NOT NULL,
|
|
`adjudicated_by` INT NOT NULL,
|
|
`adjudicated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
`legacy_label_id` BIGINT NULL,
|
|
PRIMARY KEY (`id`),
|
|
UNIQUE KEY `uniq_xlvask_calibration_legacy_label` (`legacy_label_id`),
|
|
KEY `idx_xlvask_calibration_event_suggestion` (`suggestion_id`, `id`),
|
|
KEY `idx_xlvask_calibration_event_time` (`adjudicated_at`, `id`)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
|
);
|
|
self::addColumnIfMissing(
|
|
$db,
|
|
'xlvask_automation_calibration_label_events',
|
|
'adjudication_outcome',
|
|
'VARCHAR(32) NULL AFTER outcome'
|
|
);
|
|
$db->query(
|
|
"INSERT IGNORE INTO xlvask_automation_calibration_label_events
|
|
(suggestion_id, outcome, adjudicated_by, adjudicated_at, legacy_label_id)
|
|
SELECT suggestion_id, outcome, adjudicated_by, adjudicated_at, id
|
|
FROM xlvask_automation_calibration_labels"
|
|
);
|
|
|
|
$db->query(
|
|
"CREATE TABLE IF NOT EXISTS `xlvask_automation_decision_previews` (
|
|
`id` CHAR(36) NOT NULL,
|
|
`selection_hash` CHAR(64) NOT NULL,
|
|
`action` VARCHAR(32) NOT NULL,
|
|
`payload_json` LONGTEXT NOT NULL,
|
|
`created_by` INT NULL,
|
|
`expires_at` DATETIME NOT NULL,
|
|
`applied_at` DATETIME NULL,
|
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (`id`),
|
|
KEY `idx_xlvask_preview_expiry` (`expires_at`, `applied_at`)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
|
);
|
|
|
|
$db->query(
|
|
"CREATE TABLE IF NOT EXISTS `xlvask_automation_policy_state` (
|
|
`id` TINYINT NOT NULL,
|
|
`policy_version` VARCHAR(64) NOT NULL,
|
|
`planner_identity_hash` CHAR(64) NOT NULL,
|
|
`stage` VARCHAR(32) NOT NULL DEFAULT 'off',
|
|
`halted` TINYINT(1) NOT NULL DEFAULT 0,
|
|
`attach_enabled` TINYINT(1) NOT NULL DEFAULT 0,
|
|
`create_enabled` TINYINT(1) NOT NULL DEFAULT 0,
|
|
`halt_reason` TEXT NULL,
|
|
`attach_halt_reason` TEXT NULL,
|
|
`create_halt_reason` TEXT NULL,
|
|
`halted_at` DATETIME NULL,
|
|
`halted_by` INT NULL,
|
|
`attach_activated_at` DATETIME NULL,
|
|
`attach_activated_by` INT NULL,
|
|
`create_activated_at` DATETIME NULL,
|
|
`create_activated_by` INT NULL,
|
|
`expected_version` INT NOT NULL DEFAULT 1,
|
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (`id`)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
|
);
|
|
self::addColumnIfMissing($db, 'xlvask_automation_policy_state', 'stage', "VARCHAR(32) NOT NULL DEFAULT 'off' AFTER planner_identity_hash");
|
|
self::addColumnIfMissing($db, 'xlvask_automation_policy_state', 'attach_halt_reason', 'TEXT NULL AFTER halt_reason');
|
|
self::addColumnIfMissing($db, 'xlvask_automation_policy_state', 'create_halt_reason', 'TEXT NULL AFTER attach_halt_reason');
|
|
|
|
$db->query(
|
|
"CREATE TABLE IF NOT EXISTS `xlvask_automation_policy_previews` (
|
|
`id` CHAR(36) NOT NULL,
|
|
`selection_hash` CHAR(64) NOT NULL,
|
|
`requested_transition` VARCHAR(32) NOT NULL,
|
|
`payload_json` LONGTEXT NOT NULL,
|
|
`created_by` INT NOT NULL,
|
|
`expires_at` DATETIME NOT NULL,
|
|
`applied_at` DATETIME NULL,
|
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (`id`),
|
|
KEY `idx_xlvask_policy_preview_expiry` (`expires_at`, `applied_at`)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
|
);
|
|
|
|
$db->query(
|
|
"CREATE TABLE IF NOT EXISTS `xlvask_automation_policy_events` (
|
|
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
|
`event_type` VARCHAR(48) NOT NULL,
|
|
`actor_id` INT NOT NULL,
|
|
`details_json` LONGTEXT NULL,
|
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (`id`),
|
|
KEY `idx_xlvask_policy_event_time` (`created_at`, `id`)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
|
);
|
|
|
|
$db->query(
|
|
"CREATE TABLE IF NOT EXISTS `xlvask_automation_action_events` (
|
|
`id` BIGINT NOT NULL AUTO_INCREMENT,
|
|
`suggestion_id` INT NOT NULL,
|
|
`run_id` BIGINT NULL,
|
|
`hall_id` VARCHAR(191) NOT NULL,
|
|
`action` VARCHAR(32) NOT NULL,
|
|
`source` VARCHAR(32) NOT NULL,
|
|
`policy_version` VARCHAR(64) NOT NULL,
|
|
`planner_identity_hash` CHAR(64) NOT NULL,
|
|
`review_outcome` VARCHAR(32) NULL,
|
|
`reviewed_by` INT NULL,
|
|
`reviewed_at` DATETIME NULL,
|
|
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
PRIMARY KEY (`id`),
|
|
UNIQUE KEY `uniq_xlvask_action_event_suggestion` (`suggestion_id`),
|
|
KEY `idx_xlvask_action_budget` (`action`, `created_at`),
|
|
KEY `idx_xlvask_action_hall_budget` (`hall_id`, `action`, `created_at`),
|
|
KEY `idx_xlvask_action_soak` (`source`, `action`, `created_at`)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"
|
|
);
|
|
self::addColumnIfMissing($db, 'xlvask_automation_action_events', 'hall_id', "VARCHAR(191) NOT NULL DEFAULT '' AFTER run_id");
|
|
self::addColumnIfMissing($db, 'xlvask_automation_action_events', 'review_outcome', 'VARCHAR(32) NULL AFTER planner_identity_hash');
|
|
self::addColumnIfMissing($db, 'xlvask_automation_action_events', 'reviewed_by', 'INT NULL AFTER review_outcome');
|
|
self::addColumnIfMissing($db, 'xlvask_automation_action_events', 'reviewed_at', 'DATETIME NULL AFTER reviewed_by');
|
|
if (!self::indexExists($db, 'xlvask_automation_action_events', 'uniq_xlvask_action_event_suggestion')
|
|
&& $db->query('ALTER TABLE `xlvask_automation_action_events` ADD UNIQUE KEY `uniq_xlvask_action_event_suggestion` (`suggestion_id`)') === false) {
|
|
throw new \RuntimeException('The unique XL Vask action-event suggestion index could not be created.');
|
|
}
|
|
}
|
|
|
|
public static function washIdUniquenessReady(): bool
|
|
{
|
|
global $db;
|
|
if (!isset($db) || !is_object($db) || !method_exists($db, 'query')) {
|
|
return false;
|
|
}
|
|
|
|
return self::indexExists($db, 'orders', 'uniq_orders_xlvask_wash_id');
|
|
}
|
|
|
|
/**
|
|
* Explicit activation migration. Never call this from constructors, GETs, or normal runs.
|
|
* Returns false without modifying conflicting records when duplicate wash IDs exist.
|
|
*/
|
|
public static function applyWashIdUniquenessMigration(): bool
|
|
{
|
|
global $db;
|
|
if (!self::tableExists($db, 'orders') || !self::columnExists($db, 'orders', 'wash_id')) {
|
|
return false;
|
|
}
|
|
|
|
if (self::indexExists($db, 'orders', 'uniq_orders_xlvask_wash_id')) {
|
|
return true;
|
|
}
|
|
|
|
$duplicates = $db->query(
|
|
"SELECT LOWER(TRIM(`wash_id`)) normalized_wash_id FROM `orders`
|
|
WHERE `wash_id` IS NOT NULL AND TRIM(`wash_id`) <> ''
|
|
GROUP BY LOWER(TRIM(`wash_id`)) HAVING COUNT(*) > 1 LIMIT 1"
|
|
);
|
|
if ($duplicates !== false && is_object($duplicates) && (int)$duplicates->num_rows === 0) {
|
|
if (!self::columnExists($db, 'orders', 'xlvask_normalized_wash_id')) {
|
|
$db->query(
|
|
"ALTER TABLE `orders` ADD COLUMN `xlvask_normalized_wash_id` VARCHAR(128)
|
|
GENERATED ALWAYS AS (NULLIF(LOWER(TRIM(`wash_id`)), '')) STORED"
|
|
);
|
|
}
|
|
$db->query(
|
|
"ALTER TABLE `orders` ADD UNIQUE KEY `uniq_orders_xlvask_wash_id` (`xlvask_normalized_wash_id`)"
|
|
);
|
|
return self::indexExists($db, 'orders', 'uniq_orders_xlvask_wash_id');
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private static function addColumnIfMissing(object $db, string $table, string $column, string $definition): void
|
|
{
|
|
if (!self::columnExists($db, $table, $column)) {
|
|
if ($db->query("ALTER TABLE `{$table}` ADD COLUMN `{$column}` {$definition}") === false) {
|
|
throw new \RuntimeException("The required XL Vask column {$table}.{$column} could not be created.");
|
|
}
|
|
}
|
|
}
|
|
|
|
private static function activeExecuteRunConflicts(object $db): array
|
|
{
|
|
if (!self::tableExists($db, 'xlvask_autopilot_runs')
|
|
|| !self::columnExists($db, 'xlvask_autopilot_runs', 'mode')
|
|
|| !self::columnExists($db, 'xlvask_autopilot_runs', 'status')) {
|
|
return [];
|
|
}
|
|
$result = $db->query(
|
|
"SELECT COUNT(*) total FROM xlvask_autopilot_runs
|
|
WHERE mode = 'execute' AND status IN ('queued', 'running', 'retry_wait')"
|
|
);
|
|
if ($result === false || !is_object($result)) {
|
|
return ['active_execute_preflight_unavailable'];
|
|
}
|
|
$row = $db->fetch_assoc($result);
|
|
$count = (int)($row['total'] ?? 0);
|
|
return $count > 1 ? ['multiple_active_execute_runs:' . $count] : [];
|
|
}
|
|
|
|
private static function tableExists(object $db, string $table): bool
|
|
{
|
|
$table = self::escapeIdentifier($table);
|
|
$result = $db->query("SHOW TABLES LIKE '{$table}'");
|
|
|
|
if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) {
|
|
return false;
|
|
}
|
|
|
|
return (int)$result->num_rows > 0;
|
|
}
|
|
|
|
private static function columnExists(object $db, string $table, string $column): bool
|
|
{
|
|
$table = self::escapeIdentifier($table);
|
|
$column = self::escapeIdentifier($column);
|
|
$result = $db->query("SHOW COLUMNS FROM `{$table}` LIKE '{$column}'");
|
|
|
|
if ($result === false || !is_object($result) || !property_exists($result, 'num_rows')) {
|
|
return false;
|
|
}
|
|
|
|
return (int)$result->num_rows > 0;
|
|
}
|
|
|
|
private static function indexExists(object $db, string $table, string $index): bool
|
|
{
|
|
if (!self::tableExists($db, $table)) {
|
|
return false;
|
|
}
|
|
|
|
$table = self::escapeIdentifier($table);
|
|
$index = self::escapeIdentifier($index);
|
|
$result = $db->query("SHOW INDEX FROM `{$table}` WHERE Key_name = '{$index}'");
|
|
return $result !== false && is_object($result) && (int)$result->num_rows > 0;
|
|
}
|
|
|
|
private static function escapeIdentifier(string $value): string
|
|
{
|
|
return str_replace(['\\', "'", '`'], ['\\\\', "\\'", ''], $value);
|
|
}
|
|
}
|