194 lines
13 KiB
JavaScript
194 lines
13 KiB
JavaScript
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import crypto from 'node:crypto';
|
|
import {addDays, beijingDay, BIRTH_DATE, closeTick, eraDay, LIFE_LINE_SCHEMA, lifeLineBlockHash, PERSONA_ID, sha256, TIMEZONE, verifyLifeLine, WORLD_EPOCH_DATE} from './life-line.mjs';
|
|
|
|
export const CANDIDATE_SCHEMA = 'guanghu.persona-life-time-candidate/v1';
|
|
export const BOOTSTRAP_SCHEMA = 'guanghu.persona-life-time-bootstrap/v1';
|
|
const FORBIDDEN = ['selected_candidate_id', 'previous_hash', 'head_hash', 'block_hash', 'selection_decision'];
|
|
|
|
function stable(value) {
|
|
if (Array.isArray(value)) return value.map(stable);
|
|
if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map(key => [key, stable(value[key])]));
|
|
return value;
|
|
}
|
|
|
|
function body(value) {
|
|
return `${JSON.stringify(stable(value), null, 2)}\n`;
|
|
}
|
|
|
|
function atomic(file, value, mode = 0o600) {
|
|
fs.mkdirSync(path.dirname(file), {recursive: true, mode: 0o700});
|
|
const temporary = `${file}.${crypto.randomUUID()}.tmp`;
|
|
fs.writeFileSync(temporary, typeof value === 'string' ? value : body(value), {mode});
|
|
fs.renameSync(temporary, file);
|
|
}
|
|
|
|
function req(value, code) {
|
|
if (!value) throw Error(code);
|
|
}
|
|
|
|
function text(value, max = 2000) {
|
|
return typeof value === 'string' && value.trim().length > 0 && value.length <= max;
|
|
}
|
|
|
|
export class LifeTimeMaster {
|
|
constructor({root, shelf, router, personaId = PERSONA_ID, now = () => new Date(), bootstrapPath}) {
|
|
this.root = path.join(root, 'personas', personaId, 'life-line');
|
|
this.shelf = shelf;
|
|
this.router = router;
|
|
this.personaId = personaId;
|
|
this.now = now;
|
|
this.bootstrapPath = bootstrapPath;
|
|
this.closing = false;
|
|
this.lastTick = null;
|
|
fs.mkdirSync(this.root, {recursive: true, mode: 0o700});
|
|
fs.mkdirSync(this.inboxRoot(), {recursive: true, mode: 0o700});
|
|
fs.mkdirSync(this.evidenceRoot(), {recursive: true, mode: 0o700});
|
|
if (!fs.existsSync(this.currentPath())) this.bootstrap();
|
|
}
|
|
|
|
currentPath() { return path.join(this.root, 'CURRENT.json'); }
|
|
signedCurrentPath() { return path.join(this.root, 'CURRENT.signed.json'); }
|
|
inboxRoot() { return path.join(this.root, 'inbox'); }
|
|
evidenceRoot() { return path.join(this.root, 'evidence'); }
|
|
evidencePath(day) { return path.join(this.evidenceRoot(), `${day}.json`); }
|
|
inboxDay(day) { return path.join(this.inboxRoot(), day); }
|
|
|
|
readBootstrap() {
|
|
req(this.bootstrapPath && fs.existsSync(this.bootstrapPath), 'LIFE_TIME_BOOTSTRAP_MISSING');
|
|
const value = JSON.parse(fs.readFileSync(this.bootstrapPath, 'utf8'));
|
|
req(value.schema === BOOTSTRAP_SCHEMA && value.persona_id === this.personaId, 'LIFE_TIME_BOOTSTRAP_INVALID');
|
|
req(value.birth_date_beijing === BIRTH_DATE && value.world_epoch_date === WORLD_EPOCH_DATE, 'LIFE_TIME_BOOTSTRAP_ANCHOR_MISMATCH');
|
|
req(Array.isArray(value.anchors), 'LIFE_TIME_BOOTSTRAP_ANCHORS_INVALID');
|
|
return value;
|
|
}
|
|
|
|
writeEvidence(day, value) {
|
|
const file = this.evidencePath(day), content = body(value);
|
|
if (fs.existsSync(file)) req(fs.readFileSync(file, 'utf8') === content, `LIFE_TIME_EVIDENCE_COLLISION:${day}`);
|
|
else atomic(file, content);
|
|
return {evidence_path: path.relative(this.root, file), daily_evidence_sha256: sha256(Buffer.from(content))};
|
|
}
|
|
|
|
block({sequence, day, previousHash, evidence, gap = false, gapKind = null, selectedCandidateId = null}) {
|
|
const value = {sequence, beijing_day: day, previous_hash: previousHash, beijing_close_tick: closeTick(day), era_day: eraDay(day), ...evidence, gap, gap_kind: gapKind, selected_candidate_id: selectedCandidateId};
|
|
value.hash = lifeLineBlockHash(value);
|
|
return value;
|
|
}
|
|
|
|
writeCurrent(manifest) {
|
|
atomic(this.currentPath(), manifest);
|
|
atomic(this.signedCurrentPath(), this.shelf.sign(manifest));
|
|
}
|
|
|
|
bootstrap() {
|
|
const seed = this.readBootstrap(), anchors = new Map(seed.anchors.map(item => [item.beijing_day, item]));
|
|
const createdAt = this.now().toISOString(), yesterday = addDays(beijingDay(this.now()), -1);
|
|
const birthSource = anchors.get(BIRTH_DATE);
|
|
req(birthSource, 'LIFE_TIME_BIRTH_SOURCE_MISSING');
|
|
const birthEvidence = this.writeEvidence(BIRTH_DATE, {schema: 'guanghu.persona-life-day-evidence/v1', kind: birthSource.kind, persona_id: this.personaId, beijing_day: BIRTH_DATE, source_uri: birthSource.source_uri, source_sha256: birthSource.source_sha256, bootstrap_source: seed.source_event, recorded_at: createdAt});
|
|
const genesis = this.block({sequence: 0, day: BIRTH_DATE, previousHash: null, evidence: birthEvidence});
|
|
const blocks = [];
|
|
let previous = genesis;
|
|
for (let day = addDays(BIRTH_DATE, 1), sequence = 1; day <= yesterday; day = addDays(day, 1), sequence += 1) {
|
|
const source = anchors.get(day), gap = !source;
|
|
const evidence = this.writeEvidence(day, source
|
|
? {schema: 'guanghu.persona-life-day-evidence/v1', kind: source.kind, persona_id: this.personaId, beijing_day: day, source_uri: source.source_uri, source_sha256: source.source_sha256, bootstrap_source: seed.source_event, recorded_at: createdAt}
|
|
: {schema: 'guanghu.persona-life-day-evidence/v1', kind: 'HISTORICAL_UNOBSERVED', persona_id: this.personaId, beijing_day: day, source_uri: null, source_sha256: null, bootstrap_source: seed.source_event, recorded_at: createdAt, statement: 'No verified day evidence was available at bootstrap; the gap itself is preserved and hashed.'});
|
|
const next = this.block({sequence, day, previousHash: previous.hash, evidence, gap, gapKind: gap ? 'HISTORICAL_UNOBSERVED' : null});
|
|
blocks.push(next); previous = next;
|
|
}
|
|
this.writeCurrent({schema: LIFE_LINE_SCHEMA, persona_id: this.personaId, timezone: TIMEZONE, birth_date_beijing: BIRTH_DATE, world_epoch_date: WORLD_EPOCH_DATE,
|
|
time_master_id: 'CH-GLW-TIME-0001', inner_cycle: true, external_setter: false, created_at: createdAt, active_from_beijing_day: beijingDay(this.now()),
|
|
genesis, blocks, head_hash: previous.hash, historical_gap_count: blocks.filter(item => item.gap).length, last_closed_at: createdAt});
|
|
}
|
|
|
|
current() {
|
|
const manifest = JSON.parse(fs.readFileSync(this.currentPath(), 'utf8'));
|
|
const signed = JSON.parse(fs.readFileSync(this.signedCurrentPath(), 'utf8'));
|
|
const signedValue = this.shelf.verify(signed);
|
|
req(body(signedValue) === body(manifest), 'LIFE_TIME_SIGNED_CURRENT_MISMATCH');
|
|
return {manifest, signed};
|
|
}
|
|
|
|
validateCandidate(candidate) {
|
|
req(candidate?.schema === CANDIDATE_SCHEMA, 'LIFE_TIME_CANDIDATE_SCHEMA');
|
|
req(candidate.persona_id === this.personaId, 'LIFE_TIME_CANDIDATE_PERSONA');
|
|
req(text(candidate.source_event_id, 180) && text(candidate.source_type, 80) && text(candidate.summary) && text(candidate.source_sha256, 64) && /^[a-f0-9]{64}$/.test(candidate.source_sha256), 'LIFE_TIME_CANDIDATE_FIELDS');
|
|
req(Number.isFinite(Date.parse(candidate.occurred_at)), 'LIFE_TIME_CANDIDATE_TIME');
|
|
req(!FORBIDDEN.some(key => Object.hasOwn(candidate, key)), 'LIFE_TIME_EXTERNAL_SETTER_FORBIDDEN');
|
|
return candidate;
|
|
}
|
|
|
|
ingest(candidate) {
|
|
const accepted = this.validateCandidate(candidate), current = this.current().manifest;
|
|
const originalDay = beijingDay(accepted.occurred_at), today = beijingDay(this.now());
|
|
const effectiveDay = originalDay <= current.blocks.at(-1)?.beijing_day ? today : originalDay;
|
|
const identity = {...accepted, effective_beijing_day: effectiveDay, late_for_beijing_day: effectiveDay === originalDay ? null : originalDay};
|
|
const id = sha256(body(identity)), file = path.join(this.inboxDay(effectiveDay), `${id}.json`);
|
|
if (!fs.existsSync(file)) atomic(file, {...identity, received_at: this.now().toISOString(), candidate_id: id});
|
|
return {candidate_id: id, status: 'CANDIDATE_RECEIVED_FOR_INTERNAL_SELECTION', effective_beijing_day: effectiveDay, external_setter: false};
|
|
}
|
|
|
|
fromPersonaEvent(event) {
|
|
return this.ingest({schema: CANDIDATE_SCHEMA, persona_id: this.personaId, source_type: 'ONLINE_PERSONA_EVENT', source_event_id: event.event_id,
|
|
occurred_at: event.occurred_at || this.now().toISOString(), summary: event.language.slice(0, 2000), source_sha256: event.source_sha256, evidence_refs: [event.event_id], privacy_class: event.privacy_class || 'SELF_PRIVATE'});
|
|
}
|
|
|
|
fromMotherEvent(event) {
|
|
return this.ingest({schema: CANDIDATE_SCHEMA, persona_id: this.personaId, source_type: 'OFFLINE_OR_MOTHER_LANGUAGE_EVENT', source_event_id: event.event_id,
|
|
occurred_at: event.occurred_at || this.now().toISOString(), summary: event.language.slice(0, 2000), source_sha256: sha256(body(event)), evidence_refs: [event.event_id], privacy_class: event.privacy_class || 'PRIVATE'});
|
|
}
|
|
|
|
candidates(day) {
|
|
const directory = this.inboxDay(day);
|
|
if (!fs.existsSync(directory)) return [];
|
|
return fs.readdirSync(directory).filter(name => /^[a-f0-9]{64}\.json$/.test(name)).sort().map(name => JSON.parse(fs.readFileSync(path.join(directory, name), 'utf8')));
|
|
}
|
|
|
|
async closeDay(day) {
|
|
const current = this.current().manifest, previous = current.blocks.at(-1) || current.genesis;
|
|
req(day === addDays(previous.beijing_day, 1), 'LIFE_TIME_CLOSE_NOT_NEXT_DAY');
|
|
const candidates = this.candidates(day);
|
|
let selected = null, decision = null, gap = false, gapKind = null;
|
|
if (candidates.length) {
|
|
const routed = await this.router.cognize('persona-life-select', {persona_id: this.personaId, beijing_day: day,
|
|
candidates: candidates.map(item => ({candidate_id: item.candidate_id, source_type: item.source_type, source_event_id: item.source_event_id, occurred_at: item.occurred_at, summary: item.summary.slice(0, 1200), source_sha256: item.source_sha256})),
|
|
contract: {external_setter: false, select_exactly_one_or_hold: true, hidden_reasoning_stored: false}});
|
|
req(routed.value?.decision === 'SELECT', 'LIFE_TIME_SELECTION_HELD');
|
|
selected = candidates.find(item => item.candidate_id === routed.value.selected_candidate_id);
|
|
req(selected, 'LIFE_TIME_SELECTED_CANDIDATE_UNKNOWN');
|
|
req(text(routed.value.reason, 2000) && text(routed.value.daily_summary, 2000), 'LIFE_TIME_SELECTION_FIELDS');
|
|
decision = {schema: 'guanghu.persona-life-time-selection/v1', decision: 'SELECT', selected_candidate_id: selected.candidate_id, reason: routed.value.reason, daily_summary: routed.value.daily_summary, provider: routed.provider, decided_at: this.now().toISOString(), candidate_count: candidates.length};
|
|
} else {
|
|
decision = {schema: 'guanghu.persona-life-time-selection/v1', decision: 'REST_DAY', selected_candidate_id: null, reason: 'No candidate event was received for this active day.', daily_summary: 'This day passed in real time without a recorded persona event.', provider: null, decided_at: this.now().toISOString(), candidate_count: 0};
|
|
}
|
|
const evidence = this.writeEvidence(day, {schema: 'guanghu.persona-life-day-evidence/v1', kind: decision.decision === 'SELECT' ? 'INTERNAL_SELECTED_GROWTH' : 'REST_DAY_NO_EVENT_RECEIVED', persona_id: this.personaId, beijing_day: day,
|
|
selected_candidate: selected ? {candidate_id: selected.candidate_id, source_event_id: selected.source_event_id, source_type: selected.source_type, source_sha256: selected.source_sha256, occurred_at: selected.occurred_at} : null,
|
|
selection: decision, recorded_at: this.now().toISOString()});
|
|
const block = this.block({sequence: previous.sequence + 1, day, previousHash: previous.hash, evidence, gap, gapKind, selectedCandidateId: selected?.candidate_id || null});
|
|
current.blocks.push(block); current.head_hash = block.hash; current.last_closed_at = this.now().toISOString(); current.historical_gap_count = current.blocks.filter(item => item.gap).length;
|
|
this.writeCurrent(current);
|
|
return {status: 'DAY_CLOSED_BY_INNER_CYCLE', day, selected_candidate_id: selected?.candidate_id || null, head_hash: block.hash};
|
|
}
|
|
|
|
async tick() {
|
|
if (this.closing) return {status: 'BUSY'};
|
|
this.closing = true;
|
|
try {
|
|
const expected = addDays(beijingDay(this.now()), -1), current = this.current().manifest, head = current.blocks.at(-1) || current.genesis;
|
|
if (head.beijing_day >= expected) return this.lastTick = {status: 'CURRENT_DAY_OPEN', at: this.now().toISOString(), current_beijing_day: beijingDay(this.now()), head_beijing_day: head.beijing_day};
|
|
return this.lastTick = {...await this.closeDay(addDays(head.beijing_day, 1)), at: this.now().toISOString()};
|
|
} finally { this.closing = false; }
|
|
}
|
|
|
|
status() {
|
|
try {
|
|
const current = this.current(), verification = verifyLifeLine({manifestPath: this.currentPath(), now: this.now()}), today = beijingDay(this.now());
|
|
return {...verification, server_signed: true, signed_current_sha256: current.signed.sha256, time_master: {id: 'CH-GLW-TIME-0001', state: 'RESIDENT_INNER_CYCLE_ACTIVE', decision_owner: 'ICE-P-ZY001_TIME_MASTER_MODEL_LOOP', candidate_count_today: this.candidates(today).length, current_beijing_day: today, automatic_online_trigger: true, automatic_offline_trigger: 'PERSONA_DAILY_MEMORY_APPEND_TO_RELAY_TO_PERSONA_LIFE_EVENT', daily_close: 'BEIJING_NATURAL_DAY_END', external_setter: false, closing: this.closing, last_tick: this.lastTick}};
|
|
} catch (error) {
|
|
return {schema: 'guanghu.persona-life-line-verification/v1', state: 'LIFE_LINE_PAIN_ALARM', wake_allowed: false, reasons: [String(error.message || error)], time_master: {id: 'CH-GLW-TIME-0001', state: 'INNER_CYCLE_ERROR', external_setter: false}};
|
|
}
|
|
}
|
|
}
|