Files
memind/mindspace-agent-jobs.mjs
T
2026-06-29 22:20:04 +08:00

735 lines
26 KiB
JavaScript

import crypto from 'node:crypto';
import path from 'node:path';
const JOB_TYPES = new Set(['generate_page', 'analyze_asset', 'summarize']);
const OUTPUT_TYPES = new Set(['page_draft', 'html_page', 'markdown']);
const ACTIVE_JOB_STATUSES = new Set(['queued', 'running']);
const RETRYABLE_ERROR_CODES = new Set([
'worker_crashed',
'worker_unavailable',
'model_rate_limited',
'network_temporary',
'storage_temporary',
'job_timed_out',
'invalid_agent_job_output',
]);
function asNumber(value) {
return Number(value ?? 0);
}
function agentJobError(message, code, details) {
return Object.assign(new Error(message), { code, details });
}
function dedupeStrings(values) {
return [...new Set((Array.isArray(values) ? values : []).map((value) => String(value).trim()).filter(Boolean))];
}
function normalizeInstruction(value) {
const instruction = String(value ?? '').normalize('NFKC').trim();
if (!instruction) throw agentJobError('任务说明不能为空', 'invalid_agent_job_input');
if (instruction.length > 4000) {
throw agentJobError('任务说明超过长度限制', 'invalid_agent_job_input');
}
return instruction;
}
function normalizeJobInput(input) {
const jobType = String(input.jobType ?? '').trim();
if (!JOB_TYPES.has(jobType)) {
throw agentJobError('不支持的任务类型', 'invalid_agent_job_input');
}
const outputType = String(input.outputType ?? '').trim();
if (!OUTPUT_TYPES.has(outputType)) {
throw agentJobError('不支持的输出类型', 'invalid_agent_job_input');
}
const allowedAssetIds = dedupeStrings(input.allowedAssetIds);
if (allowedAssetIds.length === 0) {
throw agentJobError('至少需要授权一个输入资产', 'invalid_agent_job_input');
}
return {
jobType,
instruction: normalizeInstruction(input.instruction),
outputType,
allowedAssetIds,
idempotencyKey: String(input.idempotencyKey ?? '').trim() || null,
outputCategoryId: input.outputCategoryId ? String(input.outputCategoryId) : null,
locale: String(input.locale ?? 'zh-CN').trim() || 'zh-CN',
timezone: String(input.timezone ?? 'Asia/Shanghai').trim() || 'Asia/Shanghai',
capabilities: {
network: Boolean(input.capabilities?.network),
shell: Boolean(input.capabilities?.shell),
createPage: input.capabilities?.createPage !== false,
},
};
}
function hashJobToken(token) {
return crypto.createHash('sha256').update(String(token)).digest('hex');
}
function jsonValue(value, fallback) {
if (value == null) return fallback;
if (typeof value === 'object') return value;
try {
return JSON.parse(String(value));
} catch {
return fallback;
}
}
function jobResponse(row, assets = []) {
if (!row) return null;
return {
id: row.id,
sessionId: row.session_id ?? null,
jobType: row.job_type,
instruction: row.instruction,
status: row.status,
outputType: row.output_type,
outputCategoryId: row.output_category_id,
outputCategoryCode: row.output_category_code ?? null,
progress: jsonValue(row.progress_json, { stage: row.status }),
permissionScope: jsonValue(row.permission_scope, {}),
userContext: jsonValue(row.user_context_json, {
locale: 'zh-CN',
timezone: 'Asia/Shanghai',
}),
resultPageId: row.result_page_id ?? null,
resultAssetId: row.result_asset_id ?? null,
errorCode: row.error_code ?? null,
errorMessage: row.error_message ?? null,
retryable: RETRYABLE_ERROR_CODES.has(row.error_code),
queuedAt: asNumber(row.queued_at),
startedAt: row.started_at == null ? null : asNumber(row.started_at),
completedAt: row.completed_at == null ? null : asNumber(row.completed_at),
expiresAt: row.expires_at == null ? null : asNumber(row.expires_at),
assets,
};
}
function outputContentFormat(outputType, input) {
if (input.contentFormat === 'html' || outputType === 'html_page') return 'html';
return 'markdown';
}
function progressPayload(input, fallbackStage) {
const stage = String(input?.stage ?? fallbackStage ?? '').trim() || fallbackStage || 'running';
const payload = { stage };
const message = String(input?.message ?? '').trim();
if (message) payload.message = message.slice(0, 500);
return payload;
}
function hasPathAccess(asset) {
return asset.status !== 'deleted' && asset.status !== 'quarantined' && asset.scan_status !== 'blocked';
}
function verifyTokenHash(expectedHash, token) {
if (!expectedHash || !token) return false;
const actualHash = hashJobToken(token);
const expected = Buffer.from(String(expectedHash), 'hex');
const actual = Buffer.from(actualHash, 'hex');
return expected.length === actual.length && crypto.timingSafeEqual(expected, actual);
}
export function createAgentJobService(pool, options = {}) {
const idFactory = options.idFactory ?? (() => crypto.randomUUID());
const nowFactory = options.nowFactory ?? (() => Date.now());
const tokenTtlMs = Number(options.tokenTtlMs ?? 30 * 60 * 1000);
const maxOutputBytes = Number(options.maxOutputBytes ?? 2 * 1024 * 1024);
const pageService = options.pageService;
const storageRoot = path.resolve(options.storageRoot ?? path.join(process.cwd(), 'data', 'mindspace'));
const absoluteStoragePath = (storageKey) => {
const resolved = path.resolve(storageRoot, storageKey);
if (resolved !== storageRoot && !resolved.startsWith(`${storageRoot}${path.sep}`)) {
throw new Error('存储路径越界');
}
return resolved;
};
const resolveStoragePathCandidates = (storageKey) => {
const normalized = String(storageKey ?? '').replace(/\\/g, '/');
const withoutMd = normalized.endsWith('.md') ? normalized.slice(0, -3) : normalized;
const match = withoutMd.match(/^users\/([^/]+)\/(assets|pages)\/([^/]+)\/(v\d+)$/);
if (!match) return [normalized];
const [, userId, scope, entityId, versionTag] = match;
return [
normalized,
`users/${userId}/${scope}/${entityId}/versions/${versionTag}.md`,
`users/${userId}/${scope}/${entityId}/versions/${versionTag}`,
].filter((candidate, index, list) => list.indexOf(candidate) === index);
};
const resolveReadableStoragePath = async (storageKey) => {
let lastError = null;
for (const candidate of resolveStoragePathCandidates(storageKey)) {
const absolutePath = absoluteStoragePath(candidate);
try {
await fs.stat(absolutePath);
return absolutePath;
} catch (error) {
if (error?.code === 'ENOENT') {
lastError = error;
continue;
}
throw error;
}
}
throw lastError ?? Object.assign(new Error('存储文件不存在'), { code: 'storage_not_found' });
};
const fetchJobAssets = async (jobId) => {
const [rows] = await pool.query(
`SELECT ja.asset_id, ja.asset_version_id, ja.permission, a.display_name, a.mime_type,
a.status, v.scan_status
FROM h5_agent_job_assets ja
JOIN h5_assets a ON a.id = ja.asset_id
JOIN h5_asset_versions v ON v.id = ja.asset_version_id
WHERE ja.job_id = ?
ORDER BY ja.created_at ASC`,
[jobId],
);
return rows.map((row) => ({
assetId: row.asset_id,
assetVersionId: row.asset_version_id,
permission: row.permission,
displayName: row.display_name,
mimeType: row.mime_type,
status: row.status,
scanStatus: row.scan_status,
}));
};
const fetchJobRow = async (jobId, userId = null) => {
const params = [jobId];
let sql =
`SELECT j.*, c.category_code AS output_category_code
FROM h5_agent_jobs j
JOIN h5_space_categories c ON c.id = j.output_category_id
WHERE j.id = ?`;
if (userId) {
sql += ' AND j.user_id = ?';
params.push(userId);
}
sql += ' LIMIT 1';
const [rows] = await pool.query(sql, params);
return rows[0] ?? null;
};
const createJob = async (userId, input) => {
const normalized = normalizeJobInput(input);
if (!pageService) {
throw agentJobError('页面服务未初始化', 'internal_error');
}
if (normalized.idempotencyKey) {
const [existingRows] = await pool.query(
`SELECT j.*, c.category_code AS output_category_code
FROM h5_agent_jobs j
JOIN h5_space_categories c ON c.id = j.output_category_id
WHERE j.user_id = ? AND j.idempotency_key = ?
LIMIT 1`,
[userId, normalized.idempotencyKey],
);
if (existingRows[0]) {
const assets = await fetchJobAssets(existingRows[0].id);
return jobResponse(existingRows[0], assets);
}
}
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const categoryParams = normalized.outputCategoryId
? [normalized.outputCategoryId, userId]
: [userId];
const [categoryRows] = await conn.query(
normalized.outputCategoryId
? `SELECT id, category_code
FROM h5_space_categories
WHERE id = ? AND user_id = ?
LIMIT 1
FOR UPDATE`
: `SELECT id, category_code
FROM h5_space_categories
WHERE user_id = ? AND category_code = 'draft'
LIMIT 1
FOR UPDATE`,
categoryParams,
);
const category = categoryRows[0];
if (!category) throw agentJobError('输出分类不存在', 'category_not_found');
if (!['draft', 'oa'].includes(category.category_code)) {
throw agentJobError('该分类不允许写入 Agent 输出', 'invalid_agent_job_input');
}
const placeholders = normalized.allowedAssetIds.map(() => '?').join(', ');
const [assetRows] = await conn.query(
`SELECT a.id, a.current_version_id, a.display_name, a.mime_type, a.status, v.scan_status
FROM h5_assets a
JOIN h5_asset_versions v ON v.id = a.current_version_id
WHERE a.user_id = ? AND a.id IN (${placeholders}) AND a.status <> 'deleted'
FOR UPDATE`,
[userId, ...normalized.allowedAssetIds],
);
const assetMap = new Map(assetRows.map((row) => [row.id, row]));
const missingIds = normalized.allowedAssetIds.filter((assetId) => !assetMap.has(assetId));
if (missingIds.length > 0) {
throw agentJobError('存在未授权或不存在的资产', 'asset_not_found', {
assetIds: missingIds,
});
}
const blockedAssets = assetRows.filter((row) => !hasPathAccess(row));
if (blockedAssets.length > 0) {
throw agentJobError('有输入资产尚未通过安全检查', 'security_scan_required', {
assetIds: blockedAssets.map((row) => row.id),
});
}
const jobId = idFactory();
const now = nowFactory();
await conn.query(
`INSERT INTO h5_agent_jobs
(id, user_id, job_type, instruction, permission_scope, user_context_json,
output_category_id, output_type, status, idempotency_key, progress_json,
queued_at, expires_at, updated_at, max_output_bytes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'queued', ?, ?, ?, ?, ?, ?)`,
[
jobId,
userId,
normalized.jobType,
normalized.instruction,
JSON.stringify({ capabilities: normalized.capabilities }),
JSON.stringify({ locale: normalized.locale, timezone: normalized.timezone }),
category.id,
normalized.outputType,
normalized.idempotencyKey,
JSON.stringify({ stage: 'queued' }),
now,
now + tokenTtlMs,
now,
maxOutputBytes,
],
);
for (const assetId of normalized.allowedAssetIds) {
const asset = assetMap.get(assetId);
await conn.query(
`INSERT INTO h5_agent_job_assets
(id, job_id, asset_id, asset_version_id, permission, created_at)
VALUES (?, ?, ?, ?, 'read', ?)`,
[idFactory(), jobId, asset.id, asset.current_version_id, now],
);
}
await conn.commit();
const created = await fetchJobRow(jobId, userId);
const assets = await fetchJobAssets(jobId);
return jobResponse(created, assets);
} catch (error) {
await conn.rollback();
throw error;
} finally {
conn.release();
}
};
const getJob = async (userId, jobId) => {
const row = await fetchJobRow(jobId, userId);
if (!row) throw agentJobError('任务不存在', 'agent_job_not_found');
const assets = await fetchJobAssets(jobId);
return jobResponse(row, assets);
};
const listJobs = async (userId, { limit = 20, offset = 0 } = {}) => {
const safeLimit = Math.min(Math.max(Number(limit) || 20, 1), 100);
const safeOffset = Math.max(Number(offset) || 0, 0);
const [countRows] = await pool.query(
`SELECT COUNT(*) AS total FROM h5_agent_jobs WHERE user_id = ?`,
[userId],
);
const total = Number(countRows[0]?.total ?? 0);
const [rows] = await pool.query(
`SELECT j.*, c.category_code AS output_category_code
FROM h5_agent_jobs j
JOIN h5_space_categories c ON c.id = j.output_category_id
WHERE j.user_id = ?
ORDER BY COALESCE(j.completed_at, j.started_at, j.queued_at) DESC
LIMIT ${safeLimit} OFFSET ${safeOffset}`,
[userId],
);
const jobs = await Promise.all(
rows.map(async (row) => jobResponse(row, await fetchJobAssets(row.id))),
);
return {
items: jobs,
total,
offset: safeOffset,
limit: safeLimit,
hasMore: safeOffset + jobs.length < total,
};
};
const cancelJob = async (userId, jobId) => {
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const [rows] = await conn.query(
`SELECT * FROM h5_agent_jobs
WHERE id = ? AND user_id = ?
LIMIT 1
FOR UPDATE`,
[jobId, userId],
);
const job = rows[0];
if (!job) throw agentJobError('任务不存在', 'agent_job_not_found');
if (!ACTIVE_JOB_STATUSES.has(job.status)) {
throw agentJobError('当前状态不允许取消任务', 'invalid_state_transition');
}
const now = nowFactory();
await conn.query(
`UPDATE h5_agent_jobs
SET status = 'cancelled', completed_at = ?, expires_at = ?, updated_at = ?,
job_token_hash = NULL, heartbeat_at = NULL, progress_json = ?
WHERE id = ?`,
[now, now, now, JSON.stringify({ stage: 'cancelled' }), jobId],
);
await conn.commit();
return getJob(userId, jobId);
} catch (error) {
await conn.rollback();
throw error;
} finally {
conn.release();
}
};
const retryJob = async (userId, jobId) => {
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const [rows] = await conn.query(
`SELECT * FROM h5_agent_jobs
WHERE id = ? AND user_id = ?
LIMIT 1
FOR UPDATE`,
[jobId, userId],
);
const job = rows[0];
if (!job) throw agentJobError('任务不存在', 'agent_job_not_found');
if (!['failed', 'timed_out', 'cancelled'].includes(job.status)) {
throw agentJobError('当前状态不允许重试任务', 'invalid_state_transition');
}
if (job.status === 'failed' && !RETRYABLE_ERROR_CODES.has(job.error_code)) {
throw agentJobError('该任务错误不可自动重试', 'invalid_state_transition');
}
const now = nowFactory();
await conn.query(
`UPDATE h5_agent_jobs
SET status = 'queued', progress_json = ?, started_at = NULL, completed_at = NULL,
error_code = NULL, error_message = NULL, result_page_id = NULL, result_asset_id = NULL,
job_token_hash = NULL, heartbeat_at = NULL, expires_at = ?, updated_at = ?
WHERE id = ?`,
[JSON.stringify({ stage: 'queued' }), now + tokenTtlMs, now, jobId],
);
await conn.commit();
return getJob(userId, jobId);
} catch (error) {
await conn.rollback();
throw error;
} finally {
conn.release();
}
};
// Marks a locked, queued job row as running and returns the claim payload.
// Caller must hold a row lock on `job` inside `conn`'s open transaction; this
// commits the transaction.
const finalizeClaim = async (conn, job) => {
const jobId = job.id;
const token = crypto.randomBytes(24).toString('base64url');
const now = nowFactory();
await conn.query(
`UPDATE h5_agent_jobs
SET status = 'running', started_at = COALESCE(started_at, ?), heartbeat_at = ?,
updated_at = ?, expires_at = ?, progress_json = ?, job_token_hash = ?
WHERE id = ?`,
[
now,
now,
now,
now + tokenTtlMs,
JSON.stringify({ stage: 'preparing_files' }),
hashJobToken(token),
jobId,
],
);
await conn.commit();
const assets = await fetchJobAssets(jobId);
const permissionScope = jsonValue(job.permission_scope, {});
const userContext = jsonValue(job.user_context_json, {
locale: 'zh-CN',
timezone: 'Asia/Shanghai',
});
return {
jobId,
userId: job.user_id,
jobToken: token,
instruction: job.instruction,
userContext,
allowedAssets: assets.map((asset) => ({
assetId: asset.assetId,
versionId: asset.assetVersionId,
permission: asset.permission,
displayName: asset.displayName,
mimeType: asset.mimeType,
downloadEndpoint: `/api/internal/agent/jobs/${jobId}/assets/${asset.assetId}`,
})),
output: {
categoryId: job.output_category_id,
categoryCode: job.output_category_code,
allowedTypes: [job.output_type],
maxBytes: asNumber(job.max_output_bytes),
},
capabilities: permissionScope.capabilities ?? {
network: false,
shell: false,
createPage: true,
},
expiresAt: now + tokenTtlMs,
};
};
// Atomically claim the oldest queued, unexpired job. Uses SKIP LOCKED so that
// multiple worker loops (across portal instances) pull distinct jobs without
// blocking each other. Returns null when the queue is empty.
const claimNextJob = async () => {
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const [rows] = await conn.query(
`SELECT j.*, c.category_code AS output_category_code
FROM h5_agent_jobs j
JOIN h5_space_categories c ON c.id = j.output_category_id
WHERE j.status = 'queued'
AND (j.expires_at IS NULL OR j.expires_at >= ?)
ORDER BY j.queued_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED`,
[nowFactory()],
);
const job = rows[0];
if (!job) {
await conn.commit();
return null;
}
return await finalizeClaim(conn, job);
} catch (error) {
await conn.rollback();
throw error;
} finally {
conn.release();
}
};
const claimJob = async (jobId) => {
const conn = await pool.getConnection();
try {
await conn.beginTransaction();
const [rows] = await conn.query(
`SELECT j.*, c.category_code AS output_category_code
FROM h5_agent_jobs j
JOIN h5_space_categories c ON c.id = j.output_category_id
WHERE j.id = ?
LIMIT 1
FOR UPDATE`,
[jobId],
);
const job = rows[0];
if (!job) throw agentJobError('任务不存在', 'agent_job_not_found');
if (job.status !== 'queued') {
throw agentJobError('任务当前不可领取', 'invalid_state_transition');
}
if (job.expires_at != null && asNumber(job.expires_at) < nowFactory()) {
throw agentJobError('任务已过期', 'agent_job_expired');
}
return await finalizeClaim(conn, job);
} catch (error) {
await conn.rollback();
throw error;
} finally {
conn.release();
}
};
const requireJobToken = async (jobId, token) => {
const [rows] = await pool.query(
`SELECT j.*, c.category_code AS output_category_code
FROM h5_agent_jobs j
JOIN h5_space_categories c ON c.id = j.output_category_id
WHERE j.id = ?
LIMIT 1`,
[jobId],
);
const job = rows[0];
if (!job) throw agentJobError('任务不存在', 'agent_job_not_found');
if (job.status !== 'running') {
throw agentJobError('任务未处于执行中状态', 'invalid_state_transition');
}
if (job.expires_at != null && asNumber(job.expires_at) < nowFactory()) {
throw agentJobError('任务 token 已过期', 'agent_job_expired');
}
if (!verifyTokenHash(job.job_token_hash, token)) {
throw agentJobError('任务 token 无效', 'agent_job_token_invalid');
}
return job;
};
const getAssetForJob = async (jobId, token, assetId) => {
await requireJobToken(jobId, token);
const [rows] = await pool.query(
`SELECT a.id, a.display_name, a.mime_type, a.status, v.id AS asset_version_id,
v.storage_key, v.scan_status
FROM h5_agent_job_assets ja
JOIN h5_assets a ON a.id = ja.asset_id
JOIN h5_asset_versions v ON v.id = ja.asset_version_id
WHERE ja.job_id = ? AND ja.asset_id = ?
LIMIT 1`,
[jobId, assetId],
);
const asset = rows[0];
if (!asset) throw agentJobError('任务资产不存在', 'asset_not_found');
if (!hasPathAccess(asset)) {
throw agentJobError('任务资产尚未通过安全检查', 'security_scan_required');
}
return {
assetId: asset.id,
assetVersionId: asset.asset_version_id,
displayName: asset.display_name,
mimeType: asset.mime_type,
path: await resolveReadableStoragePath(asset.storage_key),
};
};
const heartbeat = async (jobId, token, input) => {
const job = await requireJobToken(jobId, token);
const now = nowFactory();
const payload = progressPayload(input, 'running');
await pool.query(
`UPDATE h5_agent_jobs
SET heartbeat_at = ?, updated_at = ?, expires_at = ?, progress_json = ?
WHERE id = ?`,
[now, now, now + tokenTtlMs, JSON.stringify(payload), jobId],
);
return jobResponse({ ...job, progress_json: JSON.stringify(payload), heartbeat_at: now });
};
const completeJob = async (jobId, token, input) => {
const job = await requireJobToken(jobId, token);
const now = nowFactory();
if (input?.status === 'failed') {
const errorCode = String(input.errorCode ?? 'worker_crashed').trim() || 'worker_crashed';
const errorMessage = String(input.errorMessage ?? '任务执行失败').trim() || '任务执行失败';
await pool.query(
`UPDATE h5_agent_jobs
SET status = 'failed', error_code = ?, error_message = ?, completed_at = ?,
updated_at = ?, heartbeat_at = NULL, job_token_hash = NULL, progress_json = ?
WHERE id = ?`,
[errorCode, errorMessage.slice(0, 1000), now, now, JSON.stringify({ stage: 'failed' }), jobId],
);
return getJob(job.user_id, jobId);
}
const content = String(input?.content ?? '');
const outputBytes = Buffer.byteLength(content, 'utf8');
if (!content.trim()) {
throw agentJobError('任务输出内容不能为空', 'invalid_agent_job_output');
}
if (outputBytes > asNumber(job.max_output_bytes)) {
throw agentJobError('任务输出超过大小限制', 'page_content_too_large', {
maxBytes: asNumber(job.max_output_bytes),
});
}
const sourceAssetIds = dedupeStrings(input?.sourceAssetIds);
const boundAssets = await fetchJobAssets(jobId);
const boundAssetIdSet = new Set(boundAssets.map((asset) => asset.assetId));
const invalidSourceIds = sourceAssetIds.filter((assetId) => !boundAssetIdSet.has(assetId));
if (invalidSourceIds.length > 0) {
throw agentJobError('输出引用了未授权的输入资产', 'invalid_agent_job_output', {
assetIds: invalidSourceIds,
});
}
const page = await pageService.createFromAgent(
job.user_id,
{
title: input?.title,
summary: input?.summary,
content,
contentFormat: outputContentFormat(job.output_type, input),
pageType: input?.pageType,
templateId: input?.templateId,
categoryCode: job.output_category_code,
changeNote: `Agent job ${jobId}`,
},
{
jobId,
assetId: sourceAssetIds[0] ?? null,
assetIds: sourceAssetIds,
},
);
await pool.query(
`UPDATE h5_agent_jobs
SET status = 'completed', result_page_id = ?, completed_at = ?, updated_at = ?,
heartbeat_at = NULL, job_token_hash = NULL, progress_json = ?
WHERE id = ?`,
[page.id, now, now, JSON.stringify({ stage: 'completed' }), jobId],
);
return getJob(job.user_id, jobId);
};
// Restart resilience: a job left in 'running' by a crashed/restarted worker
// stops heartbeating. Mark such jobs failed with the retryable worker_crashed
// code so they surface to the user (and can be retried) instead of being stuck
// forever. We fail rather than auto-requeue to avoid poison-job loops, since
// there is no per-job attempt counter.
const reapStaleJobs = async (maxStaleMs = 5 * 60 * 1000) => {
const now = nowFactory();
const cutoff = now - Math.max(1, maxStaleMs);
const [result] = await pool.query(
`UPDATE h5_agent_jobs
SET status = 'failed', error_code = 'worker_crashed',
error_message = 'worker heartbeat timed out',
completed_at = ?, updated_at = ?, heartbeat_at = NULL, job_token_hash = NULL,
progress_json = ?
WHERE status = 'running'
AND heartbeat_at IS NOT NULL
AND heartbeat_at < ?`,
[now, now, JSON.stringify({ stage: 'failed' }), cutoff],
);
return result?.affectedRows ?? 0;
};
return {
createJob,
getJob,
listJobs,
cancelJob,
retryJob,
claimJob,
claimNextJob,
reapStaleJobs,
getAssetForJob,
heartbeat,
completeJob,
};
}
export const agentJobInternals = {
normalizeJobInput,
hashJobToken,
verifyTokenHash,
progressPayload,
};