guanghu-ice-heart/server-tools/dark-core/task-controller.mjs

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' };
}