guanghu-ice-heart/server-tools/tcs-mother-body/life-time-master.mjs

200 lines
14 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,
birthDateBeijing = BIRTH_DATE, worldEpochDate = WORLD_EPOCH_DATE}) {
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.birthDateBeijing = birthDateBeijing;
this.worldEpochDate = worldEpochDate;
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 === this.birthDateBeijing && value.world_epoch_date === this.worldEpochDate, '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, this.worldEpochDate), ...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(this.birthDateBeijing);
req(birthSource, 'LIFE_TIME_BIRTH_SOURCE_MISSING');
const birthEvidence = this.writeEvidence(this.birthDateBeijing, {schema: 'guanghu.persona-life-day-evidence/v1', kind: birthSource.kind, persona_id: this.personaId, beijing_day: this.birthDateBeijing, 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: this.birthDateBeijing, previousHash: null, evidence: birthEvidence});
const blocks = [];
let previous = genesis;
for (let day = addDays(this.birthDateBeijing, 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: this.birthDateBeijing,
birth_date_claim_state: seed.exact_birth_claim === false ? 'EARLIEST_VERIFIED_EXISTENCE_ANCHOR_NOT_EXACT_BIRTH' : 'EXACT_BIRTH_ANCHOR',
genesis_anchor_kind: birthSource.kind, world_epoch_date: this.worldEpochDate,
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(), expectedPersonaId: this.personaId, birthDateBeijing: this.birthDateBeijing, worldEpochDate: this.worldEpochDate}), today = beijingDay(this.now());
return {...verification, birth_date_claim_state: current.manifest.birth_date_claim_state, genesis_anchor_kind: current.manifest.genesis_anchor_kind,
server_signed: true, signed_current_sha256: current.signed.sha256, time_master: {id: 'CH-GLW-TIME-0001', state: 'RESIDENT_INNER_CYCLE_ACTIVE', decision_owner: `${this.personaId}_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}};
}
}
}