187 lines
6.4 KiB
JavaScript
187 lines
6.4 KiB
JavaScript
/** Phase 0 executable contract model for XZBT 0.1 GC5. */
|
|
|
|
export const DISPATCH_BUDGET = 1024;
|
|
export const TERMINATION_HOOK_LIMIT = 256;
|
|
export const CLEANUP_TICKS = 5 * 60;
|
|
|
|
export class ConditionTrigger {
|
|
constructor(holdTicks = 0) {
|
|
this.holdTicks = holdTicks;
|
|
this.armed = false;
|
|
this.trueTicks = 0;
|
|
}
|
|
|
|
evaluate(value) {
|
|
if (!value) {
|
|
this.armed = true;
|
|
this.trueTicks = 0;
|
|
return false;
|
|
}
|
|
if (!this.armed) return false;
|
|
this.trueTicks++;
|
|
if (this.trueTicks < Math.max(1, this.holdTicks)) return false;
|
|
this.armed = false;
|
|
this.trueTicks = 0;
|
|
return true;
|
|
}
|
|
}
|
|
|
|
export class DeferredStartQueue {
|
|
constructor(defaultExpiryTicks = 5 * 60 * 60) {
|
|
this.defaultExpiryTicks = defaultExpiryTicks;
|
|
this.requests = new Map();
|
|
}
|
|
|
|
defer({ definitionId, priority = 50, creationTick, documentOrder, expiryTicks, sampledInputs }) {
|
|
if (this.requests.has(definitionId)) return false;
|
|
this.requests.set(definitionId, {
|
|
definitionId,
|
|
priority,
|
|
creationTick,
|
|
documentOrder,
|
|
expiresAt: creationTick + (expiryTicks ?? this.defaultExpiryTicks),
|
|
sampledInputs
|
|
});
|
|
return true;
|
|
}
|
|
|
|
dispatch(tick, isEligible) {
|
|
for (const [id, request] of this.requests) {
|
|
if (tick >= request.expiresAt) this.requests.delete(id);
|
|
}
|
|
const ordered = [...this.requests.values()].sort((left, right) =>
|
|
right.priority - left.priority || left.creationTick - right.creationTick || left.documentOrder - right.documentOrder
|
|
);
|
|
const started = [];
|
|
for (const request of ordered) {
|
|
if (!isEligible(request)) continue;
|
|
started.push(request);
|
|
this.requests.delete(request.definitionId);
|
|
}
|
|
return started;
|
|
}
|
|
}
|
|
|
|
export function drainDispatch(initialQueue, expand, budget = DISPATCH_BUDGET) {
|
|
const queue = [...initialQueue];
|
|
const processed = [];
|
|
let consumed = 0;
|
|
while (queue.length > 0 && consumed < budget) {
|
|
const unit = queue.shift();
|
|
consumed++;
|
|
processed.push(unit);
|
|
const added = expand(unit) || [];
|
|
queue.push(...added);
|
|
}
|
|
return {
|
|
processed,
|
|
consumed,
|
|
exhausted: queue.length > 0,
|
|
discarded: queue,
|
|
failedOwnerIds: [...new Set(queue.map((unit) => unit.ownerId).filter(Boolean))]
|
|
};
|
|
}
|
|
|
|
export class OwnershipModel {
|
|
constructor() {
|
|
this.tickIndex = 0;
|
|
this.state = new Map();
|
|
this.owners = new Map();
|
|
this.resources = new Map();
|
|
this.diagnostics = [];
|
|
this.nextResourceId = 1;
|
|
}
|
|
|
|
startScenario(id, { onStart = [], onComplete = [], onCancel = [] } = {}) {
|
|
const owner = { id, status: 'STARTING', blocked: false, onComplete, onCancel, terminalCause: null };
|
|
this.owners.set(id, owner);
|
|
const succeeded = this.executeActions(onStart, id, 'ordinary');
|
|
if (succeeded) owner.status = 'ACTIVE';
|
|
return owner;
|
|
}
|
|
|
|
executeActions(actions, ownerId, phase = 'ordinary') {
|
|
const owner = this.owners.get(ownerId);
|
|
for (let index = 0; index < actions.length; index++) {
|
|
const action = actions[index];
|
|
if (phase === 'ordinary' && owner?.blocked) return false;
|
|
try {
|
|
if (phase === 'termination' && !['set', 'sound', 'fail'].includes(action.type)) {
|
|
throw new Error(`Action '${action.type}' is not permitted in a termination hook.`);
|
|
}
|
|
if (action.type === 'set') {
|
|
this.state.set(action.target, action.value);
|
|
} else if (action.type === 'sound') {
|
|
if (action.continuous || action.ownership === 'persistent') {
|
|
throw new Error('Termination hooks permit only nonpersistent one-shot sounds.');
|
|
}
|
|
this.createResource(ownerId, { kind: 'termination-sound', releaseTicks: action.releaseTicks ?? 0 });
|
|
} else if (action.type === 'resource') {
|
|
this.createResource(ownerId, action);
|
|
} else if (action.type === 'event') {
|
|
if (!this.executeActions(action.actions || [], ownerId, phase)) return false;
|
|
} else if (action.type === 'fail') {
|
|
throw new Error(action.message || 'injected failure');
|
|
}
|
|
} catch (error) {
|
|
this.diagnostics.push({ code: 'ERR_ACTION_FAILURE', ownerId, phase, index, message: error.message });
|
|
if (phase === 'termination') continue;
|
|
if (action.critical) {
|
|
this.terminate(ownerId, 'failure');
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
createResource(ownerId, { kind = 'generic', releaseTicks = 0, ownership = 'scenario' } = {}) {
|
|
const effectiveOwner = ownership === 'persistent' ? 'performance' : ownerId;
|
|
const id = `resource-${this.nextResourceId++}`;
|
|
this.resources.set(id, { id, kind, ownerId: effectiveOwner, releaseTicks, cleanupAt: null });
|
|
return id;
|
|
}
|
|
|
|
terminate(ownerId, cause) {
|
|
const owner = this.owners.get(ownerId);
|
|
if (!owner || owner.blocked) return;
|
|
owner.blocked = true;
|
|
owner.terminalCause = cause;
|
|
const hook = cause === 'complete' ? owner.onComplete : owner.onCancel;
|
|
this.executeActions(hook.slice(0, TERMINATION_HOOK_LIMIT), ownerId, 'termination');
|
|
if (hook.length > TERMINATION_HOOK_LIMIT) {
|
|
this.diagnostics.push({ code: 'ERR_DISPATCH_BUDGET', ownerId, phase: 'termination' });
|
|
}
|
|
|
|
for (const resource of this.resources.values()) {
|
|
if (resource.ownerId !== ownerId) continue;
|
|
const boundedRelease = Math.min(resource.releaseTicks, CLEANUP_TICKS);
|
|
if (boundedRelease === 0) this.resources.delete(resource.id);
|
|
else {
|
|
resource.ownerId = `cleanup:${ownerId}`;
|
|
resource.cleanupAt = this.tickIndex + boundedRelease;
|
|
resource.forceAt = this.tickIndex + CLEANUP_TICKS;
|
|
resource.forceRequired = resource.releaseTicks > CLEANUP_TICKS;
|
|
}
|
|
}
|
|
owner.status = cause === 'complete' ? 'COMPLETED' : cause === 'failure' ? 'FAILED' : 'CANCELLED';
|
|
}
|
|
|
|
tick(count = 1) {
|
|
for (let step = 0; step < count; step++) {
|
|
this.tickIndex++;
|
|
for (const resource of [...this.resources.values()]) {
|
|
if (!resource.cleanupAt || this.tickIndex < resource.cleanupAt) continue;
|
|
if (resource.forceRequired && this.tickIndex >= resource.forceAt) {
|
|
this.diagnostics.push({ code: 'WARN_CLEANUP_FORCED', resourceId: resource.id });
|
|
}
|
|
this.resources.delete(resource.id);
|
|
}
|
|
}
|
|
}
|
|
|
|
ownedResourceCount(ownerId) {
|
|
return [...this.resources.values()].filter((resource) => resource.ownerId === ownerId).length;
|
|
}
|
|
}
|