new-erp-mcp 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,106 @@
1
+ const fs = require('node:fs/promises');
2
+ const path = require('node:path');
3
+
4
+ const JSON_BUDGET = 10000;
5
+ const PREVIEW_BUDGET = 2200;
6
+ const isPrimitive = value => value === null || ['string', 'boolean', 'number'].includes(typeof value);
7
+ const clipped = (value, bytes) => Buffer.from(value).subarray(0, bytes).toString('utf8');
8
+
9
+ class StreamFailure extends Error {
10
+ constructor(partialResult, cause) {
11
+ super('Page stream did not finish.', { cause });
12
+ this.partialResult = partialResult;
13
+ }
14
+ }
15
+
16
+ function preview(value) {
17
+ const rows = Array.isArray(value) ? value : Array.isArray(value?.list) ? value.list : [value];
18
+ const list = [];
19
+ for (const row of rows.slice(0, 3)) {
20
+ const fields = [];
21
+ function visit(item, prefix = '', depth = 0) {
22
+ if (!item || typeof item !== 'object' || depth > 3) return;
23
+ for (const [key, val] of Object.entries(item)) {
24
+ const name = prefix ? `${prefix}.${key}` : key;
25
+ if (/^rawJson$/i.test(key)) continue;
26
+ if (isPrimitive(val)) fields.push([name, typeof val === 'string' && !/(?:url|asin|sku)$/i.test(key) ? clipped(val, 180) : val]);
27
+ else if (!Array.isArray(val)) visit(val, name, depth + 1);
28
+ }
29
+ }
30
+ if (isPrimitive(row)) fields.push(['value', typeof row === 'string' ? clipped(row, 180) : row]);
31
+ else visit(row);
32
+ const identity = [];
33
+ for (const pattern of [/(^|\.)asin$/i, /(^|\.)sku$/i, /(^|\.)image.?url$/i]) {
34
+ const field = fields.find(([key]) => pattern.test(key));
35
+ if (field) identity.push(field);
36
+ }
37
+ const ordered = [...identity, ...fields.filter(field => !identity.includes(field))];
38
+ const item = {};
39
+ for (const [key, val] of ordered) {
40
+ item[key] = val;
41
+ if (Buffer.byteLength(JSON.stringify([...list, item])) > PREVIEW_BUDGET) delete item[key];
42
+ }
43
+ if (Object.keys(item).length) list.push(item);
44
+ }
45
+ return list;
46
+ }
47
+
48
+ async function metadata(file, mediaType, extra = {}) {
49
+ const stat = await fs.stat(file);
50
+ await fs.chmod(file, 0o600);
51
+ return { path: file, mediaType, bytes: stat.size, complete: true, ...extra };
52
+ }
53
+
54
+ async function exclusive(file, value, mediaType, extra) {
55
+ await fs.writeFile(file, value, { flag: 'wx', mode: 0o600 });
56
+ return metadata(file, mediaType, extra);
57
+ }
58
+
59
+ async function adaptResult(value, { dir, outputPath }) {
60
+ if (Buffer.isBuffer(value) || Buffer.isBuffer(value?.file)) {
61
+ const buffer = Buffer.isBuffer(value) ? value : value.file;
62
+ const artifact = await exclusive(path.join(dir, 'export.xlsx'), buffer, 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
63
+ return { artifact, ...(typeof value?.taskId === 'string' ? { taskId: value.taskId } : {}), ...(Number.isFinite(value?.boxes) ? { boxes: value.boxes } : {}) };
64
+ }
65
+ const output = Array.isArray(value) && value.length === 1 ? value[0] : value;
66
+ if (output?.output && output.output === outputPath) {
67
+ const artifact = await metadata(outputPath, outputPath.endsWith('.csv') ? 'text/csv' : 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
68
+ return { artifact, ...(typeof output.taskId === 'string' ? { taskId: output.taskId } : {}), ...(Number.isFinite(output.boxes) ? { boxes: output.boxes } : {}), ...(output.encoding ? { encoding: output.encoding } : {}) };
69
+ }
70
+ const data = Array.isArray(value) ? { list: value } : value;
71
+ const json = JSON.stringify(data ?? null);
72
+ if (Buffer.byteLength(json) <= JSON_BUDGET) return { data: data ?? null };
73
+ const artifact = await exclusive(path.join(dir, 'result.json'), json, 'application/json');
74
+ const result = { artifact, preview: preview(data) };
75
+ if (Array.isArray(data?.list)) result.rowCount = data.list.length;
76
+ for (const field of ['page', 'pageSize', 'totalCount', 'fetchedCount', 'complete']) {
77
+ if (isPrimitive(data?.[field]) && data[field] !== undefined) result[field] = data[field];
78
+ }
79
+ return result;
80
+ }
81
+
82
+ async function consumePages(iterator, { dir, active }) {
83
+ const file = path.join(dir, 'pages.ndjson');
84
+ const handle = await fs.open(file, 'wx', 0o600);
85
+ let pageCount = 0; let rowCount = 0; let last = {};
86
+ let error;
87
+ try {
88
+ for await (const page of iterator) {
89
+ if (!active()) throw new Error('Job stopped');
90
+ await handle.writeFile(`${JSON.stringify(page)}\n`);
91
+ pageCount += 1;
92
+ rowCount += Array.isArray(page?.list) ? page.list.length : 0;
93
+ last = page;
94
+ }
95
+ } catch (cause) { error = cause; }
96
+ finally { await handle.close(); }
97
+ const artifact = await metadata(file, 'application/x-ndjson', { complete: !error, pageCount, rowCount });
98
+ const result = { artifact, pageCount, rowCount };
99
+ for (const field of ['page', 'pageSize', 'totalCount', 'fetchedCount', 'complete']) {
100
+ if (isPrimitive(last?.[field]) && last[field] !== undefined) result[field] = last[field];
101
+ }
102
+ if (error) { delete result.complete; throw new StreamFailure(result, error); }
103
+ return result;
104
+ }
105
+
106
+ module.exports = { adaptResult, consumePages, StreamFailure };
@@ -0,0 +1,86 @@
1
+ const { readSession, removeSession, sessionExpired } = require('./local-session.js');
2
+ const { getBaseUrl, normalizeBaseUrl } = require('./shared.js');
3
+ const { runBrowserLogin } = require('./login-browser.js');
4
+
5
+ function loginRequired() {
6
+ return Object.assign(new Error('ERP 尚未登录或会话已失效'), { code: 'LOGIN_REQUIRED' });
7
+ }
8
+
9
+ function createAuth({ openBrowser, browserTimeoutMs = 300_000 } = {}) {
10
+ let pending;
11
+ let closed = false;
12
+ let lastMessage;
13
+
14
+ function requireSession() {
15
+ let session;
16
+ try { session = readSession(); } catch { throw loginRequired(); }
17
+ if (!session || sessionExpired(session)) throw loginRequired();
18
+ try { normalizeBaseUrl(session.baseUrl); } catch { throw loginRequired(); }
19
+ return session;
20
+ }
21
+
22
+ function status() {
23
+ let session;
24
+ try { session = requireSession(); } catch {}
25
+ return {
26
+ state: pending ? 'login_pending' : session ? 'authenticated' : 'login_required',
27
+ loggedIn: Boolean(session),
28
+ ...(session ? { baseUrl: session.baseUrl, username: session.username, expireTime: session.expireTime } : {}),
29
+ ...(pending?.url ? { url: pending.url } : {}),
30
+ ...(lastMessage ? { message: lastMessage } : {}),
31
+ };
32
+ }
33
+
34
+ async function login() {
35
+ if (closed) throw Object.assign(new Error('MCP 登录服务已关闭'), { code: 'SERVICE_CLOSED' });
36
+ if (pending) return pending.ready;
37
+ let baseUrl = '', username = '';
38
+ try { baseUrl = getBaseUrl(); } catch {}
39
+ try { username = readSession()?.username || ''; } catch {}
40
+ const controller = new AbortController();
41
+ let resolveReady, rejectReady;
42
+ const ready = new Promise((resolve, reject) => { resolveReady = resolve; rejectReady = reject; });
43
+ const attempt = { controller, ready, url: undefined, done: undefined };
44
+ pending = attempt;
45
+ lastMessage = undefined;
46
+ attempt.done = runBrowserLogin({ defaults: { baseUrl, username }, signal: controller.signal,
47
+ timeoutMs: browserTimeoutMs, ...(openBrowser === undefined ? {} : { openBrowser }),
48
+ onReady(url) {
49
+ attempt.url = url;
50
+ resolveReady({ state: 'login_pending', url, expiresInSeconds: Math.ceil(browserTimeoutMs / 1000) });
51
+ },
52
+ }).then(() => {
53
+ lastMessage = undefined;
54
+ }, () => {
55
+ lastMessage = '网页登录未完成;可重新启动登录。';
56
+ rejectReady(Object.assign(new Error('无法完成本机网页登录,请重试'), { code: 'LOGIN_INCOMPLETE' }));
57
+ }).finally(() => {
58
+ if (pending === attempt) pending = undefined;
59
+ });
60
+ return ready;
61
+ }
62
+
63
+ async function stopPending() {
64
+ const attempt = pending;
65
+ if (attempt) {
66
+ attempt.controller.abort();
67
+ await attempt.done;
68
+ }
69
+ }
70
+
71
+ async function logout() {
72
+ await stopPending();
73
+ removeSession();
74
+ lastMessage = undefined;
75
+ return status();
76
+ }
77
+
78
+ async function close() {
79
+ closed = true;
80
+ await stopPending();
81
+ }
82
+
83
+ return { login, status, logout, requireSession, close };
84
+ }
85
+
86
+ module.exports = { createAuth };
@@ -0,0 +1,124 @@
1
+ const { z } = require('zod');
2
+ const { commands } = require('./new-erp.js');
3
+
4
+ const hiddenArgs = new Set(['username', 'password', 'token', 'output']);
5
+ const registry = new Map();
6
+ for (const [cliName, command] of Object.entries(commands)) {
7
+ if (!['read', 'export', 'stream', 'local'].includes(command.kind)) {
8
+ throw new Error(`Command must declare a supported kind: ${cliName}`);
9
+ }
10
+ if (command.kind === 'local') continue;
11
+ const name = cliName === 'options' ? 'erp_options' : cliName.replaceAll('-', '_');
12
+ registry.set(name, { name, description: command.description, effect: command.kind, script: command.script });
13
+ }
14
+
15
+ registry.set('export_game_products', {
16
+ name: 'export_game_products', description: '导出在用或停用商品 XLSX', effect: 'export',
17
+ api: 'exportGameProducts',
18
+ schema: z.strictObject({
19
+ fields: z.array(z.string().trim().min(1)).min(1)
20
+ .refine((fields) => new Set(fields).size === fields.length, 'fields must be unique')
21
+ .default(['sku', 'name_cn', 'price']).describe('ERP 导出字段名;非空且不重复'),
22
+ disused: z.boolean().default(false).describe('导出停用商品'),
23
+ }),
24
+ });
25
+ registry.set('export_outer_packages', {
26
+ name: 'export_outer_packages', description: '导出外包装规格 XLSX', effect: 'export',
27
+ api: 'exportOuterPackages',
28
+ schema: z.strictObject({
29
+ flowStatus: z.union([z.enum(['1', '2', '3', '4', '5']), z.number().int().min(1).max(5)])
30
+ .describe('外包装流程状态,1–5'),
31
+ }),
32
+ });
33
+
34
+ function summary(entry) {
35
+ return { name: entry.name, description: entry.description, effect: entry.effect };
36
+ }
37
+
38
+ function listOperations(query = '') {
39
+ if (typeof query !== 'string') throw new TypeError('query must be a string');
40
+ const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
41
+ return [...registry.values()].filter((entry) =>
42
+ words.every((word) => `${entry.name} ${entry.description}`.toLowerCase().includes(word))).map(summary);
43
+ }
44
+
45
+ function entryFor(name) {
46
+ const entry = registry.get(name);
47
+ if (!entry) throw new Error('Unknown ERP operation');
48
+ return entry;
49
+ }
50
+
51
+ function metadata(entry) {
52
+ if (entry.api) return { schema: entry.schema, args: [] };
53
+ const implementation = require(`./${entry.script}`);
54
+ const args = implementation.args.filter((arg) => !hiddenArgs.has(arg.name));
55
+ const shape = {};
56
+ for (const arg of args) {
57
+ let field;
58
+ if (arg.type === 'string') field = z.string();
59
+ else if (arg.type === 'int') field = z.number().int();
60
+ else if (arg.type === 'bool') field = z.boolean();
61
+ else throw new Error(`Unsupported argument type: ${arg.type}`);
62
+ field = arg.default === undefined ? field.optional() : field.default(arg.default);
63
+ shape[arg.name] = field.describe(arg.help);
64
+ }
65
+ return { schema: z.strictObject(shape), args };
66
+ }
67
+
68
+ function requirementsFor(entry, args) {
69
+ const requirements = ['Requires a fresh saved personal ERP login. Business validators enforce required filters and valid values.'];
70
+ if (entry.effect === 'export') requirements.push('Requires a stable requestId. Files are saved to a server-managed private artifact.');
71
+ for (const arg of args.filter((field) => field.choices)) {
72
+ requirements.push(`${arg.name}: ${arg.choices.join(', ')} (runtime aliases may also be accepted)`);
73
+ }
74
+ if (entry.name.startsWith('packing_order_')) {
75
+ requirements.push('Supply packingNo alone, or all of project, warehouses, startDate, endDate. Supplying any historical field requires all four.');
76
+ }
77
+ if (entry.name === 'packing_detail_export') {
78
+ requirements.push('project, warehouses, startDate, endDate are always required. taskId resumes only a matching existing ERP export.');
79
+ }
80
+ if (entry.name === 'export_game_products') requirements.push('fields accepts nonempty unique ERP field names; the three defaults are examples, not an allowlist.');
81
+ const scope = {
82
+ game_product_list: 'ERP 搜索可能模糊匹配,需核对返回 SKU;结果仅为当前页。',
83
+ game_product_batch: 'found 表示 ERP 搜索有返回,不保证精确 SKU;每个 SKU 只查一页,结合 querySku 和 totalCount 判断范围。',
84
+ product_detail: 'id 与 asin 必须二选一。ASIN 保留全部精确 SKU 和店铺匹配;imageUrl 是 ERP 原始路径,不保证有完整图库。',
85
+ inventory_list: '预计可用不代表实际签收;保留不同批次、BOM,不自动合并为单一版本。',
86
+ supply_evidence: '计划不是完成凭证;数组不带分页完整性元数据,不推断全量。需要完整范围时显式使用 fetchAll。',
87
+ stocking_assembly_inbound: '仅统计精确 SKU 关联的备货拼装实际入库凭证,不以计划量或预测库存代替。',
88
+ packing_detail: '保留全部 SKU、BOM、包装版本和原始单位,不自动选择首个版本或猜测单位。',
89
+ outer_package_list: '保留不同包装规格及 BOM 版本,不自动选择首条或把未知数量补零。',
90
+ product_declaration_list: 'SKU 筛选可能模糊匹配,需核对返回的 SKU 与国家。',
91
+ };
92
+ if (scope[entry.name]) requirements.push(scope[entry.name]);
93
+ return requirements;
94
+ }
95
+
96
+ function describeOperation(name) {
97
+ const entry = entryFor(name);
98
+ const { schema, args } = metadata(entry);
99
+ const inputSchema = z.toJSONSchema(schema, { io: 'input', unrepresentable: 'any' });
100
+ return { ...summary(entry), inputSchema, requirements: requirementsFor(entry, args), execution: 'job' };
101
+ }
102
+
103
+ function getOperation(name) {
104
+ const entry = entryFor(name);
105
+ const { schema } = metadata(entry);
106
+ return {
107
+ ...summary(entry), schema,
108
+ invoke(params = {}, hooks = {}) {
109
+ const options = schema.parse(params);
110
+ if (entry.api) return import('../index.mjs').then((api) => api[entry.api](options));
111
+ const implementation = require(`./${entry.script}`);
112
+ if (entry.effect === 'export') {
113
+ if (typeof hooks.outputPath !== 'string' || !hooks.outputPath.trim()) {
114
+ throw new TypeError('Export requires a server-owned outputPath');
115
+ }
116
+ options.output = hooks.outputPath;
117
+ if (entry.name === 'packing_detail_export') options.onProgress = hooks.onProgress;
118
+ }
119
+ return implementation[entry.effect === 'stream' ? 'pages' : 'main'](options);
120
+ },
121
+ };
122
+ }
123
+
124
+ module.exports = { listOperations, describeOperation, getOperation };
@@ -0,0 +1,65 @@
1
+ const fs = require('node:fs/promises');
2
+ const path = require('node:path');
3
+ const os = require('node:os');
4
+ const crypto = require('node:crypto');
5
+
6
+ function digest(value) {
7
+ return crypto.createHash('sha256').update(value).digest('hex');
8
+ }
9
+
10
+ function canonical(value) {
11
+ if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`;
12
+ if (value && typeof value === 'object') return `{${Object.keys(value).filter(key => value[key] !== undefined).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`;
13
+ return JSON.stringify(value);
14
+ }
15
+
16
+ function ownerOf(session) {
17
+ if (!session?.baseUrl || (session.userId == null && !session.username)) throw failure('AUTH_REQUIRED', 'A personal ERP session is required.');
18
+ const url = new URL(session.baseUrl);
19
+ url.hash = ''; url.search = '';
20
+ if (url.username || url.password) throw failure('AUTH_REQUIRED', 'A personal ERP session is required.');
21
+ return digest(canonical([url.toString().replace(/\/+$/, ''), session.userId != null ? ['id', String(session.userId)] : ['name', session.username]]));
22
+ }
23
+
24
+ function failure(code, message) {
25
+ return Object.assign(new Error(message), { code });
26
+ }
27
+
28
+ function alive(pid) {
29
+ if (!Number.isSafeInteger(pid) || pid < 1) return true;
30
+ try { process.kill(pid, 0); return true; } catch (error) { return error.code !== 'ESRCH'; }
31
+ }
32
+
33
+ function createStore(rootDir) {
34
+ const root = path.resolve(rootDir || process.env.NEW_ERP_EXPORT_DIR || path.join(os.homedir(), 'Downloads', 'new-erp'));
35
+ const jobsDir = path.join(root, '.mcp-jobs');
36
+ async function privateDir(dir) {
37
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
38
+ await fs.chmod(dir, 0o700);
39
+ }
40
+ async function init() {
41
+ await fs.mkdir(root, { recursive: true, mode: 0o700 });
42
+ await privateDir(jobsDir);
43
+ await privateDir(path.join(jobsDir, 'requests'));
44
+ await privateDir(path.join(jobsDir, 'exports'));
45
+ }
46
+ function jobDir(id) {
47
+ if (!/^[0-9a-f-]{36}$/.test(id)) throw failure('NOT_FOUND', 'Job not found.');
48
+ return path.join(jobsDir, id);
49
+ }
50
+ async function read(id) {
51
+ try { return JSON.parse(await fs.readFile(path.join(jobDir(id), 'job.json'), 'utf8')); }
52
+ catch (error) { if (error.code === 'ENOENT') throw failure('NOT_FOUND', 'Job not found.'); throw error; }
53
+ }
54
+ async function write(record) {
55
+ const dir = jobDir(record.jobId);
56
+ const temp = path.join(dir, `${crypto.randomUUID()}.tmp`);
57
+ try {
58
+ await fs.writeFile(temp, JSON.stringify(record), { flag: 'wx', mode: 0o600 });
59
+ await fs.rename(temp, path.join(dir, 'job.json'));
60
+ } finally { await fs.rm(temp, { force: true }); }
61
+ }
62
+ return { root, jobsDir, init, jobDir, read, write, privateDir };
63
+ }
64
+
65
+ module.exports = { createStore, ownerOf, canonical, digest, failure, alive };
@@ -0,0 +1,277 @@
1
+ const fs = require('node:fs/promises');
2
+ const path = require('node:path');
3
+ const crypto = require('node:crypto');
4
+ const { withErpContext, AuthRequiredError, redactSensitiveText } = require('./shared.js');
5
+ const { createStore, ownerOf, canonical, digest, failure, alive } = require('./mcp-job-store.js');
6
+ const { adaptResult, consumePages, StreamFailure } = require('./mcp-artifacts.js');
7
+
8
+ const ACTIVE = new Set(['queued', 'running']);
9
+ const pause = ms => new Promise(resolve => setTimeout(resolve, ms));
10
+
11
+ function createJobs({ rootDir, getOperation, waitMs = 750 } = {}) {
12
+ if (typeof getOperation !== 'function') throw new TypeError('getOperation is required');
13
+ if (!Number.isFinite(waitMs) || waitMs < 0 || waitMs > 5000) throw new TypeError('waitMs must be between 0 and 5000');
14
+ const store = createStore(rootDir);
15
+ const instance = crypto.randomUUID();
16
+ const local = new Map();
17
+ const admissions = new Set();
18
+ let closed = false;
19
+
20
+ function receipt(record, note) {
21
+ const result = {};
22
+ for (const key of ['jobId', 'operation', 'state', 'createdAt', 'updatedAt', 'progress', 'result', 'error']) {
23
+ if (record[key] !== undefined) result[key] = record[key];
24
+ }
25
+ if (ACTIVE.has(record.state)) result.pollAfterMs = 1000;
26
+ if (note) result.error = note;
27
+ return structuredClone(result);
28
+ }
29
+
30
+ async function owned(jobId, session) {
31
+ const owner = ownerOf(session);
32
+ const record = await store.read(jobId);
33
+ if (record.owner !== owner) throw failure('NOT_FOUND', 'Job not found.');
34
+ return record;
35
+ }
36
+
37
+ function persist(entry) {
38
+ const copy = structuredClone(entry.record);
39
+ const next = entry.writes.catch(() => {}).then(() => store.write(copy));
40
+ entry.writes = next;
41
+ return next;
42
+ }
43
+
44
+ async function status(jobId, session) {
45
+ const record = await owned(jobId, session);
46
+ const entry = local.get(jobId);
47
+ if (entry) return receipt(entry.record);
48
+ if (ACTIVE.has(record.state)) {
49
+ if (!alive(record.process?.pid)) {
50
+ record.state = 'interrupted';
51
+ record.updatedAt = new Date().toISOString();
52
+ record.error = { code: 'PROCESS_INTERRUPTED', message: 'The local worker stopped. No automatic retry was made.' };
53
+ await store.write(record);
54
+ } else return receipt(record, { code: 'STATE_UNCERTAIN', message: 'Another local worker owns this job; its last saved state is shown.' });
55
+ }
56
+ return receipt(record);
57
+ }
58
+
59
+ async function list(session, { limit = 20 } = {}) {
60
+ session = { ...session };
61
+ const owner = ownerOf(session);
62
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) throw failure('INVALID_INPUT', 'limit must be an integer between 1 and 100.');
63
+ let entries;
64
+ try { entries = await fs.readdir(store.jobsDir, { withFileTypes: true }); }
65
+ catch (error) { if (error.code === 'ENOENT') return { jobs: [] }; throw error; }
66
+ const records = [];
67
+ for (const entry of entries) {
68
+ if (!entry.isDirectory() || !/^[0-9a-f-]{36}$/.test(entry.name)) continue;
69
+ try {
70
+ const record = await store.read(entry.name);
71
+ if (record.owner === owner) records.push(record);
72
+ } catch (error) { if (error.code !== 'NOT_FOUND') throw error; }
73
+ }
74
+ records.sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.jobId.localeCompare(a.jobId));
75
+ const jobs = [];
76
+ for (const record of records.slice(0, limit)) {
77
+ const { result, ...summary } = await status(record.jobId, session);
78
+ jobs.push({ ...summary, hasResult: Boolean(result), ...(result?.artifact ? { artifact: result.artifact } : {}) });
79
+ }
80
+ return { jobs };
81
+ }
82
+
83
+ async function exportLock(entry) {
84
+ const lock = path.join(store.jobsDir, 'exports', entry.record.owner);
85
+ const reaper = `${lock}.reaping`;
86
+ const identity = { pid: process.pid, instance, jobId: entry.record.jobId };
87
+ let uncertain = 0;
88
+ const incomplete = () => failure('EXPORT_LOCK_UNCERTAIN', 'Export coordination is incomplete. No new ERP request was made; inspect the local task history before retrying.');
89
+ function waitForPublication() { if (++uncertain >= 10) throw incomplete(); }
90
+ while (!closed && entry.record.state === 'queued') {
91
+ try { await fs.access(reaper); waitForPublication(); await pause(20); continue; }
92
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
93
+ try {
94
+ await fs.mkdir(lock, { mode: 0o700 });
95
+ await fs.writeFile(path.join(lock, 'owner.json'), JSON.stringify(identity), { flag: 'wx', mode: 0o600 });
96
+ return async () => {
97
+ const current = JSON.parse(await fs.readFile(path.join(lock, 'owner.json'), 'utf8'));
98
+ if (current.instance === instance && current.jobId === identity.jobId) await fs.rm(lock, { recursive: true, force: true });
99
+ };
100
+ } catch (error) { if (error.code !== 'EEXIST') throw error; }
101
+ let holder;
102
+ try { holder = JSON.parse(await fs.readFile(path.join(lock, 'owner.json'), 'utf8')); }
103
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
104
+ if (!holder) waitForPublication();
105
+ else {
106
+ if (!Number.isSafeInteger(holder.pid) || holder.pid < 1 || typeof holder.instance !== 'string' || typeof holder.jobId !== 'string') throw incomplete();
107
+ uncertain = 0;
108
+ }
109
+ if (holder && !alive(holder.pid)) {
110
+ try {
111
+ await fs.mkdir(reaper, { mode: 0o700 });
112
+ } catch (error) { if (error.code === 'EEXIST') { await pause(20); continue; } throw error; }
113
+ try {
114
+ const current = JSON.parse(await fs.readFile(path.join(lock, 'owner.json'), 'utf8'));
115
+ if (current.instance === holder.instance && !alive(current.pid)) await fs.rm(lock, { recursive: true, force: true });
116
+ } catch (error) { if (error.code !== 'ENOENT') throw error; }
117
+ finally { await fs.rmdir(reaper); }
118
+ }
119
+ await pause(20);
120
+ }
121
+ return null;
122
+ }
123
+
124
+ async function run(entry, definition, session) {
125
+ let release;
126
+ const active = () => !closed && ACTIVE.has(entry.record.state);
127
+ try {
128
+ if (definition.effect === 'export') release = await exportLock(entry);
129
+ if (!active()) return;
130
+ entry.record.state = 'running';
131
+ entry.record.updatedAt = new Date().toISOString();
132
+ await persist(entry);
133
+ if (!active()) return;
134
+ const dir = store.jobDir(entry.record.jobId);
135
+ const outputPath = path.join(dir, entry.record.operation === 'sales_volume_export' ? 'export.csv' : 'export.xlsx');
136
+ const result = await withErpContext(session, async () => {
137
+ const value = await definition.invoke(structuredClone(entry.record.params), {
138
+ outputPath,
139
+ onProgress: async progress => {
140
+ if (!active()) throw failure('JOB_INTERRUPTED', 'The local worker stopped.');
141
+ const saved = {};
142
+ for (const key of ['taskId', 'stage', 'status', 'phase', 'boxes']) {
143
+ if (typeof progress?.[key] === 'string') saved[key] = progress[key].slice(0, 200);
144
+ else if (typeof progress?.[key] === 'number' && Number.isFinite(progress[key])) saved[key] = progress[key];
145
+ }
146
+ if (entry.record.operation === 'packing_detail_export' && typeof progress?.taskId === 'string' && progress.taskId) entry.record.checkpoint = { taskId: progress.taskId };
147
+ entry.record.progress = saved;
148
+ entry.record.updatedAt = new Date().toISOString();
149
+ await persist(entry);
150
+ if (!active()) throw failure('JOB_INTERRUPTED', 'The local worker stopped.');
151
+ },
152
+ });
153
+ if (!active()) return;
154
+ if (value && typeof value[Symbol.asyncIterator] === 'function') return consumePages(value, { dir, active });
155
+ return adaptResult(value, { dir, outputPath });
156
+ });
157
+ if (!active()) return;
158
+ entry.record.state = 'succeeded';
159
+ entry.record.result = result;
160
+ entry.record.updatedAt = new Date().toISOString();
161
+ await persist(entry);
162
+ } catch (error) {
163
+ if (!active()) return;
164
+ entry.record.state = 'failed';
165
+ entry.record.updatedAt = new Date().toISOString();
166
+ const cause = error instanceof StreamFailure ? error.cause : error;
167
+ entry.record.error = cause instanceof AuthRequiredError || cause?.code === 'LOGIN_REQUIRED'
168
+ ? { code: 'LOGIN_REQUIRED', message: 'The ERP session needs browser login.' }
169
+ : { code: 'EXECUTION_FAILED', message: redactSensitiveText(cause?.message || 'ERP execution failed.', [session.token, session.username]).slice(0, 1200) };
170
+ if (error instanceof StreamFailure) entry.record.result = error.partialResult;
171
+ await persist(entry);
172
+ } finally {
173
+ if (release) await release();
174
+ }
175
+ }
176
+
177
+ async function admit(operation, params, session, { requestId } = {}) {
178
+ if (closed) throw failure('JOBS_CLOSED', 'The local worker is closed.');
179
+ session = { ...session };
180
+ const owner = ownerOf(session);
181
+ const definition = getOperation(operation);
182
+ if (!definition) throw failure('UNKNOWN_OPERATION', 'Unknown ERP operation.');
183
+ const parsed = definition.schema.parse(params);
184
+ if (definition.effect === 'export' && requestId === undefined) throw failure('REQUEST_ID_REQUIRED', 'Exports require a stable requestId.');
185
+ if (requestId !== undefined && (typeof requestId !== 'string' || !requestId.trim() || requestId.length > 200)) throw failure('INVALID_INPUT', 'requestId must be a nonempty string of at most 200 characters.');
186
+ await store.init();
187
+ if (closed) throw failure('JOBS_CLOSED', 'The local worker is closed.');
188
+ const fingerprint = digest(canonical({ operation, params: parsed }));
189
+ let claim;
190
+ if (requestId !== undefined) {
191
+ claim = path.join(store.jobsDir, 'requests', digest(canonical([owner, requestId])));
192
+ try { await fs.mkdir(claim, { mode: 0o700 }); }
193
+ catch (error) {
194
+ if (error.code !== 'EEXIST') throw error;
195
+ let saved;
196
+ for (let attempt = 0; attempt < 10; attempt += 1) {
197
+ try { saved = JSON.parse(await fs.readFile(path.join(claim, 'claim.json'), 'utf8')); break; }
198
+ catch (readError) { if (readError.code !== 'ENOENT') throw failure('REQUEST_UNCERTAIN', 'The saved request admission is uncertain; it will not be replayed.'); await pause(10); }
199
+ }
200
+ if (!saved) throw failure('REQUEST_UNCERTAIN', 'The saved request admission is uncertain; it will not be replayed.');
201
+ if (saved.fingerprint !== fingerprint) throw failure('REQUEST_CONFLICT', 'requestId was already used for a different call.');
202
+ return status(saved.jobId, session);
203
+ }
204
+ }
205
+ const jobId = crypto.randomUUID();
206
+ const now = new Date().toISOString();
207
+ const record = { jobId, operation, state: 'queued', createdAt: now, updatedAt: now, owner, params: parsed, fingerprint, process: { pid: process.pid, instance } };
208
+ if (operation === 'packing_detail_export' && parsed.taskId) record.checkpoint = { taskId: parsed.taskId };
209
+ const entry = { record, writes: Promise.resolve() };
210
+ await fs.mkdir(store.jobDir(jobId), { mode: 0o700 });
211
+ await persist(entry);
212
+ if (claim) {
213
+ const temporary = path.join(claim, `${crypto.randomUUID()}.tmp`);
214
+ await fs.writeFile(temporary, JSON.stringify({ jobId, fingerprint }), { flag: 'wx', mode: 0o600 });
215
+ await fs.rename(temporary, path.join(claim, 'claim.json'));
216
+ }
217
+ local.set(jobId, entry);
218
+ if (closed) {
219
+ record.state = 'interrupted';
220
+ record.error = { code: 'PROCESS_INTERRUPTED', message: 'The local worker stopped. No automatic retry was made.' };
221
+ await persist(entry);
222
+ } else {
223
+ entry.done = run(entry, definition, { ...session }).catch(() => {});
224
+ if (waitMs) {
225
+ let timer;
226
+ await Promise.race([entry.done, new Promise(resolve => { timer = setTimeout(resolve, waitMs); })]);
227
+ clearTimeout(timer);
228
+ }
229
+ }
230
+ return receipt(record);
231
+ }
232
+
233
+ function start(...args) {
234
+ const pending = admit(...args);
235
+ admissions.add(pending);
236
+ pending.finally(() => admissions.delete(pending)).catch(() => {});
237
+ return pending;
238
+ }
239
+
240
+ async function resume(jobId, session, { requestId } = {}) {
241
+ session = { ...session };
242
+ const record = await owned(jobId, session);
243
+ if (ACTIVE.has(record.state) && alive(record.process?.pid)) throw failure('RESUME_UNSUPPORTED', 'This job may still be running.');
244
+ if (record.operation !== 'packing_detail_export' || !record.checkpoint?.taskId) throw failure('RESUME_UNSUPPORTED', 'Only packing jobs with a saved task ID support explicit resume.');
245
+ if (!requestId) throw failure('REQUEST_ID_REQUIRED', 'Resume requires a stable requestId.');
246
+ return start(record.operation, { ...record.params, taskId: record.checkpoint.taskId }, session, { requestId });
247
+ }
248
+
249
+ async function cancel(jobId, session) {
250
+ const record = await owned(jobId, session);
251
+ const entry = local.get(jobId);
252
+ if (record.state === 'queued' && entry?.record.state === 'queued') {
253
+ entry.record.state = 'cancelled';
254
+ entry.record.updatedAt = new Date().toISOString();
255
+ await persist(entry);
256
+ return receipt(entry.record);
257
+ }
258
+ if (ACTIVE.has(record.state)) return receipt(entry?.record || record, { code: 'CANCELLATION_UNSUPPORTED', message: 'Running ERP work cannot be cancelled end to end.' });
259
+ return receipt(entry?.record || record);
260
+ }
261
+
262
+ async function close() {
263
+ closed = true;
264
+ await Promise.allSettled([...admissions]);
265
+ for (const entry of local.values()) {
266
+ if (!ACTIVE.has(entry.record.state)) continue;
267
+ entry.record.state = 'interrupted';
268
+ entry.record.updatedAt = new Date().toISOString();
269
+ entry.record.error = { code: 'PROCESS_INTERRUPTED', message: 'The local worker stopped. ERP work was not cancelled.' };
270
+ await persist(entry);
271
+ }
272
+ }
273
+
274
+ return { start, status, list, resume, cancel, close };
275
+ }
276
+
277
+ module.exports = { createJobs };