Implement quote lifecycle investigator
All checks were successful
deploy / deploy (push) Successful in 57s
All checks were successful
deploy / deploy (push) Successful in 57s
Proof: Adds durable quote lifecycle lookup by quote_id, decision_id, or command_id through the authenticated dashboard API, pins selected Quote lifecycle evidence outside the rolling table, and covers the Awaiting executor follow-up regression with targeted tests. Assumptions: Existing swap demand, trade decision, execute command, executor result, and quote outcome history rows are sufficient to reconstruct the selected quote lifecycle; this turn stays read-side and does not alter strategy, pricing, limits, arming, signer identity, pair enablement, relay, or executor behavior. Still fake: Venue-native final fill truth, fee-aware realized PnL, already-pruned historical detail, and the broader quote analytics workbench remain incomplete.
This commit is contained in:
parent
0902cb2c93
commit
cb87910c95
9 changed files with 1332 additions and 14 deletions
|
|
@ -14,6 +14,7 @@ import {
|
||||||
buildDashboardBootstrap,
|
buildDashboardBootstrap,
|
||||||
buildDashboardControlErrorResponse,
|
buildDashboardControlErrorResponse,
|
||||||
buildLiveQuoteLifecycleRows,
|
buildLiveQuoteLifecycleRows,
|
||||||
|
buildQuoteLifecycleLookupResponse,
|
||||||
buildLiveStatusBar,
|
buildLiveStatusBar,
|
||||||
createDashboardLiveState,
|
createDashboardLiveState,
|
||||||
isDashboardWebSocketBackpressured,
|
isDashboardWebSocketBackpressured,
|
||||||
|
|
@ -44,6 +45,7 @@ import {
|
||||||
loadLatestMarketPrice,
|
loadLatestMarketPrice,
|
||||||
loadLatestPortfolioMetric,
|
loadLatestPortfolioMetric,
|
||||||
loadPairConfigSummary,
|
loadPairConfigSummary,
|
||||||
|
loadQuoteLifecycleLookupEvidence,
|
||||||
loadQuoteLifecycleRetentionSummary,
|
loadQuoteLifecycleRetentionSummary,
|
||||||
loadRecentAlertTransitions,
|
loadRecentAlertTransitions,
|
||||||
loadRecentDepositStatuses,
|
loadRecentDepositStatuses,
|
||||||
|
|
@ -378,6 +380,38 @@ async function handleApiRequest({ req, res, url, auth }) {
|
||||||
return sendJson(res, 200, submissionPage);
|
return sendJson(res, 200, submissionPage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const lifecycleLookupMatch = req.method === 'GET'
|
||||||
|
? url.pathname.match(/^\/api\/strategy\/quote-lifecycle\/(.+)$/)
|
||||||
|
: null;
|
||||||
|
if (lifecycleLookupMatch) {
|
||||||
|
const identifier = decodeURIComponent(lifecycleLookupMatch[1] || '').trim();
|
||||||
|
if (!identifier) {
|
||||||
|
return sendJson(res, 400, {
|
||||||
|
ok: false,
|
||||||
|
error: 'quote_lifecycle_identifier_required',
|
||||||
|
lifecycle_row: null,
|
||||||
|
lookup: {
|
||||||
|
requested_identifier: '',
|
||||||
|
matched_identifier_type: null,
|
||||||
|
found: false,
|
||||||
|
lookup_at: new Date().toISOString(),
|
||||||
|
latest_stage_at: null,
|
||||||
|
matched_identifiers: {
|
||||||
|
quote_ids: [],
|
||||||
|
decision_ids: [],
|
||||||
|
command_ids: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = await loadQuoteLifecycleLookupPayload({ identifier });
|
||||||
|
return sendJson(res, payload.ok ? 200 : 404, {
|
||||||
|
...payload,
|
||||||
|
...(payload.ok ? {} : { error: 'quote_lifecycle_not_found' }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const controlMatch = req.method === 'POST'
|
const controlMatch = req.method === 'POST'
|
||||||
? url.pathname.match(/^\/api\/control\/([^/]+)\/([^/]+)$/)
|
? url.pathname.match(/^\/api\/control\/([^/]+)\/([^/]+)$/)
|
||||||
: null;
|
: null;
|
||||||
|
|
@ -597,6 +631,19 @@ async function loadBootstrapPayload({ auth, page, pageSize }) {
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadQuoteLifecycleLookupPayload({ identifier }) {
|
||||||
|
const tradingConfig = await tradingConfigStore.forceRefresh();
|
||||||
|
const runtimeConfig = buildRuntimeConfig(tradingConfig);
|
||||||
|
const evidence = await loadQuoteLifecycleLookupEvidence(pool, {
|
||||||
|
identifier,
|
||||||
|
});
|
||||||
|
return buildQuoteLifecycleLookupResponse({
|
||||||
|
config: runtimeConfig,
|
||||||
|
identifier,
|
||||||
|
evidence,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function loadServiceSnapshots() {
|
async function loadServiceSnapshots() {
|
||||||
const services = listDashboardServices(config);
|
const services = listDashboardServices(config);
|
||||||
return Promise.all(services.map((service) => loadServiceSnapshot(service)));
|
return Promise.all(services.map((service) => loadServiceSnapshot(service)));
|
||||||
|
|
|
||||||
|
|
@ -1130,6 +1130,7 @@ const HUMAN_REASON_TEXT = {
|
||||||
pending_deposit_not_credited: 'Funding is not credited yet.',
|
pending_deposit_not_credited: 'Funding is not credited yet.',
|
||||||
pending_outbound_reserved: 'Pending outbound inventory is reserved.',
|
pending_outbound_reserved: 'Pending outbound inventory is reserved.',
|
||||||
quote_expired: 'Quote expired.',
|
quote_expired: 'Quote expired.',
|
||||||
|
quote_not_found_or_finished: 'Relay reported the quote as not found or already finished.',
|
||||||
quote_response_ack: 'Quote response acknowledged by the relay.',
|
quote_response_ack: 'Quote response acknowledged by the relay.',
|
||||||
quote_response_ok: 'Quote response accepted by the relay.',
|
quote_response_ok: 'Quote response accepted by the relay.',
|
||||||
awaiting_outcome: 'No durable venue outcome is recorded yet.',
|
awaiting_outcome: 'No durable venue outcome is recorded yet.',
|
||||||
|
|
@ -1425,7 +1426,11 @@ function finalizeLifecycleRow(row) {
|
||||||
} else if (execution?.status === 'failed') {
|
} else if (execution?.status === 'failed') {
|
||||||
lifecycle_state = 'failed';
|
lifecycle_state = 'failed';
|
||||||
lifecycle_label = 'Submission failed';
|
lifecycle_label = 'Submission failed';
|
||||||
reason_code = normalizeLifecycleToken(execution?.result_code || 'submission_failed');
|
reason_code = normalizeLifecycleToken(
|
||||||
|
execution?.failure_category
|
||||||
|
|| execution?.result_code
|
||||||
|
|| 'submission_failed',
|
||||||
|
);
|
||||||
reason_text = buildExecutionFailureText(execution, reason_code);
|
reason_text = buildExecutionFailureText(execution, reason_code);
|
||||||
} else if (execution?.status === 'rejected') {
|
} else if (execution?.status === 'rejected') {
|
||||||
lifecycle_state = 'blocked';
|
lifecycle_state = 'blocked';
|
||||||
|
|
@ -1504,6 +1509,100 @@ function finalizeLifecycleRow(row) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildQuoteLifecycleLookupResponse({
|
||||||
|
config,
|
||||||
|
identifier,
|
||||||
|
evidence = {},
|
||||||
|
now = new Date().toISOString(),
|
||||||
|
} = {}) {
|
||||||
|
const requestedIdentifier = String(identifier || '').trim();
|
||||||
|
const rows = deriveQuoteLifecycleRows({
|
||||||
|
recentQuotes: evidence.recentQuotes || [],
|
||||||
|
recentTradeDecisions: evidence.recentTradeDecisions || [],
|
||||||
|
recentExecuteTradeCommands: evidence.recentExecuteTradeCommands || [],
|
||||||
|
recentExecutionResults: evidence.recentExecutionResults || [],
|
||||||
|
recentQuoteOutcomes: evidence.recentQuoteOutcomes || [],
|
||||||
|
limit: null,
|
||||||
|
}).map((row) => enrichLifecycleRowForUi({ config, row }));
|
||||||
|
const lifecycleRow = selectLookupLifecycleRow(rows, requestedIdentifier);
|
||||||
|
const found = Boolean(lifecycleRow);
|
||||||
|
const lookup = {
|
||||||
|
requested_identifier: requestedIdentifier,
|
||||||
|
matched_identifier_type: found
|
||||||
|
? inferLookupIdentifierType(requestedIdentifier, lifecycleRow, evidence)
|
||||||
|
: null,
|
||||||
|
found,
|
||||||
|
lookup_at: evidence.lookup_at || now,
|
||||||
|
latest_stage_at: lifecycleRow?.latest_stage_at || null,
|
||||||
|
matched_identifiers: found
|
||||||
|
? collectLifecycleIdentifiers(lifecycleRow, evidence.matched_identifiers)
|
||||||
|
: normalizeMatchedIdentifiers(evidence.matched_identifiers),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: found,
|
||||||
|
lifecycle_row: lifecycleRow || null,
|
||||||
|
lookup,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectLookupLifecycleRow(rows, identifier) {
|
||||||
|
if (!rows.length) return null;
|
||||||
|
const normalizedIdentifier = String(identifier || '').trim();
|
||||||
|
if (!normalizedIdentifier) return rows[0] || null;
|
||||||
|
return rows.find((row) => lifecycleRowHasIdentifier(row, normalizedIdentifier))
|
||||||
|
|| rows[0]
|
||||||
|
|| null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lifecycleRowHasIdentifier(row, identifier) {
|
||||||
|
return row?.quote_id === identifier
|
||||||
|
|| row?.decision_id === identifier
|
||||||
|
|| row?.command_id === identifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferLookupIdentifierType(identifier, row, evidence = {}) {
|
||||||
|
if (evidence.matched_identifier_type) return evidence.matched_identifier_type;
|
||||||
|
if (row?.quote_id === identifier) return 'quote_id';
|
||||||
|
if (row?.decision_id === identifier) return 'decision_id';
|
||||||
|
if (row?.command_id === identifier) return 'command_id';
|
||||||
|
return 'linked_identifier';
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectLifecycleIdentifiers(row, matchedIdentifiers = null) {
|
||||||
|
const normalized = normalizeMatchedIdentifiers(matchedIdentifiers);
|
||||||
|
return {
|
||||||
|
quote_ids: uniqueStrings([
|
||||||
|
...normalized.quote_ids,
|
||||||
|
row?.quote_id,
|
||||||
|
]),
|
||||||
|
decision_ids: uniqueStrings([
|
||||||
|
...normalized.decision_ids,
|
||||||
|
row?.decision_id,
|
||||||
|
]),
|
||||||
|
command_ids: uniqueStrings([
|
||||||
|
...normalized.command_ids,
|
||||||
|
row?.command_id,
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMatchedIdentifiers(value = null) {
|
||||||
|
return {
|
||||||
|
quote_ids: uniqueStrings(value?.quote_ids),
|
||||||
|
decision_ids: uniqueStrings(value?.decision_ids),
|
||||||
|
command_ids: uniqueStrings(value?.command_ids),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueStrings(values = []) {
|
||||||
|
return [...new Set(
|
||||||
|
(values || [])
|
||||||
|
.map((value) => String(value || '').trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
)];
|
||||||
|
}
|
||||||
|
|
||||||
function buildPolicySkipText(decision, reasonCode) {
|
function buildPolicySkipText(decision, reasonCode) {
|
||||||
const base = humanizeReasonCode(reasonCode, 'Policy skipped the response.');
|
const base = humanizeReasonCode(reasonCode, 'Policy skipped the response.');
|
||||||
const policy = decision?.response_policy || {};
|
const policy = decision?.response_policy || {};
|
||||||
|
|
|
||||||
|
|
@ -3868,6 +3868,354 @@ export async function loadRecentQuoteOutcomes(pool, { limit = 200 } = {}) {
|
||||||
return result.rows.map(normalizeQuoteOutcomeRow);
|
return result.rows.map(normalizeQuoteOutcomeRow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function loadQuoteLifecycleLookupEvidence(pool, { identifier } = {}) {
|
||||||
|
const requestedIdentifier = String(identifier || '').trim();
|
||||||
|
const lookupAt = new Date().toISOString();
|
||||||
|
if (!requestedIdentifier) {
|
||||||
|
return buildQuoteLifecycleLookupEvidence({
|
||||||
|
requestedIdentifier,
|
||||||
|
lookupAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidatesResult = await pool.query(
|
||||||
|
`
|
||||||
|
WITH candidates AS (
|
||||||
|
SELECT
|
||||||
|
'quote_id' AS matched_identifier_type,
|
||||||
|
COALESCE(quote_id, payload->>'quote_id') AS quote_id,
|
||||||
|
payload->>'decision_id' AS decision_id,
|
||||||
|
payload->>'command_id' AS command_id,
|
||||||
|
COALESCE(observed_at, ingested_at) AS matched_at
|
||||||
|
FROM swap_demand_events
|
||||||
|
WHERE quote_id = $1
|
||||||
|
OR payload->>'quote_id' = $1
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
CASE
|
||||||
|
WHEN quote_id = $1 OR payload->>'quote_id' = $1 THEN 'quote_id'
|
||||||
|
WHEN decision_key = $1 OR payload->>'decision_id' = $1 THEN 'decision_id'
|
||||||
|
WHEN payload->>'command_id' = $1 THEN 'command_id'
|
||||||
|
ELSE 'linked_identifier'
|
||||||
|
END AS matched_identifier_type,
|
||||||
|
COALESCE(quote_id, payload->>'quote_id') AS quote_id,
|
||||||
|
COALESCE(payload->>'decision_id', decision_key) AS decision_id,
|
||||||
|
payload->>'command_id' AS command_id,
|
||||||
|
COALESCE(observed_at, ingested_at) AS matched_at
|
||||||
|
FROM trade_decisions
|
||||||
|
WHERE quote_id = $1
|
||||||
|
OR decision_key = $1
|
||||||
|
OR payload->>'quote_id' = $1
|
||||||
|
OR payload->>'decision_id' = $1
|
||||||
|
OR payload->>'command_id' = $1
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
CASE
|
||||||
|
WHEN quote_id = $1 OR payload->>'quote_id' = $1 THEN 'quote_id'
|
||||||
|
WHEN payload->>'decision_id' = $1 THEN 'decision_id'
|
||||||
|
WHEN decision_key = $1 OR payload->>'command_id' = $1 THEN 'command_id'
|
||||||
|
ELSE 'linked_identifier'
|
||||||
|
END AS matched_identifier_type,
|
||||||
|
COALESCE(quote_id, payload->>'quote_id') AS quote_id,
|
||||||
|
payload->>'decision_id' AS decision_id,
|
||||||
|
COALESCE(payload->>'command_id', decision_key) AS command_id,
|
||||||
|
COALESCE(observed_at, ingested_at) AS matched_at
|
||||||
|
FROM execute_trade_commands
|
||||||
|
WHERE quote_id = $1
|
||||||
|
OR decision_key = $1
|
||||||
|
OR payload->>'quote_id' = $1
|
||||||
|
OR payload->>'decision_id' = $1
|
||||||
|
OR payload->>'command_id' = $1
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
CASE
|
||||||
|
WHEN quote_id = $1 OR payload->>'quote_id' = $1 THEN 'quote_id'
|
||||||
|
WHEN payload->>'decision_id' = $1 THEN 'decision_id'
|
||||||
|
WHEN decision_key = $1 OR payload->>'command_id' = $1 THEN 'command_id'
|
||||||
|
ELSE 'linked_identifier'
|
||||||
|
END AS matched_identifier_type,
|
||||||
|
COALESCE(quote_id, payload->>'quote_id') AS quote_id,
|
||||||
|
payload->>'decision_id' AS decision_id,
|
||||||
|
COALESCE(payload->>'command_id', decision_key) AS command_id,
|
||||||
|
COALESCE(observed_at, ingested_at) AS matched_at
|
||||||
|
FROM trade_execution_results
|
||||||
|
WHERE quote_id = $1
|
||||||
|
OR decision_key = $1
|
||||||
|
OR payload->>'quote_id' = $1
|
||||||
|
OR payload->>'decision_id' = $1
|
||||||
|
OR payload->>'command_id' = $1
|
||||||
|
|
||||||
|
UNION ALL
|
||||||
|
|
||||||
|
SELECT
|
||||||
|
CASE
|
||||||
|
WHEN quote_id = $1 OR payload->>'quote_id' = $1 THEN 'quote_id'
|
||||||
|
WHEN decision_id = $1 OR payload->>'decision_id' = $1 THEN 'decision_id'
|
||||||
|
WHEN command_id = $1 OR payload->>'command_id' = $1 THEN 'command_id'
|
||||||
|
ELSE 'linked_identifier'
|
||||||
|
END AS matched_identifier_type,
|
||||||
|
COALESCE(quote_id, payload->>'quote_id') AS quote_id,
|
||||||
|
COALESCE(decision_id, payload->>'decision_id') AS decision_id,
|
||||||
|
COALESCE(command_id, payload->>'command_id') AS command_id,
|
||||||
|
COALESCE(outcome_observed_at, submitted_at, command_at, computed_at) AS matched_at
|
||||||
|
FROM ${QUOTE_OUTCOMES_TABLE}
|
||||||
|
WHERE quote_id = $1
|
||||||
|
OR decision_id = $1
|
||||||
|
OR command_id = $1
|
||||||
|
OR payload->>'quote_id' = $1
|
||||||
|
OR payload->>'decision_id' = $1
|
||||||
|
OR payload->>'command_id' = $1
|
||||||
|
)
|
||||||
|
SELECT *
|
||||||
|
FROM candidates
|
||||||
|
ORDER BY matched_at DESC NULLS LAST
|
||||||
|
LIMIT 100
|
||||||
|
`,
|
||||||
|
[requestedIdentifier],
|
||||||
|
);
|
||||||
|
|
||||||
|
const identifiers = collectQuoteLifecycleLookupIdentifiers(candidatesResult.rows);
|
||||||
|
if (!hasLookupIdentifiers(identifiers)) {
|
||||||
|
return buildQuoteLifecycleLookupEvidence({
|
||||||
|
requestedIdentifier,
|
||||||
|
lookupAt,
|
||||||
|
matchedIdentifierType: null,
|
||||||
|
matchedIdentifiers: identifiers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const [
|
||||||
|
recentQuotes,
|
||||||
|
recentTradeDecisions,
|
||||||
|
recentExecuteTradeCommands,
|
||||||
|
recentExecutionResults,
|
||||||
|
recentQuoteOutcomes,
|
||||||
|
] = await Promise.all([
|
||||||
|
loadLifecycleLookupQuotes(pool, identifiers),
|
||||||
|
loadLifecycleLookupTradeDecisions(pool, identifiers),
|
||||||
|
loadLifecycleLookupExecuteTradeCommands(pool, identifiers),
|
||||||
|
loadLifecycleLookupExecutionResults(pool, identifiers),
|
||||||
|
loadLifecycleLookupQuoteOutcomes(pool, identifiers),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return buildQuoteLifecycleLookupEvidence({
|
||||||
|
requestedIdentifier,
|
||||||
|
lookupAt,
|
||||||
|
matchedIdentifierType: inferLookupMatchedIdentifierType(
|
||||||
|
requestedIdentifier,
|
||||||
|
candidatesResult.rows,
|
||||||
|
),
|
||||||
|
matchedIdentifiers: identifiers,
|
||||||
|
recentQuotes,
|
||||||
|
recentTradeDecisions,
|
||||||
|
recentExecuteTradeCommands,
|
||||||
|
recentExecutionResults,
|
||||||
|
recentQuoteOutcomes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildQuoteLifecycleLookupEvidence({
|
||||||
|
requestedIdentifier,
|
||||||
|
lookupAt,
|
||||||
|
matchedIdentifierType = null,
|
||||||
|
matchedIdentifiers = null,
|
||||||
|
recentQuotes = [],
|
||||||
|
recentTradeDecisions = [],
|
||||||
|
recentExecuteTradeCommands = [],
|
||||||
|
recentExecutionResults = [],
|
||||||
|
recentQuoteOutcomes = [],
|
||||||
|
} = {}) {
|
||||||
|
return {
|
||||||
|
found: Boolean(
|
||||||
|
recentQuotes.length
|
||||||
|
|| recentTradeDecisions.length
|
||||||
|
|| recentExecuteTradeCommands.length
|
||||||
|
|| recentExecutionResults.length
|
||||||
|
|| recentQuoteOutcomes.length,
|
||||||
|
),
|
||||||
|
requested_identifier: requestedIdentifier,
|
||||||
|
matched_identifier_type: matchedIdentifierType,
|
||||||
|
matched_identifiers: normalizeQuoteLifecycleLookupIdentifiers(matchedIdentifiers),
|
||||||
|
lookup_at: lookupAt,
|
||||||
|
recentQuotes,
|
||||||
|
recentTradeDecisions,
|
||||||
|
recentExecuteTradeCommands,
|
||||||
|
recentExecutionResults,
|
||||||
|
recentQuoteOutcomes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectQuoteLifecycleLookupIdentifiers(rows = []) {
|
||||||
|
return normalizeQuoteLifecycleLookupIdentifiers({
|
||||||
|
quote_ids: rows.map((row) => row.quote_id),
|
||||||
|
decision_ids: rows.map((row) => row.decision_id),
|
||||||
|
command_ids: rows.map((row) => row.command_id),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeQuoteLifecycleLookupIdentifiers(value = null) {
|
||||||
|
return {
|
||||||
|
quote_ids: uniqueTextValues(value?.quote_ids),
|
||||||
|
decision_ids: uniqueTextValues(value?.decision_ids),
|
||||||
|
command_ids: uniqueTextValues(value?.command_ids),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueTextValues(values = []) {
|
||||||
|
return [...new Set(
|
||||||
|
(values || [])
|
||||||
|
.map((value) => String(value || '').trim())
|
||||||
|
.filter(Boolean),
|
||||||
|
)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasLookupIdentifiers(identifiers) {
|
||||||
|
return Boolean(
|
||||||
|
identifiers?.quote_ids?.length
|
||||||
|
|| identifiers?.decision_ids?.length
|
||||||
|
|| identifiers?.command_ids?.length,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function inferLookupMatchedIdentifierType(identifier, rows = []) {
|
||||||
|
const requestedIdentifier = String(identifier || '').trim();
|
||||||
|
const direct = rows.find((row) => (
|
||||||
|
row.matched_identifier_type
|
||||||
|
&& (
|
||||||
|
row.quote_id === requestedIdentifier
|
||||||
|
|| row.decision_id === requestedIdentifier
|
||||||
|
|| row.command_id === requestedIdentifier
|
||||||
|
)
|
||||||
|
));
|
||||||
|
return direct?.matched_identifier_type || rows[0]?.matched_identifier_type || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLifecycleLookupQuotes(pool, identifiers) {
|
||||||
|
if (!identifiers.quote_ids.length) return [];
|
||||||
|
const result = await pool.query(
|
||||||
|
`
|
||||||
|
SELECT observed_at, ingested_at, payload
|
||||||
|
FROM swap_demand_events
|
||||||
|
WHERE quote_id = ANY($1::text[])
|
||||||
|
OR payload->>'quote_id' = ANY($1::text[])
|
||||||
|
ORDER BY COALESCE(observed_at, ingested_at) DESC
|
||||||
|
LIMIT 20
|
||||||
|
`,
|
||||||
|
[identifiers.quote_ids],
|
||||||
|
);
|
||||||
|
return result.rows.map(normalizeRecentQuoteRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLifecycleLookupTradeDecisions(pool, identifiers) {
|
||||||
|
const result = await pool.query(
|
||||||
|
`
|
||||||
|
SELECT observed_at, ingested_at, payload
|
||||||
|
FROM trade_decisions
|
||||||
|
WHERE quote_id = ANY($1::text[])
|
||||||
|
OR payload->>'quote_id' = ANY($1::text[])
|
||||||
|
OR decision_key = ANY($2::text[])
|
||||||
|
OR payload->>'decision_id' = ANY($2::text[])
|
||||||
|
ORDER BY COALESCE(observed_at, ingested_at) DESC
|
||||||
|
LIMIT 20
|
||||||
|
`,
|
||||||
|
[identifiers.quote_ids, identifiers.decision_ids],
|
||||||
|
);
|
||||||
|
return result.rows.map((row) => ({
|
||||||
|
observed_at: toIsoTimestamp(row.observed_at),
|
||||||
|
ingested_at: toIsoTimestamp(row.ingested_at),
|
||||||
|
payload: row.payload,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLifecycleLookupExecuteTradeCommands(pool, identifiers) {
|
||||||
|
const result = await pool.query(
|
||||||
|
`
|
||||||
|
SELECT observed_at, ingested_at, payload
|
||||||
|
FROM execute_trade_commands
|
||||||
|
WHERE quote_id = ANY($1::text[])
|
||||||
|
OR payload->>'quote_id' = ANY($1::text[])
|
||||||
|
OR payload->>'decision_id' = ANY($2::text[])
|
||||||
|
OR decision_key = ANY($3::text[])
|
||||||
|
OR payload->>'command_id' = ANY($3::text[])
|
||||||
|
ORDER BY COALESCE(observed_at, ingested_at) DESC
|
||||||
|
LIMIT 20
|
||||||
|
`,
|
||||||
|
[identifiers.quote_ids, identifiers.decision_ids, identifiers.command_ids],
|
||||||
|
);
|
||||||
|
return result.rows.map((row) => normalizeExecuteTradeCommandRow(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLifecycleLookupExecutionResults(pool, identifiers) {
|
||||||
|
const result = await pool.query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
r.observed_at AS result_observed_at,
|
||||||
|
r.ingested_at AS result_ingested_at,
|
||||||
|
r.payload AS result_payload,
|
||||||
|
c.ingested_at AS command_ingested_at,
|
||||||
|
c.payload AS command_payload,
|
||||||
|
d.payload AS decision_payload,
|
||||||
|
o.payload AS outcome_payload
|
||||||
|
FROM trade_execution_results r
|
||||||
|
LEFT JOIN execute_trade_commands c
|
||||||
|
ON c.decision_key = r.decision_key
|
||||||
|
LEFT JOIN trade_decisions d
|
||||||
|
ON d.decision_key = COALESCE(c.payload->>'decision_id', r.payload->>'decision_id')
|
||||||
|
LEFT JOIN ${QUOTE_OUTCOMES_TABLE} o
|
||||||
|
ON o.quote_id = r.quote_id
|
||||||
|
WHERE r.quote_id = ANY($1::text[])
|
||||||
|
OR r.payload->>'quote_id' = ANY($1::text[])
|
||||||
|
OR r.payload->>'decision_id' = ANY($2::text[])
|
||||||
|
OR r.decision_key = ANY($3::text[])
|
||||||
|
OR r.payload->>'command_id' = ANY($3::text[])
|
||||||
|
ORDER BY COALESCE(r.observed_at, r.ingested_at) DESC
|
||||||
|
LIMIT 20
|
||||||
|
`,
|
||||||
|
[identifiers.quote_ids, identifiers.decision_ids, identifiers.command_ids],
|
||||||
|
);
|
||||||
|
return result.rows.map((row) => normalizeExecutionResultRow(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLifecycleLookupQuoteOutcomes(pool, identifiers) {
|
||||||
|
const result = await pool.query(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
quote_id,
|
||||||
|
decision_id,
|
||||||
|
command_id,
|
||||||
|
execution_result_status,
|
||||||
|
execution_result_code,
|
||||||
|
submitted_at,
|
||||||
|
command_at,
|
||||||
|
outcome_status,
|
||||||
|
outcome_observed_at,
|
||||||
|
outcome_source,
|
||||||
|
attribution_status,
|
||||||
|
attribution_method,
|
||||||
|
attributed_inventory_delta,
|
||||||
|
computed_at,
|
||||||
|
payload
|
||||||
|
FROM ${QUOTE_OUTCOMES_TABLE}
|
||||||
|
WHERE quote_id = ANY($1::text[])
|
||||||
|
OR payload->>'quote_id' = ANY($1::text[])
|
||||||
|
OR decision_id = ANY($2::text[])
|
||||||
|
OR payload->>'decision_id' = ANY($2::text[])
|
||||||
|
OR command_id = ANY($3::text[])
|
||||||
|
OR payload->>'command_id' = ANY($3::text[])
|
||||||
|
ORDER BY COALESCE(outcome_observed_at, submitted_at, command_at, computed_at) DESC
|
||||||
|
LIMIT 20
|
||||||
|
`,
|
||||||
|
[identifiers.quote_ids, identifiers.decision_ids, identifiers.command_ids],
|
||||||
|
);
|
||||||
|
return result.rows.map(normalizeQuoteOutcomeRow);
|
||||||
|
}
|
||||||
|
|
||||||
export async function loadIntentRequestPreflightByIdOrKey(pool, {
|
export async function loadIntentRequestPreflightByIdOrKey(pool, {
|
||||||
requestId = null,
|
requestId = null,
|
||||||
idempotencyKey = null,
|
idempotencyKey = null,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
export function lifecycleRowIdentifier(row = null) {
|
||||||
|
return row?.quote_id || row?.decision_id || row?.command_id || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findLifecycleRowByIdentifier(rows = [], identifier = '') {
|
||||||
|
const normalizedIdentifier = String(identifier || '').trim();
|
||||||
|
if (!normalizedIdentifier) return null;
|
||||||
|
return (rows || []).find((row) => (
|
||||||
|
row?.quote_id === normalizedIdentifier
|
||||||
|
|| row?.decision_id === normalizedIdentifier
|
||||||
|
|| row?.command_id === normalizedIdentifier
|
||||||
|
)) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSelectedLifecycleInvestigation(row, {
|
||||||
|
source = 'live_row',
|
||||||
|
lookup = null,
|
||||||
|
now = Date.now(),
|
||||||
|
} = {}) {
|
||||||
|
const identifier = lifecycleRowIdentifier(row);
|
||||||
|
if (!identifier) return null;
|
||||||
|
const refreshedAt = toIsoTimestamp(now);
|
||||||
|
return {
|
||||||
|
identifier,
|
||||||
|
row,
|
||||||
|
source,
|
||||||
|
lookup,
|
||||||
|
selected_at: refreshedAt,
|
||||||
|
refreshed_at: refreshedAt,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateSelectedLifecycleFromLiveRows(selected, rows = [], {
|
||||||
|
now = Date.now(),
|
||||||
|
} = {}) {
|
||||||
|
if (!selected?.identifier) return selected || null;
|
||||||
|
const liveRow = findLifecycleRowByIdentifier(rows, selected.identifier);
|
||||||
|
if (!liveRow) return selected;
|
||||||
|
return {
|
||||||
|
...selected,
|
||||||
|
row: liveRow,
|
||||||
|
source: 'live_row',
|
||||||
|
refreshed_at: toIsoTimestamp(now),
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shouldRefreshSelectedFromDurable(selected, rows = []) {
|
||||||
|
if (!selected?.identifier) return false;
|
||||||
|
return !findLifecycleRowByIdentifier(rows, selected.identifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateSelectedLifecycleFromLookup(selected, payload, {
|
||||||
|
identifier = null,
|
||||||
|
now = Date.now(),
|
||||||
|
} = {}) {
|
||||||
|
const requestedIdentifier = identifier || selected?.identifier || payload?.lookup?.requested_identifier || null;
|
||||||
|
const refreshedAt = toIsoTimestamp(now);
|
||||||
|
if (payload?.ok && payload.lifecycle_row) {
|
||||||
|
return {
|
||||||
|
...(selected || {}),
|
||||||
|
identifier: lifecycleRowIdentifier(payload.lifecycle_row) || requestedIdentifier,
|
||||||
|
row: payload.lifecycle_row,
|
||||||
|
source: 'durable_lookup',
|
||||||
|
lookup: payload.lookup || null,
|
||||||
|
selected_at: selected?.selected_at || refreshedAt,
|
||||||
|
refreshed_at: refreshedAt,
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...(selected || {}),
|
||||||
|
identifier: requestedIdentifier,
|
||||||
|
row: selected?.row || null,
|
||||||
|
source: payload?.error === 'quote_lifecycle_not_found' || payload?.lookup?.found === false
|
||||||
|
? 'not_found'
|
||||||
|
: 'lookup_error',
|
||||||
|
lookup: payload?.lookup || null,
|
||||||
|
selected_at: selected?.selected_at || refreshedAt,
|
||||||
|
refreshed_at: refreshedAt,
|
||||||
|
error: payload?.error || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toIsoTimestamp(value) {
|
||||||
|
const timestamp = typeof value === 'number' ? value : Date.parse(value);
|
||||||
|
if (!Number.isFinite(timestamp)) return new Date().toISOString();
|
||||||
|
return new Date(timestamp).toISOString();
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,13 @@ import MetricCard from '../components/MetricCard.jsx';
|
||||||
import Pill from '../components/Pill.jsx';
|
import Pill from '../components/Pill.jsx';
|
||||||
import TableFrame from '../components/TableFrame.jsx';
|
import TableFrame from '../components/TableFrame.jsx';
|
||||||
import { formatAgeFromTimestamp, formatBoolean, formatEur, formatTimestamp, truncateMiddle } from '../lib/format.js';
|
import { formatAgeFromTimestamp, formatBoolean, formatEur, formatTimestamp, truncateMiddle } from '../lib/format.js';
|
||||||
|
import {
|
||||||
|
createSelectedLifecycleInvestigation,
|
||||||
|
findLifecycleRowByIdentifier,
|
||||||
|
shouldRefreshSelectedFromDurable,
|
||||||
|
updateSelectedLifecycleFromLiveRows,
|
||||||
|
updateSelectedLifecycleFromLookup,
|
||||||
|
} from '../lib/quoteLifecycleInvestigator.js';
|
||||||
|
|
||||||
const RESPONDED_STATES = new Set(['submitted', 'awaiting_outcome', 'not_filled', 'completed']);
|
const RESPONDED_STATES = new Set(['submitted', 'awaiting_outcome', 'not_filled', 'completed']);
|
||||||
const TRADING_PAIR_MODES = new Set(['maker', 'taker', 'both']);
|
const TRADING_PAIR_MODES = new Set(['maker', 'taker', 'both']);
|
||||||
|
|
@ -13,6 +20,17 @@ const COMPETITIVENESS_DETAIL_ROW_COUNT = 8;
|
||||||
const COMPETITIVENESS_LATENCY_ROW_COUNT = 6;
|
const COMPETITIVENESS_LATENCY_ROW_COUNT = 6;
|
||||||
const COMPETITIVENESS_ROLLUP_ROW_COUNT = 8;
|
const COMPETITIVENESS_ROLLUP_ROW_COUNT = 8;
|
||||||
const QUOTE_LIFECYCLE_ROW_COUNT = 20;
|
const QUOTE_LIFECYCLE_ROW_COUNT = 20;
|
||||||
|
const QUOTE_LIFECYCLE_LOOKUP_DEBOUNCE_MS = 750;
|
||||||
|
const QUOTE_LIFECYCLE_PROBLEM_FILTERS = [
|
||||||
|
['all', 'All states'],
|
||||||
|
['awaiting_executor', 'Awaiting executor'],
|
||||||
|
['approved_no_command', 'Approved, no command'],
|
||||||
|
['blocked_before_submit', 'Blocked before submit'],
|
||||||
|
['submission_failed', 'Submission failed'],
|
||||||
|
['quote_not_found_or_finished', 'Quote not found or finished'],
|
||||||
|
['strategy_rejected', 'Strategy rejected'],
|
||||||
|
['submitted_awaiting_outcome', 'Submitted or awaiting outcome'],
|
||||||
|
];
|
||||||
|
|
||||||
async function copyIdentifier(value) {
|
async function copyIdentifier(value) {
|
||||||
if (!value || !navigator?.clipboard?.writeText) return;
|
if (!value || !navigator?.clipboard?.writeText) return;
|
||||||
|
|
@ -23,6 +41,22 @@ async function copyIdentifier(value) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchQuoteLifecycleLookup(identifier) {
|
||||||
|
const response = await fetch(`/api/strategy/quote-lifecycle/${encodeURIComponent(identifier)}`, {
|
||||||
|
headers: {
|
||||||
|
accept: 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
const payload = text.trim() ? JSON.parse(text) : null;
|
||||||
|
|
||||||
|
if (response.status === 404 && payload?.error === 'quote_lifecycle_not_found') return payload;
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(payload?.error || text.trim() || `HTTP ${response.status}`);
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
function useNow(intervalMs = 1000) {
|
function useNow(intervalMs = 1000) {
|
||||||
const [now, setNow] = useState(() => Date.now());
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
|
||||||
|
|
@ -179,6 +213,86 @@ function isStrategyRejected(item) {
|
||||||
|| String(item?.lifecycle_label || '').toLowerCase() === 'rejected by strategy';
|
|| String(item?.lifecycle_label || '').toLowerCase() === 'rejected by strategy';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isQuoteNotFoundOrFinished(item) {
|
||||||
|
return [
|
||||||
|
item?.reason_code,
|
||||||
|
item?.execution?.result_code,
|
||||||
|
item?.execution?.failure_category,
|
||||||
|
item?.execution?.outcome_reason,
|
||||||
|
item?.outcome?.outcome_reason,
|
||||||
|
item?.outcome_reason,
|
||||||
|
].some((value) => String(value || '').toLowerCase() === 'quote_not_found_or_finished');
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesLifecycleProblemFilter(item, filter) {
|
||||||
|
switch (filter) {
|
||||||
|
case 'awaiting_executor':
|
||||||
|
return item?.lifecycle_state === 'command_emitted';
|
||||||
|
case 'approved_no_command':
|
||||||
|
return item?.lifecycle_state === 'evaluated';
|
||||||
|
case 'blocked_before_submit':
|
||||||
|
return item?.lifecycle_state === 'blocked';
|
||||||
|
case 'submission_failed':
|
||||||
|
return item?.lifecycle_state === 'failed';
|
||||||
|
case 'quote_not_found_or_finished':
|
||||||
|
return isQuoteNotFoundOrFinished(item);
|
||||||
|
case 'strategy_rejected':
|
||||||
|
return isStrategyRejected(item);
|
||||||
|
case 'submitted_awaiting_outcome':
|
||||||
|
return item?.lifecycle_state === 'submitted' || item?.lifecycle_state === 'awaiting_outcome';
|
||||||
|
case 'all':
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceLabel(source) {
|
||||||
|
if (source === 'live_row') return 'Live row';
|
||||||
|
if (source === 'durable_lookup') return 'Durable lookup';
|
||||||
|
if (source === 'not_found') return 'Not found';
|
||||||
|
if (source === 'lookup_error') return 'Lookup error';
|
||||||
|
if (source === 'loading') return 'Lookup loading';
|
||||||
|
return 'No selection';
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceTone(source) {
|
||||||
|
if (source === 'not_found') return 'warning';
|
||||||
|
if (source === 'lookup_error') return 'critical';
|
||||||
|
if (source === 'live_row' || source === 'durable_lookup') return 'healthy';
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCommandAge(item, now) {
|
||||||
|
const age = formatAgeFromTimestamp(item?.command_at, now);
|
||||||
|
return age === 'Unavailable' ? 'Unavailable' : `${age} old`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function executorResultState(item) {
|
||||||
|
if (item?.execution?.status) return `Recorded: ${plainCodeLabel(item.execution.status)}`;
|
||||||
|
if (item?.execution_result_at) return 'Recorded';
|
||||||
|
if (item?.lifecycle_state === 'command_emitted') return 'Not recorded yet';
|
||||||
|
return 'Unavailable';
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextExpectedStage(item) {
|
||||||
|
if (item?.lifecycle_state === 'command_emitted') {
|
||||||
|
return 'Executor result, then relay or outcome evidence';
|
||||||
|
}
|
||||||
|
if (item?.lifecycle_state === 'submitted' || item?.lifecycle_state === 'awaiting_outcome') {
|
||||||
|
return 'Durable outcome and settlement attribution';
|
||||||
|
}
|
||||||
|
if (['failed', 'blocked', 'not_filled', 'completed', 'rejected'].includes(item?.lifecycle_state)) {
|
||||||
|
return 'No later stage is recorded as expected from current evidence';
|
||||||
|
}
|
||||||
|
return 'Strategy decision or execute command';
|
||||||
|
}
|
||||||
|
|
||||||
|
function awaitingExecutorBrief(item, now) {
|
||||||
|
if (item?.lifecycle_state !== 'command_emitted') return null;
|
||||||
|
const command = item.command_id ? truncateMiddle(item.command_id, 24) : 'Unavailable';
|
||||||
|
return `Command ${command}; age ${formatCommandAge(item, now)}; executor result ${executorResultState(item)}; next ${nextExpectedStage(item)}`;
|
||||||
|
}
|
||||||
|
|
||||||
function StageCard({ title, at, status, children }) {
|
function StageCard({ title, at, status, children }) {
|
||||||
return (
|
return (
|
||||||
<div className="lifecycle-stage-card">
|
<div className="lifecycle-stage-card">
|
||||||
|
|
@ -239,7 +353,7 @@ function TimingWaterfall({ timing }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function LifecycleDetails({ item }) {
|
function LifecycleDetails({ item, now = Date.now() }) {
|
||||||
const executionTiming = formatExecutionTiming(item.execution?.timing);
|
const executionTiming = formatExecutionTiming(item.execution?.timing);
|
||||||
const makerTerms = formatMakerTerms(item.maker_terms);
|
const makerTerms = formatMakerTerms(item.maker_terms);
|
||||||
const inventoryCheck = formatInventoryCheck(item.inventory_check);
|
const inventoryCheck = formatInventoryCheck(item.inventory_check);
|
||||||
|
|
@ -262,12 +376,17 @@ function LifecycleDetails({ item }) {
|
||||||
|
|
||||||
<StageCard at={item.command_at} status={item.command_id ? 'Command recorded' : 'No command'} title="3. Executor command">
|
<StageCard at={item.command_at} status={item.command_id ? 'Command recorded' : 'No command'} title="3. Executor command">
|
||||||
<IdentifierRow label="Command" value={item.command_id} />
|
<IdentifierRow label="Command" value={item.command_id} />
|
||||||
|
<div className="status-subtle">{`Command timestamp: ${formatTimestamp(item.command_at)}`}</div>
|
||||||
|
<div className="status-subtle">{`Command age: ${formatCommandAge(item, now)}`}</div>
|
||||||
|
<div className="status-subtle">{`Executor result: ${executorResultState(item)}`}</div>
|
||||||
|
<div className="status-subtle">{`Next expected: ${nextExpectedStage(item)}`}</div>
|
||||||
<div>{makerTerms || formatTerms(item.submitted_terms)}</div>
|
<div>{makerTerms || formatTerms(item.submitted_terms)}</div>
|
||||||
{makerTerms ? <div className="status-subtle">{formatTerms(item.submitted_terms)}</div> : null}
|
{makerTerms ? <div className="status-subtle">{formatTerms(item.submitted_terms)}</div> : null}
|
||||||
</StageCard>
|
</StageCard>
|
||||||
|
|
||||||
<StageCard at={item.execution_result_at} status={item.execution?.status || 'No relay result'} title="4. Relay response">
|
<StageCard at={item.execution_result_at} status={item.execution?.status || 'No relay result'} title="4. Relay response">
|
||||||
<div>{item.execution?.result_code || 'No executor result code stored'}</div>
|
<div>{item.execution?.result_code || 'No executor result code stored'}</div>
|
||||||
|
{item.execution?.failure_category ? <div className="status-subtle">{plainCodeLabel(item.execution.failure_category)}</div> : null}
|
||||||
{item.execution?.error_message ? <div className="status-subtle">{item.execution.error_message}</div> : null}
|
{item.execution?.error_message ? <div className="status-subtle">{item.execution.error_message}</div> : null}
|
||||||
{!item.execution?.error_message && item.execution?.note ? <div className="status-subtle">{item.execution.note}</div> : null}
|
{!item.execution?.error_message && item.execution?.note ? <div className="status-subtle">{item.execution.note}</div> : null}
|
||||||
{executionTiming ? <div className="status-subtle">{executionTiming}</div> : null}
|
{executionTiming ? <div className="status-subtle">{executionTiming}</div> : null}
|
||||||
|
|
@ -294,6 +413,81 @@ function LifecycleDetails({ item }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function QuoteLifecycleInvestigator({
|
||||||
|
selected,
|
||||||
|
lookupState,
|
||||||
|
lookupError,
|
||||||
|
now,
|
||||||
|
onRefresh,
|
||||||
|
}) {
|
||||||
|
const row = selected?.row || null;
|
||||||
|
const source = lookupState === 'loading' ? 'loading' : selected?.source;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="quote-investigator-panel">
|
||||||
|
<div className="quote-investigator-head">
|
||||||
|
<div>
|
||||||
|
<div className="eyebrow">Pinned quote investigator</div>
|
||||||
|
<h4>{row?.quote_id ? truncateMiddle(row.quote_id, 46) : selected?.identifier ? truncateMiddle(selected.identifier, 46) : 'No quote selected'}</h4>
|
||||||
|
<div className="panel-subtitle">
|
||||||
|
{row
|
||||||
|
? `${row.lifecycle_label || 'Unavailable'} - ${row.reason_text || 'Unavailable'}`
|
||||||
|
: selected?.source === 'not_found'
|
||||||
|
? 'No durable lifecycle evidence matched that identifier.'
|
||||||
|
: 'Select a row or look up an identifier to inspect durable lifecycle evidence.'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="quote-investigator-actions">
|
||||||
|
<Pill label={sourceLabel(source)} stateLabel={sourceTone(source)} />
|
||||||
|
{selected?.refreshed_at ? <Pill label={`Refreshed ${formatRelativeAge(selected.refreshed_at, now)}`} stateLabel="info" /> : null}
|
||||||
|
<button
|
||||||
|
className="button secondary"
|
||||||
|
disabled={!selected?.identifier || lookupState === 'loading'}
|
||||||
|
onClick={() => onRefresh?.(selected.identifier)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
Refresh lookup
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{lookupError ? <div className="banner error">{lookupError}</div> : null}
|
||||||
|
|
||||||
|
{row ? (
|
||||||
|
<>
|
||||||
|
<div className="quote-investigator-facts">
|
||||||
|
<div>
|
||||||
|
<div className="status-label">Lifecycle</div>
|
||||||
|
<div className="status-value">{row.lifecycle_label || 'Unavailable'}</div>
|
||||||
|
<div className="status-subtle mono">{row.reason_code || 'Unavailable'}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="status-label">Command age</div>
|
||||||
|
<div className="status-value">{formatCommandAge(row, now)}</div>
|
||||||
|
<div className="status-subtle mono">{row.command_id || 'Unavailable'}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="status-label">Executor result</div>
|
||||||
|
<div className="status-value">{executorResultState(row)}</div>
|
||||||
|
<div className="status-subtle">{formatTimestamp(row.execution_result_at)}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="status-label">Next expected</div>
|
||||||
|
<div className="status-value quote-investigator-next">{nextExpectedStage(row)}</div>
|
||||||
|
<div className="status-subtle">{formatTimestamp(row.latest_stage_at)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<LifecycleDetails item={row} now={now} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<EmptyState>
|
||||||
|
Durable lifecycle evidence is unavailable for the current identifier.
|
||||||
|
</EmptyState>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function pairDisplayLabel(pairId, pairConfig) {
|
function pairDisplayLabel(pairId, pairConfig) {
|
||||||
const pair = (pairConfig?.pairs || []).find((entry) => (
|
const pair = (pairConfig?.pairs || []).find((entry) => (
|
||||||
(entry.pair_id || entry.pairId || entry.pair) === pairId
|
(entry.pair_id || entry.pairId || entry.pair) === pairId
|
||||||
|
|
@ -661,15 +855,45 @@ function MakerCompetitivenessSection({ summary, pairConfig }) {
|
||||||
function QuoteLifecycleTable({ items }) {
|
function QuoteLifecycleTable({ items }) {
|
||||||
const [expanded, setExpanded] = useState(() => new Set());
|
const [expanded, setExpanded] = useState(() => new Set());
|
||||||
const [showStrategyRejected, setShowStrategyRejected] = useState(true);
|
const [showStrategyRejected, setShowStrategyRejected] = useState(true);
|
||||||
|
const [problemFilter, setProblemFilter] = useState('all');
|
||||||
const latestItemsRef = useRef(items || []);
|
const latestItemsRef = useRef(items || []);
|
||||||
const [quoteDisplayPaused, setQuoteDisplayPaused] = useState(false);
|
const [quoteDisplayPaused, setQuoteDisplayPaused] = useState(false);
|
||||||
const [displayItems, setDisplayItems] = useState(() => items || []);
|
const [displayItems, setDisplayItems] = useState(() => items || []);
|
||||||
|
const [lookupInput, setLookupInput] = useState('');
|
||||||
|
const [lookupState, setLookupState] = useState('idle');
|
||||||
|
const [lookupError, setLookupError] = useState(null);
|
||||||
|
const [selectedInvestigation, setSelectedInvestigation] = useState(null);
|
||||||
|
const selectedInvestigationRef = useRef(null);
|
||||||
|
const lookupTimerRef = useRef(null);
|
||||||
const liveNow = useNow();
|
const liveNow = useNow();
|
||||||
const [displayNow, setDisplayNow] = useState(() => Date.now());
|
const [displayNow, setDisplayNow] = useState(() => Date.now());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
selectedInvestigationRef.current = selectedInvestigation;
|
||||||
|
}, [selectedInvestigation]);
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (lookupTimerRef.current) window.clearTimeout(lookupTimerRef.current);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
latestItemsRef.current = items || [];
|
latestItemsRef.current = items || [];
|
||||||
if (!quoteDisplayPaused) setDisplayItems(items || []);
|
if (!quoteDisplayPaused) setDisplayItems(items || []);
|
||||||
|
|
||||||
|
const selected = selectedInvestigationRef.current;
|
||||||
|
if (!selected?.identifier) return;
|
||||||
|
const updated = updateSelectedLifecycleFromLiveRows(selected, items || [], {
|
||||||
|
now: Date.now(),
|
||||||
|
});
|
||||||
|
if (updated !== selected) {
|
||||||
|
setSelectedInvestigation(updated);
|
||||||
|
setLookupState('live');
|
||||||
|
setLookupError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (shouldRefreshSelectedFromDurable(selected, items || [])) {
|
||||||
|
scheduleDurableLookup(selected.identifier);
|
||||||
|
}
|
||||||
}, [items, quoteDisplayPaused]);
|
}, [items, quoteDisplayPaused]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -680,9 +904,17 @@ function QuoteLifecycleTable({ items }) {
|
||||||
() => displayItems.filter((item) => isStrategyRejected(item)).length,
|
() => displayItems.filter((item) => isStrategyRejected(item)).length,
|
||||||
[displayItems],
|
[displayItems],
|
||||||
);
|
);
|
||||||
|
const problemFilteredItems = useMemo(
|
||||||
|
() => displayItems.filter((item) => matchesLifecycleProblemFilter(item, problemFilter)),
|
||||||
|
[displayItems, problemFilter],
|
||||||
|
);
|
||||||
const visibleItems = useMemo(
|
const visibleItems = useMemo(
|
||||||
() => (showStrategyRejected ? displayItems : displayItems.filter((item) => !isStrategyRejected(item))),
|
() => (
|
||||||
[displayItems, showStrategyRejected],
|
showStrategyRejected || problemFilter === 'strategy_rejected'
|
||||||
|
? problemFilteredItems
|
||||||
|
: problemFilteredItems.filter((item) => !isStrategyRejected(item))
|
||||||
|
),
|
||||||
|
[problemFilteredItems, problemFilter, showStrategyRejected],
|
||||||
);
|
);
|
||||||
const visibleRows = fixedRows(visibleItems, QUOTE_LIFECYCLE_ROW_COUNT);
|
const visibleRows = fixedRows(visibleItems, QUOTE_LIFECYCLE_ROW_COUNT);
|
||||||
const emptyRowsMessage = !displayItems.length
|
const emptyRowsMessage = !displayItems.length
|
||||||
|
|
@ -700,6 +932,77 @@ function QuoteLifecycleTable({ items }) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function performLookup(identifier, { silent = false } = {}) {
|
||||||
|
const trimmed = String(identifier || '').trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
if (lookupTimerRef.current) {
|
||||||
|
window.clearTimeout(lookupTimerRef.current);
|
||||||
|
lookupTimerRef.current = null;
|
||||||
|
}
|
||||||
|
if (!silent) setLookupState('loading');
|
||||||
|
setLookupError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = await fetchQuoteLifecycleLookup(trimmed);
|
||||||
|
setSelectedInvestigation((current) => updateSelectedLifecycleFromLookup(
|
||||||
|
current?.identifier === trimmed ? current : { identifier: trimmed, row: null },
|
||||||
|
payload,
|
||||||
|
{ identifier: trimmed, now: Date.now() },
|
||||||
|
));
|
||||||
|
setLookupState(payload?.ok ? 'found' : 'not_found');
|
||||||
|
setLookupError(null);
|
||||||
|
} catch (error) {
|
||||||
|
const message = error?.message || 'Lookup failed';
|
||||||
|
setSelectedInvestigation((current) => updateSelectedLifecycleFromLookup(
|
||||||
|
current?.identifier === trimmed ? current : { identifier: trimmed, row: null },
|
||||||
|
{
|
||||||
|
error: message,
|
||||||
|
lookup: {
|
||||||
|
requested_identifier: trimmed,
|
||||||
|
matched_identifier_type: null,
|
||||||
|
found: false,
|
||||||
|
lookup_at: new Date().toISOString(),
|
||||||
|
latest_stage_at: null,
|
||||||
|
matched_identifiers: {
|
||||||
|
quote_ids: [],
|
||||||
|
decision_ids: [],
|
||||||
|
command_ids: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ identifier: trimmed, now: Date.now() },
|
||||||
|
));
|
||||||
|
setLookupState('error');
|
||||||
|
setLookupError(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleDurableLookup(identifier) {
|
||||||
|
if (!identifier) return;
|
||||||
|
if (lookupTimerRef.current) window.clearTimeout(lookupTimerRef.current);
|
||||||
|
lookupTimerRef.current = window.setTimeout(() => {
|
||||||
|
performLookup(identifier, { silent: true }).catch(() => {});
|
||||||
|
}, QUOTE_LIFECYCLE_LOOKUP_DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function inspectRow(item) {
|
||||||
|
const next = createSelectedLifecycleInvestigation(item, {
|
||||||
|
source: 'live_row',
|
||||||
|
now: Date.now(),
|
||||||
|
});
|
||||||
|
if (!next) return;
|
||||||
|
setSelectedInvestigation(next);
|
||||||
|
setLookupInput(next.identifier);
|
||||||
|
setLookupState('live');
|
||||||
|
setLookupError(null);
|
||||||
|
scheduleDurableLookup(next.identifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitLookup(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
performLookup(lookupInput).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
function applyLatestLifecycleDisplay() {
|
function applyLatestLifecycleDisplay() {
|
||||||
setDisplayItems(latestItemsRef.current || []);
|
setDisplayItems(latestItemsRef.current || []);
|
||||||
setDisplayNow(Date.now());
|
setDisplayNow(Date.now());
|
||||||
|
|
@ -712,7 +1015,29 @@ function QuoteLifecycleTable({ items }) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<form className="quote-lifecycle-lookup" onSubmit={submitLookup}>
|
||||||
|
<label className="field quote-lifecycle-lookup-field">
|
||||||
|
<span>Lookup identifier</span>
|
||||||
|
<input
|
||||||
|
onChange={(event) => setLookupInput(event.target.value)}
|
||||||
|
placeholder="quote_id, decision_id, or command_id"
|
||||||
|
type="search"
|
||||||
|
value={lookupInput}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button className="button" disabled={!lookupInput.trim() || lookupState === 'loading'} type="submit">
|
||||||
|
Lookup
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
<div className="quote-lifecycle-controls">
|
<div className="quote-lifecycle-controls">
|
||||||
|
<label className="field quote-lifecycle-filter-field">
|
||||||
|
<span>Problem state</span>
|
||||||
|
<select value={problemFilter} onChange={(event) => setProblemFilter(event.target.value)}>
|
||||||
|
{QUOTE_LIFECYCLE_PROBLEM_FILTERS.map(([value, label]) => (
|
||||||
|
<option key={value} value={value}>{label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
<label className="toggle-field">
|
<label className="toggle-field">
|
||||||
<input
|
<input
|
||||||
checked={showStrategyRejected}
|
checked={showStrategyRejected}
|
||||||
|
|
@ -732,9 +1057,17 @@ function QuoteLifecycleTable({ items }) {
|
||||||
<Pill label={quoteDisplayPaused ? 'Display paused' : 'Live updates'} stateLabel={quoteDisplayPaused ? 'warning' : 'healthy'} />
|
<Pill label={quoteDisplayPaused ? 'Display paused' : 'Live updates'} stateLabel={quoteDisplayPaused ? 'warning' : 'healthy'} />
|
||||||
</div>
|
</div>
|
||||||
<div className="status-subtle quote-lifecycle-snapshot-note">
|
<div className="status-subtle quote-lifecycle-snapshot-note">
|
||||||
Live rows update as they arrive. Fixed row slots and clamped cells keep the table height stable.
|
Pause freezes only the rolling table. The pinned quote investigator can still refresh from durable lookup.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<QuoteLifecycleInvestigator
|
||||||
|
lookupError={lookupError}
|
||||||
|
lookupState={lookupState}
|
||||||
|
now={liveNow}
|
||||||
|
onRefresh={(identifier) => performLookup(identifier).catch(() => {})}
|
||||||
|
selected={selectedInvestigation}
|
||||||
|
/>
|
||||||
|
|
||||||
<TableFrame className="quote-lifecycle-frame">
|
<TableFrame className="quote-lifecycle-frame">
|
||||||
<table className="quote-lifecycle-table">
|
<table className="quote-lifecycle-table">
|
||||||
<thead>
|
<thead>
|
||||||
|
|
@ -760,13 +1093,18 @@ function QuoteLifecycleTable({ items }) {
|
||||||
}
|
}
|
||||||
const rowKey = item.quote_id || item.decision_id || item.command_id || item.latest_stage_at || String(index);
|
const rowKey = item.quote_id || item.decision_id || item.command_id || item.latest_stage_at || String(index);
|
||||||
const isExpanded = expanded.has(rowKey);
|
const isExpanded = expanded.has(rowKey);
|
||||||
|
const isSelected = Boolean(
|
||||||
|
selectedInvestigation?.identifier
|
||||||
|
&& findLifecycleRowByIdentifier([item], selectedInvestigation.identifier),
|
||||||
|
);
|
||||||
const quoteTime = item.quote_activity_at || item.latest_stage_at;
|
const quoteTime = item.quote_activity_at || item.latest_stage_at;
|
||||||
const updatedText = item.latest_stage_at && item.latest_stage_at !== item.quote_activity_at
|
const updatedText = item.latest_stage_at && item.latest_stage_at !== item.quote_activity_at
|
||||||
? `Updated ${formatTimestamp(item.latest_stage_at)} - ${formatRelativeAge(item.latest_stage_at, displayNow)}`
|
? `Updated ${formatTimestamp(item.latest_stage_at)} - ${formatRelativeAge(item.latest_stage_at, displayNow)}`
|
||||||
: '';
|
: '';
|
||||||
|
const awaitingBrief = awaitingExecutorBrief(item, displayNow);
|
||||||
return (
|
return (
|
||||||
<Fragment key={rowKey}>
|
<Fragment key={rowKey}>
|
||||||
<tr className={`quote-lifecycle-row${item.live_flash_at ? ' quote-row-flash' : ''}`} key={`${rowKey}:row`}>
|
<tr className={`quote-lifecycle-row${item.live_flash_at ? ' quote-row-flash' : ''}${isSelected ? ' is-selected' : ''}`} key={`${rowKey}:row`}>
|
||||||
<td>
|
<td>
|
||||||
<div className="quote-lifecycle-cell">
|
<div className="quote-lifecycle-cell">
|
||||||
<div className="lifecycle-line lifecycle-clamp-one">{formatTimestamp(quoteTime)}</div>
|
<div className="lifecycle-line lifecycle-clamp-one">{formatTimestamp(quoteTime)}</div>
|
||||||
|
|
@ -788,8 +1126,9 @@ function QuoteLifecycleTable({ items }) {
|
||||||
<td><Pill label={item.lifecycle_label} stateLabel={item.lifecycle_tone} /></td>
|
<td><Pill label={item.lifecycle_label} stateLabel={item.lifecycle_tone} /></td>
|
||||||
<td>
|
<td>
|
||||||
<div className="quote-lifecycle-cell">
|
<div className="quote-lifecycle-cell">
|
||||||
<div className="lifecycle-line lifecycle-clamp-two">{item.reason_text}</div>
|
<div className="lifecycle-line lifecycle-clamp-two">{item.reason_text || 'Unavailable'}</div>
|
||||||
<div className="status-subtle mono lifecycle-line lifecycle-clamp-one">{item.reason_code || 'reason_unknown'}</div>
|
<div className="status-subtle mono lifecycle-line lifecycle-clamp-one">{item.reason_code || 'Unavailable'}</div>
|
||||||
|
{awaitingBrief ? <div className="status-subtle lifecycle-line lifecycle-clamp-two">{awaitingBrief}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
|
|
@ -800,14 +1139,19 @@ function QuoteLifecycleTable({ items }) {
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<button className="button secondary" onClick={() => toggle(rowKey)} type="button">
|
<div className="lifecycle-action-stack">
|
||||||
{isExpanded ? 'Hide lifecycle' : 'Show lifecycle'}
|
<button className="button secondary" onClick={() => inspectRow(item)} type="button">
|
||||||
</button>
|
Inspect quote
|
||||||
|
</button>
|
||||||
|
<button className="button secondary" onClick={() => toggle(rowKey)} type="button">
|
||||||
|
{isExpanded ? 'Hide lifecycle' : 'Show lifecycle'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{isExpanded ? (
|
{isExpanded ? (
|
||||||
<tr className="lifecycle-expanded-row" key={`${rowKey}:details`}>
|
<tr className="lifecycle-expanded-row" key={`${rowKey}:details`}>
|
||||||
<td colSpan={8}><LifecycleDetails item={item} /></td>
|
<td colSpan={8}><LifecycleDetails item={item} now={displayNow} /></td>
|
||||||
</tr>
|
</tr>
|
||||||
) : null}
|
) : null}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
|
|
|
||||||
|
|
@ -618,10 +618,85 @@ table.lifecycle-table th:nth-child(5) {
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quote-lifecycle-lookup {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: end;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-lifecycle-lookup-field {
|
||||||
|
flex: 1 1 320px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-lifecycle-filter-field {
|
||||||
|
min-width: 230px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-lifecycle-lookup-field span,
|
||||||
|
.quote-lifecycle-filter-field span {
|
||||||
|
font-size: 0.84rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
.quote-lifecycle-snapshot-note {
|
.quote-lifecycle-snapshot-note {
|
||||||
margin-bottom: 12px;
|
margin-bottom: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quote-investigator-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-investigator-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-investigator-head h4 {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-investigator-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-investigator-facts {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-investigator-facts > div {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--panel-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-investigator-facts .status-value {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 1rem;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quote-investigator-next {
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
.toggle-field {
|
.toggle-field {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -651,6 +726,11 @@ table.lifecycle-table th:nth-child(5) {
|
||||||
height: 112px;
|
height: 112px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quote-lifecycle-table tbody tr.quote-lifecycle-row.is-selected td {
|
||||||
|
box-shadow: inset 3px 0 0 rgba(31, 122, 90, 0.76);
|
||||||
|
background: rgba(31, 122, 90, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
.quote-lifecycle-table td {
|
.quote-lifecycle-table td {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
@ -752,7 +832,12 @@ table.lifecycle-table th:nth-child(5) {
|
||||||
.quote-lifecycle-table td:nth-child(8),
|
.quote-lifecycle-table td:nth-child(8),
|
||||||
.successful-trades-table th:nth-child(7),
|
.successful-trades-table th:nth-child(7),
|
||||||
.successful-trades-table td:nth-child(7) {
|
.successful-trades-table td:nth-child(7) {
|
||||||
width: 150px;
|
width: 170px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lifecycle-action-stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.successful-trades-table {
|
.successful-trades-table {
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,16 @@ test('operator dashboard API auth failures are JSON for frontend fetches', () =>
|
||||||
assert.match(source, /sendJson\(res, 401, \{ error: 'authentication_required' \}\)/);
|
assert.match(source, /sendJson\(res, 401, \{ error: 'authentication_required' \}\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('operator dashboard exposes authenticated durable quote lifecycle lookup API', () => {
|
||||||
|
assert.match(source, /quote-lifecycle/);
|
||||||
|
assert.match(source, /lifecycleLookupMatch/);
|
||||||
|
assert.match(source, /loadQuoteLifecycleLookupEvidence/);
|
||||||
|
assert.match(source, /buildQuoteLifecycleLookupResponse/);
|
||||||
|
assert.match(source, /quote_lifecycle_identifier_required/);
|
||||||
|
assert.match(source, /quote_lifecycle_not_found/);
|
||||||
|
assert.match(source, /payload\.ok \? 200 : 404/);
|
||||||
|
});
|
||||||
|
|
||||||
test('operator dashboard bootstrap does not synchronously refresh intent request outcomes', () => {
|
test('operator dashboard bootstrap does not synchronously refresh intent request outcomes', () => {
|
||||||
assert.match(source, /loadRecentIntentRequests\(pool, \{[\s\S]*refreshOutcomes: false/);
|
assert.match(source, /loadRecentIntentRequests\(pool, \{[\s\S]*refreshOutcomes: false/);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -32,9 +32,29 @@ test('strategy page owns consolidated quote lifecycle and successful trade table
|
||||||
assert.match(strategySource, /setDisplayItems\(items \|\| \[\]\)/);
|
assert.match(strategySource, /setDisplayItems\(items \|\| \[\]\)/);
|
||||||
assert.match(strategySource, /useNow\(\)/);
|
assert.match(strategySource, /useNow\(\)/);
|
||||||
assert.match(strategySource, /Live updates/);
|
assert.match(strategySource, /Live updates/);
|
||||||
assert.match(strategySource, /Live rows update as they arrive/);
|
assert.match(strategySource, /Pause freezes only the rolling table/);
|
||||||
|
assert.match(strategySource, /Pinned quote investigator/);
|
||||||
|
assert.match(strategySource, /Lookup identifier/);
|
||||||
|
assert.match(strategySource, /Inspect quote/);
|
||||||
|
assert.match(strategySource, /Refresh lookup/);
|
||||||
|
assert.match(strategySource, /fetchQuoteLifecycleLookup/);
|
||||||
|
assert.match(strategySource, /\/api\/strategy\/quote-lifecycle\//);
|
||||||
|
assert.match(strategySource, /QUOTE_LIFECYCLE_PROBLEM_FILTERS/);
|
||||||
|
assert.match(strategySource, /awaiting_executor/);
|
||||||
|
assert.match(strategySource, /quote_not_found_or_finished/);
|
||||||
|
assert.match(strategySource, /approved_no_command/);
|
||||||
|
assert.match(strategySource, /submitted_awaiting_outcome/);
|
||||||
|
assert.match(strategySource, /updateSelectedLifecycleFromLiveRows/);
|
||||||
|
assert.match(strategySource, /shouldRefreshSelectedFromDurable/);
|
||||||
|
assert.match(strategySource, /updateSelectedLifecycleFromLookup/);
|
||||||
|
assert.match(strategySource, /Command age:/);
|
||||||
|
assert.match(strategySource, /Executor result:/);
|
||||||
|
assert.match(strategySource, /Next expected:/);
|
||||||
assert.match(strategySource, /visibleRows/);
|
assert.match(strategySource, /visibleRows/);
|
||||||
assert.match(strategySource, /quote-lifecycle-placeholder-row/);
|
assert.match(strategySource, /quote-lifecycle-placeholder-row/);
|
||||||
|
assert.match(stylesSource, /\.quote-investigator-panel/);
|
||||||
|
assert.match(stylesSource, /\.quote-lifecycle-lookup/);
|
||||||
|
assert.match(stylesSource, /\.quote-lifecycle-table tbody tr\.quote-lifecycle-row\.is-selected/);
|
||||||
assert.match(stylesSource, /\.shell \{[\s\S]*?width: 100%;[\s\S]*?margin: 0;[\s\S]*?\}/);
|
assert.match(stylesSource, /\.shell \{[\s\S]*?width: 100%;[\s\S]*?margin: 0;[\s\S]*?\}/);
|
||||||
assert.doesNotMatch(stylesSource, /^\s*max-width\s*:/m);
|
assert.doesNotMatch(stylesSource, /^\s*max-width\s*:/m);
|
||||||
assert.doesNotMatch(strategySource, /maxWidth:/);
|
assert.doesNotMatch(strategySource, /maxWidth:/);
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
buildDashboardBootstrap,
|
buildDashboardBootstrap,
|
||||||
buildDashboardControlErrorResponse,
|
buildDashboardControlErrorResponse,
|
||||||
buildLiveStatusBar,
|
buildLiveStatusBar,
|
||||||
|
buildQuoteLifecycleLookupResponse,
|
||||||
buildProfitabilitySummary,
|
buildProfitabilitySummary,
|
||||||
createDashboardLiveState,
|
createDashboardLiveState,
|
||||||
deriveQuoteLifecycleRows,
|
deriveQuoteLifecycleRows,
|
||||||
|
|
@ -15,12 +16,19 @@ import {
|
||||||
resolveDashboardControlTimeoutMs,
|
resolveDashboardControlTimeoutMs,
|
||||||
} from '../src/core/operator-dashboard.mjs';
|
} from '../src/core/operator-dashboard.mjs';
|
||||||
import { formatAge, formatAgeFromTimestamp } from '../src/operator-dashboard/static/lib/format.js';
|
import { formatAge, formatAgeFromTimestamp } from '../src/operator-dashboard/static/lib/format.js';
|
||||||
|
import {
|
||||||
|
createSelectedLifecycleInvestigation,
|
||||||
|
shouldRefreshSelectedFromDurable,
|
||||||
|
updateSelectedLifecycleFromLiveRows,
|
||||||
|
updateSelectedLifecycleFromLookup,
|
||||||
|
} from '../src/operator-dashboard/static/lib/quoteLifecycleInvestigator.js';
|
||||||
import { dashboardReducer } from '../src/operator-dashboard/static/state/dashboardReducer.js';
|
import { dashboardReducer } from '../src/operator-dashboard/static/state/dashboardReducer.js';
|
||||||
import {
|
import {
|
||||||
buildDashboardSessionToken,
|
buildDashboardSessionToken,
|
||||||
parseBasicAuthorizationHeader,
|
parseBasicAuthorizationHeader,
|
||||||
resolveDashboardRequestAuth,
|
resolveDashboardRequestAuth,
|
||||||
} from '../src/core/operator-dashboard-auth.mjs';
|
} from '../src/core/operator-dashboard-auth.mjs';
|
||||||
|
import { loadQuoteLifecycleLookupEvidence } from '../src/lib/postgres.mjs';
|
||||||
|
|
||||||
function buildConfig() {
|
function buildConfig() {
|
||||||
const tradingBtc = {
|
const tradingBtc = {
|
||||||
|
|
@ -564,6 +572,272 @@ test('socket lifecycle messages replace strategy rows without page refresh', ()
|
||||||
assert.equal(next.dashboard.strategy.strategy_state.recent_lifecycle_rows[0].live_flash_at, '2026-04-04T09:00:00.000Z');
|
assert.equal(next.dashboard.strategy.strategy_state.recent_lifecycle_rows[0].live_flash_at, '2026-04-04T09:00:00.000Z');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('selected awaiting executor quote remains inspectable after live rows move and refreshes from durable result', () => {
|
||||||
|
const config = buildConfig();
|
||||||
|
const awaiting = buildQuoteLifecycleLookupResponse({
|
||||||
|
config,
|
||||||
|
identifier: 'quote-follow',
|
||||||
|
evidence: {
|
||||||
|
lookup_at: '2026-06-12T10:00:00.000Z',
|
||||||
|
matched_identifier_type: 'quote_id',
|
||||||
|
matched_identifiers: {
|
||||||
|
quote_ids: ['quote-follow'],
|
||||||
|
decision_ids: ['decision-follow'],
|
||||||
|
command_ids: ['cmd-follow'],
|
||||||
|
},
|
||||||
|
recentTradeDecisions: [{
|
||||||
|
observed_at: '2026-06-12T09:59:58.000Z',
|
||||||
|
payload: {
|
||||||
|
quote_id: 'quote-follow',
|
||||||
|
decision_id: 'decision-follow',
|
||||||
|
pair: config.activePair,
|
||||||
|
decision: 'actionable',
|
||||||
|
decision_reason: 'actionable',
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
recentExecuteTradeCommands: [{
|
||||||
|
observed_at: '2026-06-12T09:59:59.000Z',
|
||||||
|
command_id: 'cmd-follow',
|
||||||
|
decision_id: 'decision-follow',
|
||||||
|
quote_id: 'quote-follow',
|
||||||
|
pair: config.activePair,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(awaiting.ok, true);
|
||||||
|
assert.equal(awaiting.lifecycle_row.lifecycle_state, 'command_emitted');
|
||||||
|
assert.equal(awaiting.lifecycle_row.lifecycle_label, 'Awaiting executor');
|
||||||
|
|
||||||
|
const selected = createSelectedLifecycleInvestigation(awaiting.lifecycle_row, {
|
||||||
|
now: '2026-06-12T10:00:00.000Z',
|
||||||
|
});
|
||||||
|
const afterLiveMove = updateSelectedLifecycleFromLiveRows(selected, [{
|
||||||
|
quote_id: 'quote-newer',
|
||||||
|
lifecycle_state: 'observed',
|
||||||
|
}], {
|
||||||
|
now: '2026-06-12T10:00:02.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(afterLiveMove.row.quote_id, 'quote-follow');
|
||||||
|
assert.equal(afterLiveMove.row.lifecycle_state, 'command_emitted');
|
||||||
|
assert.equal(shouldRefreshSelectedFromDurable(afterLiveMove, [{ quote_id: 'quote-newer' }]), true);
|
||||||
|
|
||||||
|
const later = buildQuoteLifecycleLookupResponse({
|
||||||
|
config,
|
||||||
|
identifier: 'cmd-follow',
|
||||||
|
evidence: {
|
||||||
|
lookup_at: '2026-06-12T10:00:04.000Z',
|
||||||
|
matched_identifier_type: 'command_id',
|
||||||
|
matched_identifiers: {
|
||||||
|
quote_ids: ['quote-follow'],
|
||||||
|
decision_ids: ['decision-follow'],
|
||||||
|
command_ids: ['cmd-follow'],
|
||||||
|
},
|
||||||
|
recentExecuteTradeCommands: [{
|
||||||
|
observed_at: '2026-06-12T09:59:59.000Z',
|
||||||
|
command_id: 'cmd-follow',
|
||||||
|
decision_id: 'decision-follow',
|
||||||
|
quote_id: 'quote-follow',
|
||||||
|
pair: config.activePair,
|
||||||
|
}],
|
||||||
|
recentExecutionResults: [{
|
||||||
|
command_id: 'cmd-follow',
|
||||||
|
decision_id: 'decision-follow',
|
||||||
|
quote_id: 'quote-follow',
|
||||||
|
pair: config.activePair,
|
||||||
|
result_at: '2026-06-12T10:00:03.000Z',
|
||||||
|
status: 'failed',
|
||||||
|
result_code: 'submission_failed',
|
||||||
|
failure_category: 'quote_not_found_or_finished',
|
||||||
|
note: 'relay no longer has the quote',
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const refreshed = updateSelectedLifecycleFromLookup(afterLiveMove, later, {
|
||||||
|
identifier: 'cmd-follow',
|
||||||
|
now: '2026-06-12T10:00:04.000Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(refreshed.source, 'durable_lookup');
|
||||||
|
assert.equal(refreshed.row.quote_id, 'quote-follow');
|
||||||
|
assert.equal(refreshed.row.lifecycle_state, 'failed');
|
||||||
|
assert.equal(refreshed.row.reason_code, 'quote_not_found_or_finished');
|
||||||
|
assert.match(refreshed.row.reason_text, /not found or already finished/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('quote lifecycle lookup response matches quote, decision, and command identifiers from durable evidence', () => {
|
||||||
|
const config = buildConfig();
|
||||||
|
const evidence = {
|
||||||
|
lookup_at: '2026-06-12T10:10:00.000Z',
|
||||||
|
matched_identifiers: {
|
||||||
|
quote_ids: ['quote-lookup'],
|
||||||
|
decision_ids: ['decision-lookup'],
|
||||||
|
command_ids: ['cmd-lookup'],
|
||||||
|
},
|
||||||
|
recentQuotes: [{
|
||||||
|
quote_id: 'quote-lookup',
|
||||||
|
pair: config.activePair,
|
||||||
|
asset_in: config.tradingBtc.assetId,
|
||||||
|
asset_out: config.tradingEure.assetId,
|
||||||
|
amount_in: '100',
|
||||||
|
amount_out: '200',
|
||||||
|
observed_at: '2026-06-12T10:09:57.000Z',
|
||||||
|
}],
|
||||||
|
recentTradeDecisions: [{
|
||||||
|
observed_at: '2026-06-12T10:09:58.000Z',
|
||||||
|
payload: {
|
||||||
|
quote_id: 'quote-lookup',
|
||||||
|
decision_id: 'decision-lookup',
|
||||||
|
pair: config.activePair,
|
||||||
|
decision: 'actionable',
|
||||||
|
decision_reason: 'actionable',
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
recentExecuteTradeCommands: [{
|
||||||
|
observed_at: '2026-06-12T10:09:59.000Z',
|
||||||
|
command_id: 'cmd-lookup',
|
||||||
|
decision_id: 'decision-lookup',
|
||||||
|
quote_id: 'quote-lookup',
|
||||||
|
pair: config.activePair,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
const byQuote = buildQuoteLifecycleLookupResponse({
|
||||||
|
config,
|
||||||
|
identifier: 'quote-lookup',
|
||||||
|
evidence: { ...evidence, matched_identifier_type: 'quote_id' },
|
||||||
|
});
|
||||||
|
const byDecision = buildQuoteLifecycleLookupResponse({
|
||||||
|
config,
|
||||||
|
identifier: 'decision-lookup',
|
||||||
|
evidence: { ...evidence, matched_identifier_type: 'decision_id' },
|
||||||
|
});
|
||||||
|
const byCommand = buildQuoteLifecycleLookupResponse({
|
||||||
|
config,
|
||||||
|
identifier: 'cmd-lookup',
|
||||||
|
evidence: { ...evidence, matched_identifier_type: 'command_id' },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(byQuote.lifecycle_row.quote_id, 'quote-lookup');
|
||||||
|
assert.equal(byDecision.lifecycle_row.decision_id, 'decision-lookup');
|
||||||
|
assert.equal(byCommand.lifecycle_row.command_id, 'cmd-lookup');
|
||||||
|
assert.equal(byQuote.lookup.matched_identifier_type, 'quote_id');
|
||||||
|
assert.equal(byDecision.lookup.matched_identifier_type, 'decision_id');
|
||||||
|
assert.equal(byCommand.lookup.matched_identifier_type, 'command_id');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('postgres quote lifecycle lookup loader links durable quote, decision, and command evidence', async () => {
|
||||||
|
const queries = [];
|
||||||
|
const pool = {
|
||||||
|
async query(sql, params = []) {
|
||||||
|
queries.push({ sql, params });
|
||||||
|
if (sql.includes('WITH candidates')) {
|
||||||
|
return {
|
||||||
|
rows: [{
|
||||||
|
matched_identifier_type: 'decision_id',
|
||||||
|
quote_id: 'quote-loader',
|
||||||
|
decision_id: 'decision-loader',
|
||||||
|
command_id: 'cmd-loader',
|
||||||
|
matched_at: '2026-06-12T10:20:00.000Z',
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (sql.includes('FROM swap_demand_events')) {
|
||||||
|
return {
|
||||||
|
rows: [{
|
||||||
|
observed_at: '2026-06-12T10:19:57.000Z',
|
||||||
|
ingested_at: '2026-06-12T10:19:57.000Z',
|
||||||
|
payload: {
|
||||||
|
quote_id: 'quote-loader',
|
||||||
|
pair: 'btc->eure',
|
||||||
|
asset_in: 'btc',
|
||||||
|
asset_out: 'eure',
|
||||||
|
amount_in: '100',
|
||||||
|
amount_out: '200',
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (sql.includes('FROM trade_decisions')) {
|
||||||
|
return {
|
||||||
|
rows: [{
|
||||||
|
observed_at: '2026-06-12T10:19:58.000Z',
|
||||||
|
ingested_at: '2026-06-12T10:19:58.000Z',
|
||||||
|
payload: {
|
||||||
|
quote_id: 'quote-loader',
|
||||||
|
decision_id: 'decision-loader',
|
||||||
|
decision: 'actionable',
|
||||||
|
decision_reason: 'actionable',
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (sql.includes('FROM execute_trade_commands')) {
|
||||||
|
return {
|
||||||
|
rows: [{
|
||||||
|
observed_at: '2026-06-12T10:19:59.000Z',
|
||||||
|
ingested_at: '2026-06-12T10:19:59.000Z',
|
||||||
|
payload: {
|
||||||
|
quote_id: 'quote-loader',
|
||||||
|
decision_id: 'decision-loader',
|
||||||
|
command_id: 'cmd-loader',
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (sql.includes('FROM trade_execution_results r')) {
|
||||||
|
return {
|
||||||
|
rows: [{
|
||||||
|
result_observed_at: '2026-06-12T10:20:01.000Z',
|
||||||
|
result_ingested_at: '2026-06-12T10:20:01.000Z',
|
||||||
|
result_payload: {
|
||||||
|
quote_id: 'quote-loader',
|
||||||
|
decision_id: 'decision-loader',
|
||||||
|
command_id: 'cmd-loader',
|
||||||
|
status: 'failed',
|
||||||
|
result_code: 'submission_failed',
|
||||||
|
failure_category: 'quote_not_found_or_finished',
|
||||||
|
},
|
||||||
|
command_ingested_at: '2026-06-12T10:19:59.000Z',
|
||||||
|
command_payload: {
|
||||||
|
quote_id: 'quote-loader',
|
||||||
|
decision_id: 'decision-loader',
|
||||||
|
command_id: 'cmd-loader',
|
||||||
|
},
|
||||||
|
decision_payload: {
|
||||||
|
quote_id: 'quote-loader',
|
||||||
|
decision_id: 'decision-loader',
|
||||||
|
},
|
||||||
|
outcome_payload: null,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (sql.includes('FROM quote_outcome_attributions')) {
|
||||||
|
return { rows: [] };
|
||||||
|
}
|
||||||
|
return { rows: [] };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const evidence = await loadQuoteLifecycleLookupEvidence(pool, {
|
||||||
|
identifier: 'decision-loader',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(evidence.found, true);
|
||||||
|
assert.equal(evidence.matched_identifier_type, 'decision_id');
|
||||||
|
assert.deepEqual(evidence.matched_identifiers, {
|
||||||
|
quote_ids: ['quote-loader'],
|
||||||
|
decision_ids: ['decision-loader'],
|
||||||
|
command_ids: ['cmd-loader'],
|
||||||
|
});
|
||||||
|
assert.equal(evidence.recentQuotes[0].quote_id, 'quote-loader');
|
||||||
|
assert.equal(evidence.recentTradeDecisions[0].payload.decision_id, 'decision-loader');
|
||||||
|
assert.equal(evidence.recentExecuteTradeCommands[0].command_id, 'cmd-loader');
|
||||||
|
assert.equal(evidence.recentExecutionResults[0].failure_category, 'quote_not_found_or_finished');
|
||||||
|
assert.equal(queries[0].params[0], 'decision-loader');
|
||||||
|
});
|
||||||
|
|
||||||
test('dashboard age formatting uses seconds first and exact timestamp deltas', () => {
|
test('dashboard age formatting uses seconds first and exact timestamp deltas', () => {
|
||||||
assert.equal(formatAge(999), '999 ms');
|
assert.equal(formatAge(999), '999 ms');
|
||||||
assert.equal(formatAge(1_500), '1s');
|
assert.equal(formatAge(1_500), '1s');
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue