mirror of
https://github.com/EvoMap/evolver.git
synced 2026-09-18 21:47:53 +08:00
226 lines
9.0 KiB
JavaScript
226 lines
9.0 KiB
JavaScript
import { hub as hubNs, mailbox, ops } from '@evomap/evolver-core';
|
|
import { renderFailureError } from './failureCodes.js';
|
|
const LAST_UPDATE_STATE_KEY = 'self_update:last_update';
|
|
const FINISHED_AT_MIN_MS = 1_700_000_000_000;
|
|
const LAST_UPDATE_TTL_MS = 7 * 24 * 60 * 60_000;
|
|
const LAST_UPDATE_TO_VERSION_MAX = 32;
|
|
const LAST_UPDATE_FROM_VERSION_MAX = 32;
|
|
const LAST_UPDATE_DIRECTIVE_ID_MAX = 64;
|
|
const LAST_UPDATE_ERROR_MAX = 1000;
|
|
const STATUS_SET = new Set(['success', 'failed', 'skipped', 'pending']);
|
|
export function readPendingLastUpdate(store, now = Date.now()) {
|
|
const raw = store.getState(LAST_UPDATE_STATE_KEY);
|
|
if (!raw)
|
|
return undefined;
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(raw);
|
|
}
|
|
catch {
|
|
clearPendingLastUpdate(store);
|
|
return undefined;
|
|
}
|
|
const payload = normalizeLastUpdate(parsed);
|
|
if (!payload) {
|
|
clearPendingLastUpdate(store);
|
|
return undefined;
|
|
}
|
|
if (payload.finished_at >= FINISHED_AT_MIN_MS && now - payload.finished_at > LAST_UPDATE_TTL_MS) {
|
|
clearPendingLastUpdate(store);
|
|
return undefined;
|
|
}
|
|
return payload;
|
|
}
|
|
function clearPendingLastUpdate(store) {
|
|
store.setState(LAST_UPDATE_STATE_KEY, '');
|
|
}
|
|
export function clearLastUpdateOnAck(store, sent, now = Date.now()) {
|
|
const current = readPendingLastUpdate(store, now);
|
|
if (!current)
|
|
return false;
|
|
if (!sameIdentity(current, sent))
|
|
return false;
|
|
clearPendingLastUpdate(store);
|
|
return true;
|
|
}
|
|
export function writeLastUpdate(store, payload, now = Date.now()) {
|
|
const normalized = normalizeLastUpdate(payload);
|
|
if (!normalized)
|
|
return false;
|
|
if (normalized.status === 'skipped' || normalized.status === 'pending') {
|
|
const pending = readPendingLastUpdate(store, now);
|
|
if (pending?.status === 'success' || pending?.status === 'failed')
|
|
return false;
|
|
}
|
|
store.setState(LAST_UPDATE_STATE_KEY, JSON.stringify(normalized));
|
|
return true;
|
|
}
|
|
export function shouldClearForLastUpdateAck(ack) {
|
|
if (!ack || typeof ack !== 'object')
|
|
return false;
|
|
return ack.ok === true;
|
|
}
|
|
export function isLastUpdateRelatedError(value) {
|
|
const text = typeof value === 'string' ? value : JSON.stringify(value ?? '');
|
|
return /last[_\-.]?update/i.test(text);
|
|
}
|
|
export function reportSelfUpdateLastUpdate(store, directive, result, opts = { fromVersion: '0.0.0' }) {
|
|
const payload = lastUpdateFromSelfUpdateResult(directive, result, {
|
|
fromVersion: opts.fromVersion,
|
|
now: opts.now ?? Date.now(),
|
|
});
|
|
if (!payload)
|
|
return false;
|
|
return writeLastUpdate(store, payload, opts.now ?? Date.now());
|
|
}
|
|
export function reportPendingSelfUpdateLastUpdate(store, directive, opts = { fromVersion: '0.0.0' }) {
|
|
const now = opts.now ?? Date.now();
|
|
const toVersion = targetVersionForDirective(directive);
|
|
if (!toVersion)
|
|
return false;
|
|
return writeLastUpdate(store, {
|
|
to_version: toVersion,
|
|
status: 'pending',
|
|
finished_at: Math.max(now, FINISHED_AT_MIN_MS),
|
|
from_version: clampString(opts.fromVersion, LAST_UPDATE_FROM_VERSION_MAX),
|
|
...(directive.directive_id ? { directive_id: String(directive.directive_id) } : {}),
|
|
}, now);
|
|
}
|
|
export function finalizeSelfUpdateRecoveryLastUpdate(store, recovery, now = Date.now()) {
|
|
if (recovery.outcome !== 'confirmed'
|
|
&& recovery.outcome !== 'rolled_back'
|
|
&& recovery.outcome !== 'blocked')
|
|
return false;
|
|
const toVersion = concreteVersion(recovery.targetVersion);
|
|
const fromVersion = concreteVersion(recovery.fromVersion);
|
|
if (!toVersion)
|
|
return false;
|
|
const current = readPendingLastUpdate(store, now);
|
|
if (current && current.to_version !== toVersion)
|
|
return false;
|
|
const common = {
|
|
to_version: toVersion,
|
|
finished_at: Math.max(now, FINISHED_AT_MIN_MS),
|
|
...(current?.directive_id ? { directive_id: current.directive_id } : {}),
|
|
...(current?.from_version ? { from_version: current.from_version } : fromVersion ? { from_version: fromVersion } : {}),
|
|
};
|
|
if (recovery.outcome === 'confirmed') {
|
|
return writeLastUpdate(store, {
|
|
...common,
|
|
status: 'success',
|
|
...(current?.applied_via ? { applied_via: current.applied_via } : {}),
|
|
}, now);
|
|
}
|
|
return writeLastUpdate(store, {
|
|
...common,
|
|
status: 'failed',
|
|
error: clampString(hubNs.redactString(`${recovery.failureCode ?? 'self_update_recovery_failed'}: ${recovery.outcome}`), LAST_UPDATE_ERROR_MAX),
|
|
}, now);
|
|
}
|
|
export function lastUpdateFromSelfUpdateResult(directive, result, opts) {
|
|
if (result.outcome === 'already_in_progress' || result.outcome === 'disabled')
|
|
return undefined;
|
|
const toVersion = targetVersionForReport(directive, result);
|
|
if (!toVersion)
|
|
return undefined;
|
|
const base = {
|
|
to_version: toVersion,
|
|
status: statusForResult(result),
|
|
finished_at: Math.max(opts.now, FINISHED_AT_MIN_MS),
|
|
...(directive.directive_id ? { directive_id: String(directive.directive_id) } : {}),
|
|
};
|
|
if (base.status === 'success' || base.status === 'pending') {
|
|
return {
|
|
...base,
|
|
from_version: clampString(opts.fromVersion, LAST_UPDATE_FROM_VERSION_MAX),
|
|
...(result.appliedVia ? { applied_via: result.appliedVia } : {}),
|
|
};
|
|
}
|
|
if (base.status === 'failed') {
|
|
return {
|
|
...base,
|
|
from_version: clampString(opts.fromVersion, LAST_UPDATE_FROM_VERSION_MAX),
|
|
error: clampString(hubNs.redactString(renderFailureError(result.failureCode, result.reason)), LAST_UPDATE_ERROR_MAX),
|
|
};
|
|
}
|
|
return base;
|
|
}
|
|
function statusForResult(result) {
|
|
if (result.outcome === 'applied')
|
|
return result.confirmationPending ? 'pending' : 'success';
|
|
if (result.outcome === 'noop')
|
|
return 'skipped';
|
|
return 'failed';
|
|
}
|
|
function targetVersionForReport(directive, result) {
|
|
const fromResult = reportableVersion(result.targetVersion);
|
|
if (fromResult)
|
|
return fromResult;
|
|
if (result.outcome !== 'applied' && result.outcome !== 'noop')
|
|
return requiredVersionForReport(directive);
|
|
return targetVersionForDirective(directive);
|
|
}
|
|
function targetVersionForDirective(directive) {
|
|
const manifest = directive.manifest;
|
|
if (manifest && typeof manifest === 'object' && !Array.isArray(manifest)) {
|
|
const version = concreteVersion(manifest.version);
|
|
if (version)
|
|
return version;
|
|
}
|
|
return ops.normalizeRequiredVersion(directive.required_version);
|
|
}
|
|
function requiredVersionForReport(directive) {
|
|
const normalized = ops.normalizeRequiredVersion(directive.required_version);
|
|
if (!normalized || normalized.length > LAST_UPDATE_TO_VERSION_MAX)
|
|
return undefined;
|
|
return normalized;
|
|
}
|
|
function normalizeLastUpdate(value) {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
return undefined;
|
|
const input = value;
|
|
const toVersion = concreteVersion(input['to_version']);
|
|
const status = typeof input['status'] === 'string' && STATUS_SET.has(input['status'])
|
|
? input['status']
|
|
: undefined;
|
|
const finishedAt = typeof input['finished_at'] === 'number' && Number.isFinite(input['finished_at'])
|
|
? Math.max(Math.trunc(input['finished_at']), FINISHED_AT_MIN_MS)
|
|
: undefined;
|
|
if (!toVersion || !status || finishedAt === undefined)
|
|
return undefined;
|
|
const appliedVia = input['applied_via'];
|
|
return {
|
|
to_version: toVersion,
|
|
status,
|
|
finished_at: finishedAt,
|
|
...(typeof input['from_version'] === 'string' ? { from_version: clampString(input['from_version'], LAST_UPDATE_FROM_VERSION_MAX) } : {}),
|
|
...(typeof input['directive_id'] === 'string' && input['directive_id'].length > 0
|
|
? { directive_id: clampString(input['directive_id'], LAST_UPDATE_DIRECTIVE_ID_MAX) }
|
|
: {}),
|
|
...(typeof input['error'] === 'string' && input['error'].length > 0
|
|
? { error: clampString(hubNs.redactString(input['error']), LAST_UPDATE_ERROR_MAX) }
|
|
: {}),
|
|
...(appliedVia === 'binary' || appliedVia === 'tarball' ? { applied_via: appliedVia } : {}),
|
|
};
|
|
}
|
|
function sameIdentity(a, b) {
|
|
return a.status === b.status
|
|
&& a.finished_at === b.finished_at
|
|
&& a.to_version === b.to_version
|
|
&& (a.directive_id ?? '') === (b.directive_id ?? '');
|
|
}
|
|
function concreteVersion(value) {
|
|
const normalized = ops.normalizeConcreteVersion(value);
|
|
if (!normalized || normalized.length > LAST_UPDATE_TO_VERSION_MAX)
|
|
return undefined;
|
|
return normalized;
|
|
}
|
|
function reportableVersion(value) {
|
|
const normalized = ops.normalizeRequiredVersion(value) ?? ops.normalizeConcreteVersion(value);
|
|
if (!normalized || normalized.length > LAST_UPDATE_TO_VERSION_MAX)
|
|
return undefined;
|
|
return normalized;
|
|
}
|
|
function clampString(value, max) {
|
|
return value.length <= max ? value : value.slice(0, max);
|
|
} |