799 lines
26 KiB
JavaScript
799 lines
26 KiB
JavaScript
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_JOB_EVENT_VERSION = 'executor-job-event-v1';
|
|
const EXECUTOR_DISPATCH_IMPLEMENTED = true;
|
|
const MAX_INSTRUCTION_CHARACTERS = 32_000;
|
|
const MAX_EVENT_PAGE_SIZE = 500;
|
|
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 buildExecutorJobEvent({
|
|
eventId = crypto.randomUUID(),
|
|
jobId,
|
|
sequence = 0,
|
|
type,
|
|
timestamp = Date.now(),
|
|
data = null,
|
|
} = {}) {
|
|
const normalizedJobId = String(jobId ?? '').trim();
|
|
const normalizedType = String(type ?? '').trim();
|
|
if (!normalizedJobId) {
|
|
throw gatewayError('EXECUTOR_EVENT_JOB_ID_REQUIRED', 'Executor event requires jobId', 422);
|
|
}
|
|
if (!normalizedType) {
|
|
throw gatewayError('EXECUTOR_EVENT_TYPE_REQUIRED', 'Executor event requires type', 422);
|
|
}
|
|
return {
|
|
version: EXECUTOR_JOB_EVENT_VERSION,
|
|
eventId: String(eventId),
|
|
jobId: normalizedJobId,
|
|
sequence: Math.max(0, Number(sequence) || 0),
|
|
type: normalizedType,
|
|
timestamp: Number(timestamp) || Date.now(),
|
|
data: data == null ? null : clone(data),
|
|
};
|
|
}
|
|
|
|
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 (!/^[a-zA-Z0-9:_-]{1,128}$/.test(jobId)) {
|
|
throw gatewayError(
|
|
'EXECUTOR_JOB_ID_INVALID',
|
|
'Executor jobId must be a safe identifier no longer than 128 characters',
|
|
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, ''),
|
|
maxAttempts: clampInteger(source.controls?.maxAttempts, 3, 1, 20),
|
|
},
|
|
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 createWorkerQueueExecutorAdapter(descriptor, { enabled = false } = {}) {
|
|
const id = normalizeWorkflowEngineId(descriptor?.id, '');
|
|
if (!id) throw new Error('Worker queue executor adapter requires a valid id');
|
|
const reject = () => {
|
|
throw gatewayError(
|
|
'EXECUTOR_WORKER_CLAIM_REQUIRED',
|
|
`Executor adapter ${id} is dispatched through the worker claim protocol`,
|
|
);
|
|
};
|
|
return {
|
|
id,
|
|
label: descriptor.label ?? id,
|
|
kind: descriptor.kind ?? 'worker',
|
|
enabled,
|
|
dispatchImplemented: true,
|
|
status: enabled ? 'worker-queue' : 'disabled',
|
|
capabilities: Array.isArray(descriptor.capabilities)
|
|
? [...descriptor.capabilities, 'lease-fencing']
|
|
: ['lease-fencing'],
|
|
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 createPhase5ExecutorAdapterRegistry({ enabledExecutors = [] } = {}) {
|
|
const enabled = new Set(
|
|
(Array.isArray(enabledExecutors) ? enabledExecutors : [])
|
|
.map((id) => normalizeWorkflowEngineId(id, ''))
|
|
.filter(Boolean),
|
|
);
|
|
return createExecutorAdapterRegistry(
|
|
PHASE3_EXECUTOR_DESCRIPTORS.map((descriptor) => (
|
|
createWorkerQueueExecutorAdapter(descriptor, { enabled: enabled.has(descriptor.id) })
|
|
)),
|
|
);
|
|
}
|
|
|
|
export function listPhase5ExecutorAdapters(options = {}) {
|
|
return createPhase5ExecutorAdapterRegistry(options).list();
|
|
}
|
|
|
|
export function projectExecutorJobForRead(record) {
|
|
if (!record) return null;
|
|
const {
|
|
request: _request,
|
|
requestFingerprint: _requestFingerprint,
|
|
lease,
|
|
...safe
|
|
} = clone(record);
|
|
return {
|
|
...safe,
|
|
lease: lease
|
|
? {
|
|
workerId: lease.workerId ?? null,
|
|
acquiredAt: lease.acquiredAt ?? null,
|
|
heartbeatAt: lease.heartbeatAt ?? null,
|
|
expiresAt: lease.expiresAt ?? null,
|
|
}
|
|
: null,
|
|
};
|
|
}
|
|
|
|
export function createInMemoryExecutorJobStore() {
|
|
const byId = new Map();
|
|
const byIdempotencyKey = new Map();
|
|
const eventsByJobId = new Map();
|
|
const workersById = new Map();
|
|
|
|
function appendEvent(jobId, event) {
|
|
if (!event) return;
|
|
const events = eventsByJobId.get(jobId) ?? [];
|
|
events.push({
|
|
...clone(event),
|
|
jobId,
|
|
sequence: events.length + 1,
|
|
});
|
|
eventsByJobId.set(jobId, events);
|
|
}
|
|
|
|
function transitionResult(applied, record, reason = null) {
|
|
return {
|
|
applied,
|
|
record: record ? clone(record) : null,
|
|
reason,
|
|
};
|
|
}
|
|
|
|
return {
|
|
kind: 'memory',
|
|
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, { initialEvent = null } = {}) {
|
|
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);
|
|
appendEvent(record.jobId, initialEvent);
|
|
return { created: true, record: clone(record) };
|
|
},
|
|
async update(record, { event = null } = {}) {
|
|
if (!byId.has(record.jobId)) return null;
|
|
byId.set(record.jobId, clone(record));
|
|
appendEvent(record.jobId, event);
|
|
return clone(record);
|
|
},
|
|
async claimNext({
|
|
workerId,
|
|
executors,
|
|
now,
|
|
leaseToken,
|
|
leaseDurationMs,
|
|
eventFactory,
|
|
}) {
|
|
const allowedExecutors = new Set(executors);
|
|
const candidate = [...byId.values()]
|
|
.filter((record) => (
|
|
allowedExecutors.has(record.executor)
|
|
&& (
|
|
record.status === 'queued'
|
|
|| (
|
|
record.status === 'retryable'
|
|
&& Number(record.nextAttemptAt ?? 0) <= now
|
|
)
|
|
)
|
|
))
|
|
.sort((left, right) => (
|
|
Number(left.createdAt ?? 0) - Number(right.createdAt ?? 0)
|
|
|| String(left.jobId).localeCompare(String(right.jobId))
|
|
))[0];
|
|
if (!candidate) return null;
|
|
const updated = {
|
|
...clone(candidate),
|
|
status: 'leased',
|
|
reason: null,
|
|
attempts: Number(candidate.attempts ?? 0) + 1,
|
|
nextAttemptAt: null,
|
|
lease: {
|
|
token: leaseToken,
|
|
workerId,
|
|
acquiredAt: now,
|
|
heartbeatAt: now,
|
|
expiresAt: now + leaseDurationMs,
|
|
},
|
|
updatedAt: now,
|
|
completedAt: null,
|
|
};
|
|
byId.set(updated.jobId, clone(updated));
|
|
appendEvent(
|
|
updated.jobId,
|
|
typeof eventFactory === 'function' ? eventFactory(updated, candidate) : null,
|
|
);
|
|
return clone(updated);
|
|
},
|
|
async transition(jobId, {
|
|
expectedStatuses = [],
|
|
leaseToken = null,
|
|
updater,
|
|
eventFactory = null,
|
|
} = {}) {
|
|
const normalizedJobId = String(jobId ?? '').trim();
|
|
const current = byId.get(normalizedJobId);
|
|
if (!current) return transitionResult(false, null, 'not_found');
|
|
if (expectedStatuses.length && !expectedStatuses.includes(current.status)) {
|
|
return transitionResult(false, current, 'status_mismatch');
|
|
}
|
|
if (leaseToken != null && current.lease?.token !== leaseToken) {
|
|
return transitionResult(false, current, 'lease_mismatch');
|
|
}
|
|
const updated = updater(clone(current));
|
|
if (!updated || updated.jobId !== current.jobId) {
|
|
throw new Error('Executor job transition must preserve job identity');
|
|
}
|
|
byId.set(normalizedJobId, clone(updated));
|
|
appendEvent(
|
|
normalizedJobId,
|
|
typeof eventFactory === 'function' ? eventFactory(updated, current) : null,
|
|
);
|
|
return transitionResult(true, updated);
|
|
},
|
|
async listExpiredLeases({ now, limit = 100 } = {}) {
|
|
return [...byId.values()]
|
|
.filter((record) => (
|
|
['leased', 'running'].includes(record.status)
|
|
&& Number(record.lease?.expiresAt ?? 0) <= now
|
|
))
|
|
.sort((left, right) => (
|
|
Number(left.lease?.expiresAt ?? 0) - Number(right.lease?.expiresAt ?? 0)
|
|
))
|
|
.slice(0, clampInteger(limit, 100, 1, 500))
|
|
.map(clone);
|
|
},
|
|
async getQueueStats({ now = Date.now() } = {}) {
|
|
const records = [...byId.values()];
|
|
const counts = {};
|
|
for (const record of records) {
|
|
counts[record.status] = Number(counts[record.status] ?? 0) + 1;
|
|
}
|
|
return {
|
|
total: records.length,
|
|
counts,
|
|
claimable: records.filter((record) => (
|
|
record.status === 'queued'
|
|
|| (
|
|
record.status === 'retryable'
|
|
&& Number(record.nextAttemptAt ?? 0) <= now
|
|
)
|
|
)).length,
|
|
expiredLeases: records.filter((record) => (
|
|
['leased', 'running'].includes(record.status)
|
|
&& Number(record.lease?.expiresAt ?? 0) <= now
|
|
)).length,
|
|
};
|
|
},
|
|
async getAdmissionStats({
|
|
tenantId = null,
|
|
userId = null,
|
|
now = Date.now(),
|
|
windowMs = 60_000,
|
|
} = {}) {
|
|
const activeStatuses = new Set(['queued', 'retryable', 'leased', 'running']);
|
|
const records = [...byId.values()];
|
|
const matchesTenant = (record) => (
|
|
tenantId != null && record.request?.subject?.tenantId === tenantId
|
|
);
|
|
const matchesUser = (record) => (
|
|
userId != null && record.request?.subject?.userId === userId
|
|
);
|
|
return {
|
|
globalActive: records.filter((record) => activeStatuses.has(record.status)).length,
|
|
tenantActive: tenantId == null
|
|
? 0
|
|
: records.filter((record) => activeStatuses.has(record.status) && matchesTenant(record)).length,
|
|
userActive: userId == null
|
|
? 0
|
|
: records.filter((record) => activeStatuses.has(record.status) && matchesUser(record)).length,
|
|
tenantRecent: tenantId == null
|
|
? 0
|
|
: records.filter((record) => (
|
|
matchesTenant(record) && Number(record.createdAt ?? 0) >= now - windowMs
|
|
)).length,
|
|
userRecent: userId == null
|
|
? 0
|
|
: records.filter((record) => (
|
|
matchesUser(record) && Number(record.createdAt ?? 0) >= now - windowMs
|
|
)).length,
|
|
};
|
|
},
|
|
async recordWorkerHeartbeat(record) {
|
|
workersById.set(record.workerId, clone(record));
|
|
return clone(record);
|
|
},
|
|
async listWorkers({ now = Date.now(), staleAfterMs = 60_000 } = {}) {
|
|
return [...workersById.values()]
|
|
.sort((left, right) => Number(right.lastSeenAt) - Number(left.lastSeenAt))
|
|
.map((worker) => ({
|
|
...clone(worker),
|
|
stale: Number(worker.lastSeenAt ?? 0) < now - staleAfterMs,
|
|
}));
|
|
},
|
|
async listEvents(jobId, { after = 0, limit = MAX_EVENT_PAGE_SIZE } = {}) {
|
|
const cursor = Math.max(0, Number(after) || 0);
|
|
const pageSize = clampInteger(limit, MAX_EVENT_PAGE_SIZE, 1, MAX_EVENT_PAGE_SIZE);
|
|
return (eventsByJobId.get(String(jobId ?? '').trim()) ?? [])
|
|
.filter((event) => event.sequence > cursor)
|
|
.slice(0, pageSize)
|
|
.map(clone);
|
|
},
|
|
};
|
|
}
|
|
|
|
function validateJobStore(store) {
|
|
for (const method of [
|
|
'getById',
|
|
'getByIdempotencyKey',
|
|
'createIfAbsent',
|
|
'update',
|
|
'listEvents',
|
|
]) {
|
|
if (typeof store?.[method] !== 'function') {
|
|
throw new Error(`Executor job store missing method: ${method}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
export function createExecutorGateway({
|
|
registry = createPhase3ExecutorAdapterRegistry(),
|
|
store = createInMemoryExecutorJobStore(),
|
|
nowMs = Date.now,
|
|
executionEnabled = false,
|
|
admissionPolicy = null,
|
|
} = {}) {
|
|
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,
|
|
serviceEnabled: executionEnabled === true,
|
|
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_dispatch_not_implemented'
|
|
: executionEnabled !== true
|
|
? 'executor_execution_disabled'
|
|
: !adapter
|
|
? 'executor_adapter_unregistered'
|
|
: adapter.enabled !== true
|
|
? 'executor_adapter_disabled'
|
|
: !request.authorization.executionAllowed
|
|
? 'executor_execution_unauthorized'
|
|
: !request.policy.sideEffectsAllowed
|
|
? 'executor_side_effects_not_allowed'
|
|
: !request.task.workspaceRef
|
|
? 'executor_workspace_required'
|
|
: 'queued_for_worker',
|
|
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 };
|
|
}
|
|
let decision = previewNormalized(request);
|
|
if (decision.dispatchAllowed && admissionPolicy?.evaluate) {
|
|
const admission = await admissionPolicy.evaluate(request);
|
|
if (!admission.allowed) {
|
|
decision = {
|
|
...decision,
|
|
dispatchAllowed: false,
|
|
reason: admission.reason,
|
|
admission,
|
|
};
|
|
} else {
|
|
decision = { ...decision, admission };
|
|
}
|
|
}
|
|
const now = nowMs();
|
|
const dispatchAllowed = decision.dispatchAllowed;
|
|
const record = {
|
|
version: EXECUTOR_JOB_STATE_VERSION,
|
|
jobId: request.jobId,
|
|
idempotencyKey: request.idempotencyKey,
|
|
requestFingerprint,
|
|
executor: request.executor,
|
|
status: dispatchAllowed ? 'queued' : 'blocked',
|
|
reason: decision.reason,
|
|
attempts: 0,
|
|
maxAttempts: request.controls.maxAttempts,
|
|
dispatchAllowed,
|
|
cancellationAllowed: request.controls.cancellationAllowed,
|
|
timeoutMs: request.controls.timeoutMs,
|
|
fallbackExecutor: decision.fallbackExecutor,
|
|
fallbackRegistered: decision.fallbackRegistered,
|
|
fallbackAvailable: decision.fallbackAvailable,
|
|
decision,
|
|
request: dispatchAllowed ? request : null,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
startedAt: null,
|
|
completedAt: dispatchAllowed ? null : now,
|
|
};
|
|
const result = await store.createIfAbsent(record, {
|
|
initialEvent: buildExecutorJobEvent({
|
|
jobId: request.jobId,
|
|
sequence: 1,
|
|
type: dispatchAllowed ? 'executor_job_queued' : 'executor_job_blocked',
|
|
timestamp: now,
|
|
data: {
|
|
executor: request.executor,
|
|
status: record.status,
|
|
reason: record.reason,
|
|
dispatchAllowed,
|
|
},
|
|
}),
|
|
});
|
|
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();
|
|
const applyCancellation = (record) => ({
|
|
...record,
|
|
status: 'cancelled',
|
|
reason: String(reason).slice(0, 256),
|
|
lease: null,
|
|
updatedAt: now,
|
|
completedAt: now,
|
|
});
|
|
const eventForCancellation = (updated) => buildExecutorJobEvent({
|
|
jobId: updated.jobId,
|
|
type: 'executor_job_cancelled',
|
|
timestamp: now,
|
|
data: {
|
|
status: updated.status,
|
|
reason: updated.reason,
|
|
},
|
|
});
|
|
if (typeof store.transition === 'function') {
|
|
const result = await store.transition(current.jobId, {
|
|
expectedStatuses: ['blocked', 'queued', 'retryable', 'leased', 'running'],
|
|
updater: applyCancellation,
|
|
eventFactory: eventForCancellation,
|
|
});
|
|
return result.applied ? result.record : result.record;
|
|
}
|
|
const updated = applyCancellation(current);
|
|
return store.update(updated, { event: eventForCancellation(updated) });
|
|
}
|
|
|
|
return {
|
|
preview,
|
|
createJob,
|
|
cancel,
|
|
async getJob(jobId, { includeRequest = false } = {}) {
|
|
const record = await store.getById(jobId);
|
|
return includeRequest ? record : projectExecutorJobForRead(record);
|
|
},
|
|
async listEvents(jobId, { after = 0, limit = MAX_EVENT_PAGE_SIZE } = {}) {
|
|
const normalizedJobId = String(jobId ?? '').trim();
|
|
if (!normalizedJobId) return null;
|
|
const job = await store.getById(normalizedJobId);
|
|
if (!job) return null;
|
|
const cursor = Math.max(0, Number(after) || 0);
|
|
const events = await store.listEvents(normalizedJobId, {
|
|
after: cursor,
|
|
limit: clampInteger(limit, MAX_EVENT_PAGE_SIZE, 1, MAX_EVENT_PAGE_SIZE),
|
|
});
|
|
return {
|
|
jobId: normalizedJobId,
|
|
events,
|
|
nextCursor: events.at(-1)?.sequence ?? cursor,
|
|
};
|
|
},
|
|
listAdapters() {
|
|
return registry.list();
|
|
},
|
|
status() {
|
|
return {
|
|
version: 'executor-gateway-status-v1',
|
|
dispatchImplemented: EXECUTOR_DISPATCH_IMPLEMENTED,
|
|
executionEnabled: executionEnabled === true,
|
|
store: {
|
|
kind: String(store.kind ?? (store.durable ? 'durable' : 'memory')),
|
|
durable: store.durable === true,
|
|
},
|
|
adapters: registry.list(),
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
export const executorGatewayInternals = {
|
|
EXECUTOR_DISPATCH_IMPLEMENTED,
|
|
EXECUTOR_JOB_EVENT_VERSION,
|
|
EXECUTOR_JOB_REQUEST_VERSION,
|
|
EXECUTOR_JOB_STATE_VERSION,
|
|
MAX_EVENT_PAGE_SIZE,
|
|
MAX_INSTRUCTION_CHARACTERS,
|
|
PHASE3_EXECUTOR_DESCRIPTORS,
|
|
TERMINAL_JOB_STATUSES,
|
|
fingerprint,
|
|
projectExecutorJobForRead,
|
|
stableValue,
|
|
};
|