feat: add executor gateway contracts

This commit is contained in:
john
2026-07-24 22:50:37 +08:00
parent 19706eb3a0
commit d21c4849a9
8 changed files with 647 additions and 0 deletions
@@ -86,6 +86,21 @@ The framework-neutral contracts are:
- `orchestrator-event-v1` - `orchestrator-event-v1`
- `workflow-execution-request-v1` - `workflow-execution-request-v1`
- `workflow-execution-decision-v1` - `workflow-execution-decision-v1`
- `executor-job-request-v1`
- `executor-job-state-v1`
- `executor-dispatch-decision-v1`
The Phase 3.2 Executor Gateway owns the adapter registry and executor job state
transition boundary. Requests contain workspace/artifact references,
authorization and side-effect policy, timeout, cancellation, fallback, and an
idempotency key. The store interface requires atomic create-if-absent semantics
so retries return the original job and conflicting payloads fail closed.
Goosed, Aider, and OpenHands currently appear only as disabled
`contract-only` adapters. The reference in-memory store is non-durable and is
not a deployment backend. The compile-time dispatch capability remains false,
so creating a job records `blocked` state without importing an executor SDK,
launching a process, or touching a workspace.
The implemented internal API is: The implemented internal API is:
+11
View File
@@ -237,6 +237,16 @@ export type OrchestratorEngineDescriptor = {
capabilities: string[]; capabilities: string[];
}; };
export type OrchestratorExecutorDescriptor = {
id: string;
label: string;
kind: string;
enabled: boolean;
dispatchImplemented: boolean;
status: string;
capabilities: string[];
};
export type OrchestratorConfigState = { export type OrchestratorConfigState = {
config: OrchestratorConfig; config: OrchestratorConfig;
configVersion: number; configVersion: number;
@@ -245,6 +255,7 @@ export type OrchestratorConfigState = {
source: string; source: string;
runtime: OrchestratorRuntime; runtime: OrchestratorRuntime;
engines: OrchestratorEngineDescriptor[]; engines: OrchestratorEngineDescriptor[];
executors: OrchestratorExecutorDescriptor[];
}; };
export type OrchestratorServiceHealth = { export type OrchestratorServiceHealth = {
+30
View File
@@ -169,6 +169,36 @@ export function OrchestratorPage() {
</p> </p>
</div> </div>
<div className="card grid">
<div>
<h3 style={{ margin: 0 }}>Executor Gateway</h3>
<p style={{ margin: '4px 0 0', color: '#68716c', fontSize: 13 }}>
Phase 3.2 contract-only
</p>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 10 }}>
{(state.executors ?? []).map((executor) => (
<div
key={executor.id}
style={{ border: '1px solid #e4e9e6', borderRadius: 10, padding: 12 }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8 }}>
<strong>{executor.label}</strong>
<span style={{ color: executor.enabled ? '#2f6f57' : '#8a5a16', fontSize: 13 }}>
{executor.status}
</span>
</div>
<p style={{ margin: '8px 0 0', color: '#68716c', fontSize: 13 }}>
{executor.capabilities.join(' · ') || '无能力声明'}
</p>
<small style={{ display: 'block', marginTop: 8 }}>
dispatch {executor.dispatchImplemented ? 'implemented' : 'disabled'}
</small>
</div>
))}
</div>
</div>
<OrchestratorShadowPanel /> <OrchestratorShadowPanel />
<OrchestratorExecutionPlanPanel /> <OrchestratorExecutionPlanPanel />
+17
View File
@@ -93,6 +93,23 @@ reasons, task-type distribution, Native terminal coverage, recent decisions,
and an explicit alert if any stored decision ever reports and an explicit alert if any stored decision ever reports
`handoffAllowed=true`. `handoffAllowed=true`.
Phase 3.2 establishes the Executor Gateway boundary:
- `executor-job-request-v1`, `executor-job-state-v1`, and
`executor-dispatch-decision-v1` remain independent of Goosed, Aider,
OpenHands, and LangGraph SDK types;
- jobs use workspace and artifact references rather than host filesystem paths;
- the store contract provides atomic idempotency, lookup, and state update
operations;
- timeout, cancellation, fallback, authorization, network, and side-effect
controls are normalized before adapter selection;
- Goosed, Aider, and OpenHands are registered as `contract-only` adapters;
- the reference memory store is explicitly non-durable and used only for local
contract tests.
`EXECUTOR_DISPATCH_IMPLEMENTED=false` is a code-level gate. Phase 3.2 can record
blocked, idempotent job state but cannot invoke an adapter or launch a process.
The default memindadm mode remains `off`. Canary and Active routing are not The default memindadm mode remains `off`. Canary and Active routing are not
wired to the LangGraph executor in Phase 3. Native Agent Run remains the sole wired to the LangGraph executor in Phase 3. Native Agent Run remains the sole
executor in every mode. executor in every mode.
+3
View File
@@ -7,6 +7,7 @@ import {
normalizeWorkflowName, normalizeWorkflowName,
stableRolloutBucket, stableRolloutBucket,
} from './contracts.mjs'; } from './contracts.mjs';
import { listPhase3ExecutorAdapters } from './executor-gateway.mjs';
const CONFIG_TABLE = 'h5_orchestrator_admin_config'; const CONFIG_TABLE = 'h5_orchestrator_admin_config';
const CONFIG_SCOPE = 'global'; const CONFIG_SCOPE = 'global';
@@ -301,6 +302,7 @@ export function createOrchestratorAdminConfigService(pool, {
...state, ...state,
runtime: runtimeState(state.config, env), runtime: runtimeState(state.config, env),
engines: engineCatalog(state.config), engines: engineCatalog(state.config),
executors: listPhase3ExecutorAdapters(),
}; };
}, },
@@ -333,6 +335,7 @@ export function createOrchestratorAdminConfigService(pool, {
...state, ...state,
runtime: runtimeState(state.config, env), runtime: runtimeState(state.config, env),
engines: engineCatalog(state.config), engines: engineCatalog(state.config),
executors: listPhase3ExecutorAdapters(),
...(probe ? { serviceHealth: await probeServiceHealth(state.config, { fetchImpl }) } : {}), ...(probe ? { serviceHealth: await probeServiceHealth(state.config, { fetchImpl }) } : {}),
}; };
}, },
@@ -35,6 +35,15 @@ test('orchestrator config defaults to a disabled native-safe runtime', async ()
assert.equal(state.runtime.reason, 'mode_off'); assert.equal(state.runtime.reason, 'mode_off');
assert.equal(state.engines.find((engine) => engine.id === 'native').configured, true); assert.equal(state.engines.find((engine) => engine.id === 'native').configured, true);
assert.equal(state.engines.find((engine) => engine.id === 'langgraph').configured, false); assert.equal(state.engines.find((engine) => engine.id === 'langgraph').configured, false);
assert.deepEqual(
state.executors.map((executor) => executor.id),
['goosed', 'aider', 'openhands'],
);
assert.equal(state.executors.every((executor) => executor.enabled === false), true);
assert.equal(
state.executors.every((executor) => executor.dispatchImplemented === false),
true,
);
}); });
test('orchestrator config normalizes unsafe values and keeps a workflow allowlist', () => { test('orchestrator config normalizes unsafe values and keeps a workflow allowlist', () => {
+418
View File
@@ -0,0 +1,418 @@
import crypto from 'node:crypto';
import { normalizeWorkflowEngineId } from './contracts.mjs';
const EXECUTOR_JOB_REQUEST_VERSION = 'executor-job-request-v1';
const EXECUTOR_JOB_STATE_VERSION = 'executor-job-state-v1';
const EXECUTOR_DISPATCH_IMPLEMENTED = false;
const MAX_INSTRUCTION_CHARACTERS = 32_000;
const TERMINAL_JOB_STATUSES = new Set([
'succeeded',
'failed',
'cancelled',
'timed_out',
'blocked',
]);
const PHASE3_EXECUTOR_DESCRIPTORS = Object.freeze([
Object.freeze({
id: 'goosed',
label: 'Goosed',
kind: 'remote',
capabilities: Object.freeze(['session-execution', 'streaming', 'tools']),
}),
Object.freeze({
id: 'aider',
label: 'Aider',
kind: 'worker',
capabilities: Object.freeze(['code-edit', 'multi-file']),
}),
Object.freeze({
id: 'openhands',
label: 'OpenHands',
kind: 'worker',
capabilities: Object.freeze(['repo-task', 'code-edit', 'command-execution']),
}),
]);
function clone(value) {
return typeof structuredClone === 'function'
? structuredClone(value)
: JSON.parse(JSON.stringify(value));
}
function clampInteger(value, fallback, min, max) {
const number = Number(value);
if (!Number.isFinite(number)) return fallback;
return Math.min(max, Math.max(min, Math.floor(number)));
}
function normalizeReference(value, fallbackKind) {
const source = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
const kind = String(source.kind ?? fallbackKind ?? '').trim().slice(0, 64);
const id = String(source.id ?? '').trim().slice(0, 256);
return kind && id ? { kind, id } : null;
}
function stableValue(value) {
if (Array.isArray(value)) return value.map(stableValue);
if (!value || typeof value !== 'object') return value;
return Object.fromEntries(
Object.keys(value)
.sort()
.map((key) => [key, stableValue(value[key])]),
);
}
function fingerprint(value) {
return crypto
.createHash('sha256')
.update(JSON.stringify(stableValue(value)))
.digest('hex');
}
function gatewayError(code, message, status = 409) {
const error = new Error(message);
error.code = code;
error.status = status;
return error;
}
export function normalizeExecutorJobRequest(input = {}) {
const source = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
const jobId = String(source.jobId ?? crypto.randomUUID()).trim();
const idempotencyKey = String(source.idempotencyKey ?? '').trim().slice(0, 200);
const executor = normalizeWorkflowEngineId(source.executor, '');
const instruction = String(source.task?.instruction ?? source.instruction ?? '')
.trim()
.slice(0, MAX_INSTRUCTION_CHARACTERS);
if (!jobId) throw gatewayError('EXECUTOR_JOB_ID_REQUIRED', 'Executor job requires jobId', 422);
if (!idempotencyKey) {
throw gatewayError(
'EXECUTOR_IDEMPOTENCY_KEY_REQUIRED',
'Executor job requires idempotencyKey',
422,
);
}
if (!executor) {
throw gatewayError('EXECUTOR_ID_REQUIRED', 'Executor job requires executor', 422);
}
if (!instruction) {
throw gatewayError('EXECUTOR_INSTRUCTION_REQUIRED', 'Executor job requires instruction', 422);
}
return {
version: EXECUTOR_JOB_REQUEST_VERSION,
jobId,
idempotencyKey,
executor,
task: {
type: String(source.task?.type ?? 'code_task').trim().slice(0, 128) || 'code_task',
instruction,
workspaceRef: normalizeReference(source.task?.workspaceRef, 'workspace'),
inputRefs: Array.isArray(source.task?.inputRefs)
? source.task.inputRefs
.map((item) => normalizeReference(item, 'artifact'))
.filter(Boolean)
.slice(0, 100)
: [],
},
subject: {
tenantId: String(source.subject?.tenantId ?? '').trim() || null,
userId: String(source.subject?.userId ?? '').trim() || null,
},
authorization: {
executionAllowed: source.authorization?.executionAllowed === true,
actorId: String(source.authorization?.actorId ?? '').trim() || null,
},
policy: {
sideEffectsAllowed: source.policy?.sideEffectsAllowed === true,
networkAllowed: source.policy?.networkAllowed === true,
},
controls: {
timeoutMs: clampInteger(source.controls?.timeoutMs, 15 * 60_000, 500, 60 * 60_000),
cancellationAllowed: source.controls?.cancellationAllowed !== false,
fallbackExecutor: normalizeWorkflowEngineId(source.controls?.fallbackExecutor, ''),
},
metadata: source.metadata && typeof source.metadata === 'object'
? clone(source.metadata)
: {},
};
}
function validateExecutorAdapter(adapter) {
if (!adapter || typeof adapter !== 'object') {
throw new Error('Executor adapter must be an object');
}
const id = normalizeWorkflowEngineId(adapter.id, '');
if (!id) throw new Error('Executor adapter requires a valid id');
for (const method of ['submit', 'cancel', 'getState', 'streamEvents']) {
if (typeof adapter[method] !== 'function') {
throw new Error(`Executor adapter ${id} missing method: ${method}`);
}
}
return id;
}
export function createExecutorAdapterRegistry(initialAdapters = []) {
const adapters = new Map();
function register(adapter) {
const id = validateExecutorAdapter(adapter);
if (adapters.has(id)) throw new Error(`Executor adapter already registered: ${id}`);
adapters.set(id, adapter);
return adapter;
}
for (const adapter of initialAdapters) register(adapter);
return {
register,
has(id) {
return adapters.has(normalizeWorkflowEngineId(id, ''));
},
get(id) {
return adapters.get(normalizeWorkflowEngineId(id, '')) ?? null;
},
list() {
return [...adapters.values()].map((adapter) => ({
id: adapter.id,
label: adapter.label ?? adapter.id,
kind: adapter.kind ?? 'plugin',
enabled: adapter.enabled === true,
dispatchImplemented: adapter.dispatchImplemented === true,
status: adapter.status ?? 'unknown',
capabilities: Array.isArray(adapter.capabilities)
? [...adapter.capabilities]
: [],
}));
},
};
}
export function createDisabledExecutorAdapter(descriptor) {
const id = normalizeWorkflowEngineId(descriptor?.id, '');
if (!id) throw new Error('Disabled executor adapter requires a valid id');
const reject = () => {
throw gatewayError(
'EXECUTOR_ADAPTER_DISABLED',
`Executor adapter ${id} is contract-only and cannot dispatch`,
);
};
return {
id,
label: descriptor.label ?? id,
kind: descriptor.kind ?? 'plugin',
enabled: false,
dispatchImplemented: false,
status: 'contract-only',
capabilities: Array.isArray(descriptor.capabilities)
? [...descriptor.capabilities]
: [],
submit: reject,
cancel: reject,
getState: reject,
async *streamEvents() {
reject();
},
};
}
export function createPhase3ExecutorAdapterRegistry() {
return createExecutorAdapterRegistry(
PHASE3_EXECUTOR_DESCRIPTORS.map(createDisabledExecutorAdapter),
);
}
export function listPhase3ExecutorAdapters() {
return createPhase3ExecutorAdapterRegistry().list();
}
export function createInMemoryExecutorJobStore() {
const byId = new Map();
const byIdempotencyKey = new Map();
return {
durable: false,
async getById(jobId) {
const value = byId.get(String(jobId ?? '').trim());
return value ? clone(value) : null;
},
async getByIdempotencyKey(key) {
const jobId = byIdempotencyKey.get(String(key ?? '').trim());
const value = jobId ? byId.get(jobId) : null;
return value ? clone(value) : null;
},
async createIfAbsent(record) {
const existingId = byIdempotencyKey.get(record.idempotencyKey);
if (existingId) return { created: false, record: clone(byId.get(existingId)) };
if (byId.has(record.jobId)) {
throw gatewayError('EXECUTOR_JOB_ID_CONFLICT', `Executor job id already exists: ${record.jobId}`);
}
byId.set(record.jobId, clone(record));
byIdempotencyKey.set(record.idempotencyKey, record.jobId);
return { created: true, record: clone(record) };
},
async update(record) {
if (!byId.has(record.jobId)) return null;
byId.set(record.jobId, clone(record));
return clone(record);
},
};
}
function validateJobStore(store) {
for (const method of ['getById', 'getByIdempotencyKey', 'createIfAbsent', 'update']) {
if (typeof store?.[method] !== 'function') {
throw new Error(`Executor job store missing method: ${method}`);
}
}
}
export function createExecutorGateway({
registry = createPhase3ExecutorAdapterRegistry(),
store = createInMemoryExecutorJobStore(),
nowMs = Date.now,
} = {}) {
if (!registry?.has || !registry?.list) {
throw new Error('Executor Gateway requires an adapter registry');
}
validateJobStore(store);
function previewNormalized(request) {
const adapter = registry.get(request.executor);
const fallbackAdapter = request.controls.fallbackExecutor
? registry.get(request.controls.fallbackExecutor)
: null;
const fallbackRegistered = Boolean(fallbackAdapter);
const fallbackAvailable = fallbackAdapter?.enabled === true
&& fallbackAdapter?.dispatchImplemented === true;
const gates = {
implementation: EXECUTOR_DISPATCH_IMPLEMENTED,
adapterRegistered: Boolean(adapter),
adapterEnabled: adapter?.enabled === true,
executionAuthorized: request.authorization.executionAllowed,
sideEffectsAllowed: request.policy.sideEffectsAllowed,
idempotencyKeyPresent: Boolean(request.idempotencyKey),
workspaceReferencePresent: Boolean(request.task.workspaceRef),
};
return {
version: 'executor-dispatch-decision-v1',
jobId: request.jobId,
executor: request.executor,
fallbackExecutor: request.controls.fallbackExecutor || null,
fallbackRegistered,
fallbackAvailable,
dispatchAllowed: Object.values(gates).every(Boolean),
reason: EXECUTOR_DISPATCH_IMPLEMENTED
? 'executor_adapter_unavailable'
: 'executor_dispatch_not_implemented',
gates,
};
}
async function preview(input) {
return previewNormalized(normalizeExecutorJobRequest(input));
}
async function createJob(input) {
const request = normalizeExecutorJobRequest(input);
const {
jobId: _jobId,
idempotencyKey: _idempotencyKey,
...semanticRequest
} = request;
const requestFingerprint = fingerprint(semanticRequest);
const existing = await store.getByIdempotencyKey(request.idempotencyKey);
if (existing) {
if (existing.requestFingerprint !== requestFingerprint) {
throw gatewayError(
'EXECUTOR_IDEMPOTENCY_CONFLICT',
`Idempotency key already belongs to executor job ${existing.jobId}`,
);
}
return { created: false, job: existing };
}
const decision = previewNormalized(request);
const now = nowMs();
const record = {
version: EXECUTOR_JOB_STATE_VERSION,
jobId: request.jobId,
idempotencyKey: request.idempotencyKey,
requestFingerprint,
executor: request.executor,
status: 'blocked',
reason: decision.reason,
attempts: 0,
dispatchAllowed: false,
cancellationAllowed: request.controls.cancellationAllowed,
timeoutMs: request.controls.timeoutMs,
fallbackExecutor: decision.fallbackExecutor,
fallbackRegistered: decision.fallbackRegistered,
fallbackAvailable: decision.fallbackAvailable,
decision,
createdAt: now,
updatedAt: now,
startedAt: null,
completedAt: now,
};
const result = await store.createIfAbsent(record);
if (
!result.created
&& result.record.requestFingerprint !== requestFingerprint
) {
throw gatewayError(
'EXECUTOR_IDEMPOTENCY_CONFLICT',
`Idempotency key already belongs to executor job ${result.record.jobId}`,
);
}
return { created: result.created, job: result.record };
}
async function cancel(jobId, { reason = 'cancelled_by_request' } = {}) {
const current = await store.getById(jobId);
if (!current) return null;
if (current.status === 'cancelled' || !current.cancellationAllowed) return current;
if (TERMINAL_JOB_STATUSES.has(current.status) && current.status !== 'blocked') return current;
const now = nowMs();
return store.update({
...current,
status: 'cancelled',
reason: String(reason).slice(0, 256),
updatedAt: now,
completedAt: now,
});
}
return {
preview,
createJob,
cancel,
getJob(jobId) {
return store.getById(jobId);
},
listAdapters() {
return registry.list();
},
status() {
return {
version: 'executor-gateway-status-v1',
dispatchImplemented: EXECUTOR_DISPATCH_IMPLEMENTED,
executionEnabled: false,
store: {
kind: store.durable ? 'durable' : 'memory',
durable: store.durable === true,
},
adapters: registry.list(),
};
},
};
}
export const executorGatewayInternals = {
EXECUTOR_DISPATCH_IMPLEMENTED,
EXECUTOR_JOB_REQUEST_VERSION,
EXECUTOR_JOB_STATE_VERSION,
MAX_INSTRUCTION_CHARACTERS,
PHASE3_EXECUTOR_DESCRIPTORS,
TERMINAL_JOB_STATUSES,
fingerprint,
stableValue,
};
@@ -0,0 +1,144 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
createExecutorAdapterRegistry,
createExecutorGateway,
createInMemoryExecutorJobStore,
createPhase3ExecutorAdapterRegistry,
normalizeExecutorJobRequest,
} from './executor-gateway.mjs';
function request(overrides = {}) {
return {
jobId: 'job-1',
idempotencyKey: 'run-1:node-execute:attempt-1',
executor: 'aider',
task: {
type: 'code_change',
instruction: 'Fix the login form validation',
workspaceRef: { kind: 'mindspace-workspace', id: 'workspace-1' },
},
subject: { userId: 'user-1' },
authorization: { executionAllowed: true, actorId: 'user-1' },
policy: { sideEffectsAllowed: true, networkAllowed: false },
controls: {
timeoutMs: 20_000,
cancellationAllowed: true,
fallbackExecutor: 'goosed',
},
...overrides,
};
}
test('executor job contract uses resource references and bounded controls', () => {
const normalized = normalizeExecutorJobRequest(request({
task: {
instruction: 'x'.repeat(40_000),
workspaceRef: { kind: 'workspace', id: 'workspace-1' },
inputRefs: [
{ kind: 'artifact', id: 'artifact-1' },
{ kind: '', id: 'invalid' },
],
},
controls: { timeoutMs: 100, fallbackExecutor: 'openhands' },
}));
assert.equal(normalized.version, 'executor-job-request-v1');
assert.equal(normalized.task.instruction.length, 32_000);
assert.deepEqual(normalized.task.workspaceRef, { kind: 'workspace', id: 'workspace-1' });
assert.deepEqual(normalized.task.inputRefs, [{ kind: 'artifact', id: 'artifact-1' }]);
assert.equal(normalized.controls.timeoutMs, 500);
assert.equal(normalized.controls.fallbackExecutor, 'openhands');
assert.equal('cwd' in normalized.task, false);
});
test('Phase 3 executor catalog exposes disabled Goosed, Aider and OpenHands adapters', () => {
const registry = createPhase3ExecutorAdapterRegistry();
assert.deepEqual(
registry.list().map((adapter) => adapter.id),
['goosed', 'aider', 'openhands'],
);
for (const adapter of registry.list()) {
assert.equal(adapter.enabled, false);
assert.equal(adapter.dispatchImplemented, false);
assert.equal(adapter.status, 'contract-only');
}
});
test('executor adapter registry rejects incomplete and duplicate adapters', () => {
const registry = createExecutorAdapterRegistry();
assert.throws(() => registry.register({ id: 'broken' }), /missing method/);
const adapter = {
id: 'custom',
submit() {},
cancel() {},
getState() {},
async *streamEvents() {},
};
registry.register(adapter);
assert.throws(() => registry.register(adapter), /already registered/);
});
test('Executor Gateway records a blocked idempotent job without calling an adapter', async () => {
let now = 1000;
const gateway = createExecutorGateway({
store: createInMemoryExecutorJobStore(),
nowMs: () => now,
});
const preview = await gateway.preview(request());
assert.equal(preview.executor, 'aider');
assert.equal(preview.fallbackExecutor, 'goosed');
assert.equal(preview.fallbackRegistered, true);
assert.equal(preview.fallbackAvailable, false);
assert.equal(preview.dispatchAllowed, false);
assert.equal(preview.gates.implementation, false);
assert.equal(preview.gates.adapterEnabled, false);
const first = await gateway.createJob(request());
assert.equal(first.created, true);
assert.equal(first.job.status, 'blocked');
assert.equal(first.job.attempts, 0);
assert.equal(first.job.reason, 'executor_dispatch_not_implemented');
now = 2000;
const repeated = await gateway.createJob(request());
assert.equal(repeated.created, false);
assert.equal(repeated.job.jobId, first.job.jobId);
assert.equal(repeated.job.createdAt, 1000);
});
test('Executor Gateway rejects idempotency key reuse with a different payload', async () => {
const gateway = createExecutorGateway();
await gateway.createJob(request());
await assert.rejects(
() => gateway.createJob(request({
jobId: 'job-2',
task: {
type: 'code_change',
instruction: 'A different instruction',
workspaceRef: { kind: 'mindspace-workspace', id: 'workspace-1' },
},
})),
(error) => error.code === 'EXECUTOR_IDEMPOTENCY_CONFLICT',
);
});
test('Executor Gateway cancellation is idempotent and never invokes a disabled adapter', async () => {
let now = 1000;
const gateway = createExecutorGateway({ nowMs: () => now });
await gateway.createJob(request());
now = 1500;
const cancelled = await gateway.cancel('job-1', { reason: 'user_cancelled' });
assert.equal(cancelled.status, 'cancelled');
assert.equal(cancelled.reason, 'user_cancelled');
assert.equal(cancelled.completedAt, 1500);
assert.deepEqual(await gateway.cancel('job-1'), cancelled);
assert.equal(await gateway.cancel('missing-job'), null);
});
test('Executor Gateway status remains hard-disabled with a non-durable reference store', () => {
const status = createExecutorGateway().status();
assert.equal(status.version, 'executor-gateway-status-v1');
assert.equal(status.dispatchImplemented, false);
assert.equal(status.executionEnabled, false);
assert.deepEqual(status.store, { kind: 'memory', durable: false });
});