Files
memind/services/orchestrator/executor-gateway.mjs
T

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