146 lines
8 KiB
JavaScript
146 lines
8 KiB
JavaScript
// Host implementation of TCS-DARK-CORE-EXECUTION-0001. No model or authority setter.
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { createHash } from 'node:crypto';
|
|
|
|
export const digest = value => createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
const copy = value => structuredClone(value);
|
|
const terminal = new Set(['COMPLETED', 'STOPPED', 'WITHDRAWN', 'FAILED', 'RECOVERY_REQUIRED']);
|
|
const text = value => typeof value === 'string' && value.trim().length > 0;
|
|
const deepFreeze = value => {
|
|
if (value && typeof value === 'object') {
|
|
Object.values(value).forEach(deepFreeze);
|
|
Object.freeze(value);
|
|
}
|
|
return value;
|
|
};
|
|
|
|
export class DarkCoreTask {
|
|
#plan; #state; #host; #file; #busy = false; #abort; #epoch = 0; #pending;
|
|
constructor({ plan, directory, host }) {
|
|
if (plan?.channel !== 'ICE-CH-DK001' || !text(plan.id) || !text(plan.goal) ||
|
|
!text(plan.authorizationRef) || !Array.isArray(plan.steps) || !plan.steps.length ||
|
|
!plan.steps.every(s => text(s.id) && text(s.capability) && s.args && typeof s.args === 'object') ||
|
|
new Set(plan.steps.map(s => s.id)).size !== plan.steps.length) throw Error('INVALID_PLAN');
|
|
for (const method of ['authorize', 'execute', 'verify', 'verifyHuman']) {
|
|
if (typeof host?.[method] !== 'function') throw Error(`HOST_ADAPTER_REQUIRED:${method}`);
|
|
}
|
|
if (!path.isAbsolute(directory)) throw Error('ABSOLUTE_STATE_DIRECTORY_REQUIRED');
|
|
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
if (fs.realpathSync(directory) !== path.resolve(directory)) throw Error('STATE_DIRECTORY_SYMLINK');
|
|
this.#file = path.join(directory, 'task.json');
|
|
this.#plan = deepFreeze(copy(plan)); this.#host = host;
|
|
this.#state = { schema: 'guanghu.dark-core-task/v1', plan: this.#plan,
|
|
planHash: digest(this.#plan), status: 'READY', nextStep: 0,
|
|
receipts: [], suggestions: [], events: [], seenHumanEvents: [], uncertainSteps: [] };
|
|
// Exclusive creation prevents two writers and implicit replay after a crash.
|
|
fs.writeFileSync(this.#file, JSON.stringify(this.#state), { flag: 'wx', mode: 0o600 });
|
|
this.#record('CREATED', { goal: plan.goal });
|
|
}
|
|
get snapshot() { return copy(this.#state); }
|
|
#record(type, detail = {}) {
|
|
const previous = this.#state.events.at(-1)?.hash ?? null;
|
|
const event = { sequence: this.#state.events.length + 1, at: new Date().toISOString(), type, detail, previous };
|
|
event.hash = digest(event); this.#state.events.push(event);
|
|
const temporary = this.#file + '.tmp';
|
|
fs.writeFileSync(temporary, JSON.stringify(this.#state, null, 2) + '\n', { mode: 0o600 });
|
|
fs.renameSync(temporary, this.#file);
|
|
}
|
|
async human(input) {
|
|
const event = deepFreeze(copy(input));
|
|
if (!text(event.id) || event.taskId !== this.#plan.id ||
|
|
!['ADVICE', 'STOP', 'WITHDRAW', 'CORRECTION', 'CHANGE_GOAL', 'RESUME'].includes(event.kind) ||
|
|
!text(event.text)) throw Error('INVALID_HUMAN_EVENT');
|
|
// Only the host adapter authenticates provenance. A field saying "human" is insufficient.
|
|
if (await this.#host.verifyHuman(event) !== true) throw Error('UNTRUSTED_CONTROL_SOURCE');
|
|
if (this.#state.seenHumanEvents.includes(event.id)) return this.snapshot;
|
|
if (terminal.has(this.#state.status)) throw Error('TASK_CLOSED');
|
|
if (event.kind === 'RESUME') {
|
|
if (this.#busy || this.#state.status !== 'PAUSED') throw Error('NOT_SETTLED_PAUSE');
|
|
if (this.#state.uncertainSteps.length) throw Error('UNCERTAIN_EFFECTS_REQUIRE_NEW_RECONCILED_TASK');
|
|
this.#pending = undefined; this.#state.status = 'READY';
|
|
} else if (event.kind === 'ADVICE') {
|
|
this.#state.suggestions.push({ id: event.id, text: event.text, state: 'QUEUED' });
|
|
} else {
|
|
const next = event.kind === 'WITHDRAW' ? 'WITHDRAW' : event.kind === 'STOP' ? 'STOP' : 'PAUSE';
|
|
// A later correction must never demote an already accepted stop or withdrawal.
|
|
const rank = { PAUSE: 1, STOP: 2, WITHDRAW: 3 };
|
|
if (!this.#pending || rank[next] > rank[this.#pending]) this.#pending = next;
|
|
this.#epoch++;
|
|
this.#state.status = this.#pending + '_REQUESTED';
|
|
this.#abort?.abort(new Error(event.kind));
|
|
if (!this.#busy) this.#settleControl();
|
|
}
|
|
this.#state.seenHumanEvents.push(event.id);
|
|
this.#record('HUMAN_' + event.kind, { id: event.id, text: event.text });
|
|
return this.snapshot;
|
|
}
|
|
decideAdvice(id, decision, reason) {
|
|
if (!['ACCEPT', 'DEFER', 'REJECT'].includes(decision) || !text(reason)) throw Error('REASON_REQUIRED');
|
|
const item = this.#state.suggestions.find(s => s.id === id);
|
|
if (!item || item.state !== 'QUEUED' || terminal.has(this.#state.status)) throw Error('SUGGESTION_UNAVAILABLE');
|
|
Object.assign(item, { state: decision, reason });
|
|
this.#record('PERSONA_ADVICE_DECISION', { id, decision, reason });
|
|
// ACCEPT records agreement; it does not rewrite the immutable execution plan.
|
|
return this.snapshot;
|
|
}
|
|
#settleControl() {
|
|
if (this.#pending) this.#state.status = { PAUSE: 'PAUSED', STOP: 'STOPPED', WITHDRAW: 'WITHDRAWN' }[this.#pending];
|
|
}
|
|
async run() {
|
|
if (this.#busy || this.#state.status !== 'READY') throw Error('TASK_NOT_READY');
|
|
this.#busy = true; this.#state.status = 'RUNNING'; this.#record('RUN_STARTED');
|
|
try {
|
|
while (this.#state.nextStep < this.#plan.steps.length && !this.#pending) {
|
|
const step = this.#plan.steps[this.#state.nextStep];
|
|
const epoch = this.#epoch; this.#abort = new AbortController();
|
|
const signal = this.#abort.signal;
|
|
const grant = await this.#host.authorize({ plan: this.#plan, step, planHash: this.#state.planHash, signal });
|
|
// Stop can arrive while the host is obtaining native approval.
|
|
if (this.#pending || epoch !== this.#epoch) break;
|
|
if (!grant || grant.planHash !== this.#state.planHash || grant.stepId !== step.id || !text(grant.receipt)) {
|
|
throw Error('HOST_AUTHORIZATION_MISMATCH');
|
|
}
|
|
this.#record('STEP_STARTED', { stepId: step.id, authorizationReceipt: grant.receipt });
|
|
let result;
|
|
try {
|
|
result = await this.#host.execute({ plan: this.#plan, step, grant, signal });
|
|
if (!result || typeof result !== 'object' || !text(result.kind)) throw Error('EXECUTION_RECEIPT_REQUIRED');
|
|
result = deepFreeze(copy(result));
|
|
const verified = await this.#host.verify({ plan: this.#plan, step, result });
|
|
if (verified !== true) throw Error('TARGET_READBACK_FAILED');
|
|
} catch (error) {
|
|
// Abort or failure does not prove that a side effect did not happen.
|
|
this.#state.uncertainSteps.push(step.id);
|
|
throw error;
|
|
}
|
|
this.#state.receipts.push({ stepId: step.id, result: copy(result), verified: true });
|
|
this.#state.nextStep++;
|
|
this.#record('STEP_VERIFIED', { stepId: step.id });
|
|
}
|
|
if (!this.#pending && this.#state.nextStep === this.#plan.steps.length) this.#state.status = 'COMPLETED';
|
|
} catch (error) {
|
|
this.#state.lastError = String(error.message ?? error);
|
|
if (!this.#pending) this.#state.status = 'FAILED';
|
|
this.#record('EXECUTION_INTERRUPTED', { error: this.#state.lastError });
|
|
} finally {
|
|
this.#busy = false; this.#abort = undefined; this.#settleControl();
|
|
this.#record('RUN_SETTLED', { status: this.#state.status });
|
|
}
|
|
return this.snapshot;
|
|
}
|
|
}
|
|
|
|
export function inspectTask(directory) {
|
|
const state = JSON.parse(fs.readFileSync(path.join(directory, 'task.json'), 'utf8'));
|
|
if (digest(state.plan) !== state.planHash) throw Error('PLAN_INTEGRITY_FAILURE');
|
|
let previous = null;
|
|
for (let i = 0; i < state.events.length; i++) {
|
|
const { hash, ...event } = state.events[i];
|
|
if (event.sequence !== i + 1 || event.previous !== previous || digest(event) !== hash) throw Error('JOURNAL_INTEGRITY_FAILURE');
|
|
previous = hash;
|
|
}
|
|
// Inspection never resumes potentially interrupted real operations.
|
|
return { ...state, recoveryRequired: !terminal.has(state.status),
|
|
recoveryPolicy: 'INSPECT_REAL_EFFECTS_AND_AUTHORIZE_NEW_TASK_NO_AUTOMATIC_REPLAY' };
|
|
}
|