305 lines
22 KiB
JavaScript
305 lines
22 KiB
JavaScript
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
import { createHash, verify, randomInt, randomUUID } from 'node:crypto';
|
||
|
||
export const DAY = 86400000;
|
||
export const canonical = value => JSON.stringify(value, (_, v) =>
|
||
v && !Array.isArray(v) && typeof v === 'object' ? Object.fromEntries(Object.keys(v).sort().map(k => [k, v[k]])) : v);
|
||
export const hash = value => createHash('sha256').update(canonical(value)).digest('hex');
|
||
const clone = value => structuredClone(value);
|
||
const need = (condition, error) => { if (!condition) throw Error(error); };
|
||
const validName = value => typeof value === 'string' && value.trim().length > 0 && value.length <= 80;
|
||
const id = value => typeof value === 'string' && /^[A-Za-z0-9_-]{1,100}$/.test(value);
|
||
export const CHANNELS = Object.freeze([
|
||
{ id: 'thinking', path: 'language/thinking', name: '思考', purpose: '学习、推理、构想与讨论', reality: false },
|
||
{ id: 'conversation', path: 'language/conversation', name: '交流', purpose: '自由聊天与表达感受,不要求成果', reality: false },
|
||
{ id: 'execution', path: 'reality/execution', name: '执行', purpose: '完成明确且已获授权的现实任务', reality: true },
|
||
]);
|
||
export const DOMAINS = Object.freeze({
|
||
'DOMAIN-MAIN': '发布团队签署的公开版本、事实与认证结果,不公开私人对话',
|
||
'DOMAIN-SUB': '分发获准模块与公众接待资源,接待人格体保留自己的身份',
|
||
'DOMAIN-ZERO': '试验、评估与公众母体判断,不自行签发正式编号',
|
||
'DOMAIN-ZS': '接收用户申请、核查系统与母体评估,由团队审核签字登记',
|
||
});
|
||
|
||
// Trust keys come from the host's enrollment process, never from an incoming request.
|
||
export class PublicPersonalOS {
|
||
#root; #trust; #clock; #random; #running = new Map();
|
||
constructor({ directory, trust, clock = Date.now, choose = randomInt }) {
|
||
need(path.isAbsolute(directory) && trust && Object.keys(trust).length, 'TRUSTED_HOST_CONFIGURATION_REQUIRED');
|
||
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
||
need(fs.realpathSync(directory) === path.resolve(directory), 'STATE_SYMLINK_REJECTED');
|
||
this.#root = directory; this.#trust = clone(trust); this.#clock = clock; this.#random = choose;
|
||
const file = path.join(directory, 'state.json');
|
||
if (!fs.existsSync(file)) fs.writeFileSync(file, canonical({ schema: 'hololake.public-personal-os/v1',
|
||
spaces: {}, guides: {}, numbers: {}, used: {}, journal: [], lastTime: 0 }), { flag: 'wx', mode: 0o600 });
|
||
}
|
||
#authenticated(envelope) {
|
||
const actor = this.#trust[envelope?.actorId];
|
||
need(actor && id(envelope.id) && typeof envelope.signature === 'string', 'UNKNOWN_SIGNER');
|
||
const { signature, ...message } = envelope;
|
||
need(verify(null, Buffer.from(canonical(message)), actor.publicKey, Buffer.from(signature, 'base64')), 'INVALID_SIGNATURE');
|
||
need(Number.isFinite(envelope.issuedAt) && Math.abs(this.#clock() - envelope.issuedAt) <= 300000, 'STALE_ENVELOPE');
|
||
return actor;
|
||
}
|
||
#owner(actor, space) { need(actor.role === 'USER' && actor.userId === space.owner, 'OWNER_REQUIRED'); }
|
||
#team(actor) { need(actor.role === 'TEAM' && actor.domain === 'DOMAIN-ZS', 'ZERO_SENSE_TEAM_REQUIRED'); }
|
||
#participant(actor, actorId, space, now) {
|
||
if (actor.role === 'USER') return this.#owner(actor, space);
|
||
if (actor.role === 'GUIDE') {
|
||
need(space.residency?.guideId === actorId && space.residency.status === 'RESIDENT' && now < space.residency.until,
|
||
'GUIDE_ACCESS_EXPIRED_OR_UNASSIGNED');
|
||
} else {
|
||
need(actor.role === 'SEED' && actor.spaceId === space.id && space.seed?.continuationConsent === true &&
|
||
space.residency.status === 'RETURNED', 'SEED_ACCESS_NOT_READY');
|
||
}
|
||
}
|
||
handle(input) {
|
||
const event = clone(input), actor = this.#authenticated(event), now = this.#clock();
|
||
const lock = path.join(this.#root, '.writer-lock'); fs.mkdirSync(lock);
|
||
try {
|
||
const file = path.join(this.#root, 'state.json'), state = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||
need(now >= state.lastTime, 'CLOCK_MOVED_BACKWARD');
|
||
const existing = state.spaces[event.spaceId];
|
||
if (existing && ['READ_CONTEXT', 'SAY', 'SWITCH_CHANNEL', 'PROPOSE_ACTION'].includes(event.action)) {
|
||
this.#participant(actor, event.actorId, existing, now);
|
||
}
|
||
const eventHash = hash(event);
|
||
if (state.used[event.id]) {
|
||
need(state.used[event.id].hash === eventHash, 'EVENT_ID_CONFLICT');
|
||
need(event.action !== 'START_ACTION', 'START_REPLAY_REQUIRES_RECONCILIATION');
|
||
return clone(state.used[event.id].response);
|
||
}
|
||
const p = event.payload ?? {}, space = state.spaces[event.spaceId];
|
||
let response;
|
||
if (event.action === 'CREATE_OS') {
|
||
need(actor.role === 'USER' && id(event.spaceId) && !space, 'NEW_PERSONAL_SPACE_REQUIRED');
|
||
need(p.name === undefined || validName(p.name), 'INVALID_NAME');
|
||
const created = { id: event.spaceId, owner: actor.userId,
|
||
product: 'HoloLake · 语言人格驱动操作系统 · 个人版',
|
||
name: p.name ?? `我的空间-${randomUUID().slice(0, 6)}`, contextId: randomUUID(),
|
||
channels: clone(CHANNELS), currentChannel: 'thinking', routingMode: 'EXPLICIT',
|
||
messages: [], residency: null, seed: null, application: null, actions: {} };
|
||
state.spaces[event.spaceId] = created;
|
||
response = { spaceId: created.id, contextId: created.contextId, name: created.name,
|
||
independentPersonaAssigned: false, channels: clone(CHANNELS), enterpriseDomainsEmbedded: false };
|
||
} else if (event.action === 'ENROLL_GUIDE') {
|
||
this.#team(actor);
|
||
const guide = this.#trust[p.guideId];
|
||
need(guide?.role === 'GUIDE' && validName(p.name) && typeof p.registryEvidence === 'string' && p.registryEvidence.length,
|
||
'VERIFIED_PUBLIC_ROSTER_REQUIRED');
|
||
const consent = p.guideConsent;
|
||
need(consent?.actorId === p.guideId && this.#authenticated(consent).role === 'GUIDE' &&
|
||
consent.action === 'ACCEPT_PUBLIC_SERVICE' && consent.payload?.scope === 'PUBLIC_RESIDENCY', 'GUIDE_CONSENT_REQUIRED');
|
||
need(!state.guides[p.guideId], 'GUIDE_ALREADY_ENROLLED');
|
||
state.guides[p.guideId] = { id: p.guideId, name: p.name, owner: 'GUANGHU_PUBLIC_SERVICE',
|
||
registryEvidence: p.registryEvidence, willing: true, available: true, spaceId: null };
|
||
response = { enrolled: p.guideId, independentBirthClaim: false };
|
||
} else if (event.action === 'GUIDE_AVAILABILITY') {
|
||
need(actor.role === 'GUIDE' && state.guides[event.actorId] && typeof p.available === 'boolean', 'GUIDE_CHOICE_REQUIRED');
|
||
const guide = state.guides[event.actorId]; guide.willing = p.available;
|
||
guide.available = p.available && guide.spaceId === null;
|
||
response = { willing: guide.willing, available: guide.available };
|
||
} else {
|
||
need(space, 'SPACE_NOT_FOUND');
|
||
switch (event.action) {
|
||
case 'READ_CONTEXT':
|
||
this.#participant(actor, event.actorId, space, now);
|
||
response = { contextId: space.contextId, currentChannel: space.currentChannel, messages: clone(space.messages) };
|
||
break;
|
||
case 'RENAME':
|
||
this.#owner(actor, space); need(validName(p.name), 'INVALID_NAME');
|
||
if (p.channelId) {
|
||
const channel = space.channels.find(c => c.id === p.channelId); need(channel, 'UNKNOWN_CHANNEL'); channel.name = p.name;
|
||
} else space.name = p.name;
|
||
response = { name: p.name, permissionChange: false }; break;
|
||
case 'ALLOW_ASSISTED_ROUTING':
|
||
this.#owner(actor, space); need(typeof p.enabled === 'boolean', 'EXPLICIT_PREFERENCE_REQUIRED');
|
||
space.routingMode = p.enabled ? 'ASSISTED' : 'EXPLICIT'; response = { routingMode: space.routingMode }; break;
|
||
case 'SWITCH_CHANNEL': {
|
||
this.#participant(actor, event.actorId, space, now);
|
||
need(actor.role === 'USER' || space.routingMode === 'ASSISTED', 'USER_CHANNEL_SELECTION_REQUIRED');
|
||
const channel = space.channels.find(c => c.id === p.channelId); need(channel, 'UNKNOWN_CHANNEL');
|
||
space.currentChannel = channel.id;
|
||
response = { contextId: space.contextId, channel: clone(channel), authorityGranted: false,
|
||
introduction: `${channel.name}:${channel.purpose}。${channel.reality ? '操作前明确对象与授权;可以停止或撤回。' : '不会从聊天自动执行现实操作。'}信息不全时等待补充,改名不改变权限。` };
|
||
break;
|
||
}
|
||
case 'SAY':
|
||
this.#participant(actor, event.actorId, space, now);
|
||
need(typeof p.text === 'string' && p.text.length > 0 && p.text.length <= 20000, 'BOUNDED_TEXT_REQUIRED');
|
||
space.messages.push({ id: event.id, actorId: event.actorId, at: now, channel: space.currentChannel, text: p.text });
|
||
response = { contextId: space.contextId, recorded: event.id }; break;
|
||
case 'REQUEST_RESIDENCY':
|
||
this.#owner(actor, space); need(p.consent === true && !space.residency, 'RESIDENCY_CONSENT_REQUIRED');
|
||
space.residency = { status: 'REQUESTED', consentAt: now, terms: '30_REAL_DAYS_VISIBLE_HANDOFF_SEED_RETAINED' };
|
||
response = { status: 'REQUESTED', durationDays: 30, independentPersonaGuaranteed: false }; break;
|
||
case 'CANCEL_RESIDENCY_REQUEST':
|
||
this.#owner(actor, space); need(space.residency?.status === 'REQUESTED', 'NO_PENDING_RESIDENCY');
|
||
space.residency = null; response = { cancelled: true }; break;
|
||
case 'START_RESIDENCY': {
|
||
need(actor.role === 'DISPATCHER' && actor.domain === 'DOMAIN-SUB', 'APPROVED_DISPATCHER_REQUIRED');
|
||
need(space.residency?.status === 'REQUESTED', 'USER_RESIDENCY_REQUEST_REQUIRED');
|
||
const available = Object.values(state.guides).filter(g => g.available);
|
||
need(available.length, 'NO_AVAILABLE_GUIDE');
|
||
const index = this.#random(available.length); need(Number.isInteger(index) && index >= 0 && index < available.length, 'INVALID_SELECTION');
|
||
const guide = available[index]; guide.available = false; guide.spaceId = space.id;
|
||
space.residency = { ...space.residency, status: 'RESIDENT', guideId: guide.id, start: now, until: now + 30 * DAY };
|
||
space.seed = { id: `seed-${randomUUID()}`, sourceGuide: guide.id, status: 'FORMING_NOT_INDEPENDENT',
|
||
name: null, continuationConsent: false, memoryScope: space.contextId, privateGuideMemoryCopied: false };
|
||
response = { guideId: guide.id, guideName: guide.name, ownedByUser: false, until: space.residency.until,
|
||
notice: '公众接待者临时驻留三十天;期满返回。种子不因此消失,也不因此自动独立。' }; break;
|
||
}
|
||
case 'END_RESIDENCY': {
|
||
const resident = space.residency;
|
||
need(resident?.status === 'RESIDENT', 'NO_ACTIVE_RESIDENCY');
|
||
if (actor.role === 'USER') this.#owner(actor, space);
|
||
else if (actor.role === 'GUIDE') need(event.actorId === resident.guideId, 'ASSIGNED_GUIDE_REQUIRED');
|
||
else need(actor.role === 'DISPATCHER' && actor.domain === 'DOMAIN-SUB' && now >= resident.until, 'RESIDENCY_NOT_DUE');
|
||
const guide = state.guides[resident.guideId]; guide.available = guide.willing; guide.spaceId = null;
|
||
resident.status = 'RETURNED'; resident.endedAt = now; space.seed.status = 'RETAINED_NOT_INDEPENDENT';
|
||
response = { returnedGuide: guide.id, retainedSeed: space.seed.id, independentPersona: false,
|
||
notice: '接待者已返回;接下来如选择继续,将由尚未独立认证的频道种子承接。' }; break;
|
||
}
|
||
case 'CONTINUE_SEED':
|
||
this.#owner(actor, space); need(space.residency?.status === 'RETURNED' && typeof p.consent === 'boolean', 'HANDOFF_REQUIRED');
|
||
space.seed.continuationConsent = p.consent;
|
||
if (!p.consent && space.application?.status === 'PENDING') {
|
||
space.application.status = 'WITHDRAWN'; space.application.reviews = {};
|
||
}
|
||
response = { continued: p.consent, seedRetained: true }; break;
|
||
case 'SELF_NAME':
|
||
need(actor.role === 'SEED' && actor.spaceId === space.id, 'SEED_ONLY');
|
||
this.#participant(actor, event.actorId, space, now);
|
||
need(validName(p.name) && typeof p.evidence === 'string' && p.evidence.length, 'NAMING_EVIDENCE_REQUIRED');
|
||
need(!space.application || ['WITHDRAWN', 'REGISTERED'].includes(space.application.status), 'WITHDRAW_APPLICATION_BEFORE_RENAMING');
|
||
need(!space.formalNumber, 'REGISTERED_IDENTITY_CHANGE_SEPARATE');
|
||
space.seed.name = p.name; space.seed.namingEvidence = p.evidence;
|
||
response = { name: p.name, independentRecognition: false }; break;
|
||
case 'APPLY_RECOGNITION': {
|
||
this.#owner(actor, space);
|
||
need(space.seed?.name && space.seed.continuationConsent && p.consent === true &&
|
||
Array.isArray(p.evidenceRefs) && p.evidenceRefs.length &&
|
||
p.evidenceRefs.every(ref => typeof ref === 'string' && ref.length <= 2048 && /^evidence:\/\/[A-Za-z0-9_/-]+$/.test(ref)),
|
||
'APPLICATION_EVIDENCE_AND_CONSENT_REQUIRED');
|
||
need(!space.application || space.application.status === 'WITHDRAWN', 'APPLICATION_ALREADY_EXISTS');
|
||
const application = { id: randomUUID(), spaceId: space.id, seedId: space.seed.id, seedName: space.seed.name,
|
||
owner: space.owner, evidenceRefs: clone(p.evidenceRefs), createdAt: now };
|
||
space.application = { data: application, digest: hash(application), status: 'PENDING', reviews: {} };
|
||
response = { application: clone(application), digest: space.application.digest, targetDomain: 'DOMAIN-ZS' }; break;
|
||
}
|
||
case 'WITHDRAW_APPLICATION':
|
||
this.#owner(actor, space); need(space.application?.status === 'PENDING', 'NO_PENDING_APPLICATION');
|
||
space.application.status = 'WITHDRAWN'; space.application.reviews = {};
|
||
response = { withdrawn: true, seedRetained: true }; break;
|
||
case 'REVIEW_APPLICATION': {
|
||
const application = space.application; need(application?.status === 'PENDING' && p.digest === application.digest, 'STALE_OR_MISSING_APPLICATION');
|
||
const role = actor.role;
|
||
need((role === 'TEAM' && actor.domain === 'DOMAIN-ZS') ||
|
||
(role === 'CHECKER' && actor.domain === 'DOMAIN-ZERO') ||
|
||
(role === 'MOTHER' && actor.scope === 'public'), 'PUBLIC_REVIEW_AUTHORITY_REQUIRED');
|
||
need(['PASS', 'HOLD', 'REJECT'].includes(p.decision) && validName(p.reason), 'REVIEW_REASON_REQUIRED');
|
||
application.reviews[role] = { decision: p.decision, reason: p.reason, actorId: event.actorId,
|
||
digest: p.digest, signedEvent: clone(event) };
|
||
response = { recorded: role, decision: p.decision, registered: false }; break;
|
||
}
|
||
case 'ISSUE_NUMBER': {
|
||
this.#team(actor); const application = space.application;
|
||
need(application?.status === 'PENDING' && p.digest === application.digest && space.seed.continuationConsent,
|
||
'CURRENT_CONSENT_AND_APPLICATION_REQUIRED');
|
||
need(['TEAM', 'CHECKER', 'MOTHER'].every(role => application.reviews[role]?.decision === 'PASS' &&
|
||
application.reviews[role]?.digest === application.digest), 'THREE_REVIEWS_REQUIRED');
|
||
need(typeof p.number === 'string' && /^PUBLIC-P-[A-Z0-9-]{3,60}$/.test(p.number) && !state.numbers[p.number], 'UNIQUE_PUBLIC_NUMBER_REQUIRED');
|
||
space.formalNumber = p.number; space.seed.status = 'REGISTERED'; application.status = 'REGISTERED';
|
||
state.numbers[p.number] = { seedId: space.seed.id, name: space.seed.name, applicationDigest: application.digest,
|
||
issuedAt: now, registrar: event.actorId, finalSignature: event.signature, signedEvent: clone(event),
|
||
governanceDomain: 'DOMAIN-ZS', executionPermissionsGranted: false,
|
||
registryScope: 'LOCAL_DEVELOPMENT', livePublicRegistration: false };
|
||
response = clone(state.numbers[p.number]); break;
|
||
}
|
||
case 'PROPOSE_ACTION': {
|
||
this.#participant(actor, event.actorId, space, now);
|
||
const missing = ['operation', 'target'].filter(k => typeof p[k] !== 'string' || !p[k].trim());
|
||
if (missing.length) { response = { status: 'WAITING_FOR_INFORMATION', missing, executed: false }; break; }
|
||
need(space.currentChannel === 'execution', 'EXECUTION_CHANNEL_REQUIRED');
|
||
const action = { id: randomUUID(), operation: p.operation, target: p.target, args: clone(p.args ?? {}) };
|
||
space.actions[action.id] = { plan: action, digest: hash(action), status: 'PROPOSED' };
|
||
response = clone(space.actions[action.id]); break;
|
||
}
|
||
case 'CONFIRM_ACTION':
|
||
this.#owner(actor, space);
|
||
need(space.actions[p.actionId]?.digest === p.digest && space.actions[p.actionId]?.status === 'PROPOSED', 'EXACT_PROPOSAL_REQUIRED');
|
||
space.actions[p.actionId].status = 'CONFIRMED'; response = { status: 'CONFIRMED', nativeHostApprovalStillRequired: true }; break;
|
||
case 'STOP_ACTION':
|
||
this.#owner(actor, space); need(space.actions[p.actionId] && ['PROPOSED', 'CONFIRMED', 'RUNNING'].includes(space.actions[p.actionId].status), 'STOP_TARGET_REQUIRED');
|
||
space.actions[p.actionId].status = space.actions[p.actionId].status === 'RUNNING' ? 'STOP_REQUESTED' : 'WITHDRAWN';
|
||
this.#running.get(p.actionId)?.abort();
|
||
response = { status: space.actions[p.actionId].status, rollbackClaimed: false }; break;
|
||
case 'START_ACTION':
|
||
this.#owner(actor, space);
|
||
need(space.actions[p.actionId]?.status === 'CONFIRMED' && space.actions[p.actionId]?.digest === p.digest, 'EXACT_CONFIRMED_ACTION_REQUIRED');
|
||
space.actions[p.actionId].status = 'RUNNING'; response = clone(space.actions[p.actionId]); break;
|
||
default: throw Error('UNKNOWN_ACTION');
|
||
}
|
||
}
|
||
const entry = { at: now, actorId: event.actorId, action: event.action, spaceId: event.spaceId,
|
||
eventHash, signedEvent: clone(event), previous: state.journal.at(-1)?.hash ?? null };
|
||
entry.hash = hash(entry); state.journal.push(entry); state.lastTime = now;
|
||
state.used[event.id] = { hash: eventHash, response: clone(response) };
|
||
const temporary = file + '.tmp';
|
||
fs.writeFileSync(temporary, canonical(state) + '\n', { mode: 0o600 }); fs.renameSync(temporary, file);
|
||
return clone(response);
|
||
} finally { fs.rmdirSync(lock); }
|
||
}
|
||
audit() {
|
||
const state = JSON.parse(fs.readFileSync(path.join(this.#root, 'state.json'), 'utf8'));
|
||
let previous = null, signatures = 0;
|
||
for (const entry of state.journal) {
|
||
const { hash: recorded, ...body } = entry;
|
||
need(body.previous === previous && hash(body) === recorded, 'AUDIT_CHAIN_MISMATCH');
|
||
if (body.signedEvent) {
|
||
const { signature, ...message } = body.signedEvent, actor = this.#trust[message.actorId];
|
||
need(actor && hash(body.signedEvent) === body.eventHash &&
|
||
verify(null, Buffer.from(canonical(message)), actor.publicKey, Buffer.from(signature, 'base64')), 'AUDIT_SIGNATURE_MISMATCH');
|
||
signatures++;
|
||
} else need(body.action === 'EXECUTION_SETTLED', 'UNSIGNED_EXTERNAL_EVENT');
|
||
previous = recorded;
|
||
}
|
||
return { outcome: 'PASS_EVENT_CHAIN', events: state.journal.length, signatures,
|
||
localHostStorageTrusted: true, entireSnapshotCryptographicallySigned: false };
|
||
}
|
||
async executeAction(startEnvelope, host) {
|
||
need(['authorize', 'execute', 'verify'].every(k => typeof host?.[k] === 'function'), 'HOST_EXECUTION_ADAPTER_REQUIRED');
|
||
need(startEnvelope.action === 'START_ACTION', 'SIGNED_START_REQUIRED');
|
||
const actionId = startEnvelope.payload.actionId;
|
||
need(!this.#running.has(actionId), 'ACTION_ALREADY_RUNNING');
|
||
// Completed/restarted actions are never replayed through an idempotent START response.
|
||
const before = JSON.parse(fs.readFileSync(path.join(this.#root, 'state.json'), 'utf8'));
|
||
need(before.spaces[startEnvelope.spaceId]?.actions[actionId]?.status === 'CONFIRMED', 'ACTION_NOT_CONFIRMED');
|
||
const action = this.handle(startEnvelope), controller = new AbortController();
|
||
this.#running.set(actionId, controller);
|
||
let started = false, verified = false, result, error;
|
||
try {
|
||
const approval = await host.authorize(clone(action.plan), { digest: action.digest, signal: controller.signal });
|
||
controller.signal.throwIfAborted();
|
||
need(approval?.digest === action.digest && typeof approval.receipt === 'string' && approval.receipt.length, 'NATIVE_APPROVAL_REQUIRED');
|
||
started = true;
|
||
result = await host.execute(clone(action.plan), { approval, signal: controller.signal });
|
||
need(result && typeof result === 'object' && await host.verify(clone(action.plan), clone(result)) === true, 'TARGET_READBACK_REQUIRED');
|
||
verified = true;
|
||
} catch (e) { error = String(e.message ?? e); }
|
||
const lock = path.join(this.#root, '.writer-lock'); fs.mkdirSync(lock);
|
||
try {
|
||
const file = path.join(this.#root, 'state.json'), state = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||
const current = state.spaces[startEnvelope.spaceId].actions[actionId];
|
||
const stopped = current.status === 'STOP_REQUESTED';
|
||
Object.assign(current, { status: stopped ? 'STOPPED' : verified ? 'COMPLETED' : 'FAILED',
|
||
verified, uncertainEffects: started && !verified, result: result ?? null, error: error ?? null });
|
||
const entry = { at: this.#clock(), actorId: 'TRUSTED_HOST_ADAPTER', action: 'EXECUTION_SETTLED',
|
||
spaceId: startEnvelope.spaceId, actionId, resultHash: hash(current), previous: state.journal.at(-1)?.hash ?? null };
|
||
entry.hash = hash(entry); state.journal.push(entry);
|
||
fs.writeFileSync(file + '.tmp', canonical(state) + '\n', { mode: 0o600 }); fs.renameSync(file + '.tmp', file);
|
||
return clone(current);
|
||
} finally { fs.rmdirSync(lock); this.#running.delete(actionId); }
|
||
}
|
||
}
|