dsh-vibe-math 2.6.0 → 2.7.1
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.
- package/README.en.md +161 -389
- package/README.md +161 -390
- package/cordis.patch.yml +62 -14
- package/docs/AUDIT-CHECKLIST.md +2 -2
- package/docs/final-paper.md +1 -0
- package/docs/formal-verification.md +184 -29
- package/docs/math-computation.md +163 -0
- package/docs/release-notes/RELEASE-NOTES-2.7.0.en.md +151 -0
- package/docs/release-notes/RELEASE-NOTES-2.7.0.md +115 -0
- package/docs/release-notes/RELEASE-NOTES-2.7.1.en.md +86 -0
- package/docs/release-notes/RELEASE-NOTES-2.7.1.md +86 -0
- package/docs/test-timing.md +24 -12
- package/installer.js +7 -4
- package/package.json +21 -3
- package/prompt-corpus-persona/persona-corpus.json +12 -12
- package/prompt-corpus-persona/persona-corpus.md +128 -32
- package/prompt-corpus-v2/formal-verify-v2.json +182 -92
- package/prompt-corpus-v2/formal-verify-v2.md +2379 -55
- package/prompt-corpus-v3/formal-verify-v3.json +215 -53
- package/prompt-corpus-v3/formal-verify-v3.md +2147 -22
- package/prompt-corpus-v4/formal-verify-v4.json +14 -14
- package/prompt-corpus-v4/formal-verify-v4.md +267 -14
- package/prompt-corpus-v5/prompt-corpus-v5.json +62 -62
- package/prompt-corpus-v5/prompt-corpus-v5.md +631 -3
- package/tests/audit-math-computation-contract.mjs +204 -0
- package/tests/audit-math-computation-parity.mjs +198 -0
- package/tests/audit-math-computation-sensitivity.mjs +231 -0
- package/tests/audit-persona-sensitivity.mjs +4 -4
- package/tests/audit-persona-surface.test.mjs +38 -10
- package/tests/audit-v5-integrity.mjs +14 -0
- package/tests/e2e-v5-round2.test.mjs +459 -2
- package/tests/formal-verify-v2.test.mjs +362 -8
- package/tests/formal-verify-v3.test.mjs +355 -6
- package/tests/formal-verify-v4.test.mjs +295 -6
- package/tests/formal-verify-v5.test.mjs +9 -1
- package/tests/helpers/math-computation-fake-seam.mjs +163 -0
- package/tests/math-computation-shared.test.mjs +806 -0
- package/tests/prompt-v5-integrity.test.mjs +5 -5
- package/vibe-math-v2/agent.cordis.yml +22 -8
- package/vibe-math-v2/math-computation.js +1279 -0
- package/vibe-math-v2/math-engines.js +197 -0
- package/vibe-math-v2/vibe-math-v2.js +853 -40
- package/vibe-math-v2//345/256/236/347/216/260/346/226/271/346/241/210.md +6 -0
- package/vibe-math-v3/agent.cordis.yml +22 -8
- package/vibe-math-v3/math-computation.js +1279 -0
- package/vibe-math-v3/math-engines.js +197 -0
- package/vibe-math-v3/vibe-math-v3.js +844 -47
- package/vibe-math-v3//345/256/236/347/216/260/346/226/271/346/241/210.md +6 -0
- package/vibe-math-v4/agent.cordis.yml +38 -6
- package/vibe-math-v4/math-computation.js +1279 -0
- package/vibe-math-v4/math-engines.js +197 -0
- package/vibe-math-v4/vibe-math-v4.js +816 -31
- package/vibe-math-v4//345/256/236/347/216/260/346/226/271/346/241/210.md +6 -0
- package/vibe-math-v5/agent.cordis.yml +42 -6
- package/vibe-math-v5/math-computation.js +1279 -0
- package/vibe-math-v5/math-engines.js +197 -0
- package/vibe-math-v5/vibe-math-v5.js +1132 -37
- package/vibe-math-v5//345/256/236/347/216/260/346/226/271/346/241/210.md +83 -3
- package/vibe-math-v5//346/236/266/346/236/204/345/233/276.md +251 -0
|
@@ -0,0 +1,1279 @@
|
|
|
1
|
+
// math-computation.js — the `math_computation` tool for the Vibe Math presets (P1).
|
|
2
|
+
//
|
|
3
|
+
// CANONICAL COPY. Must stay BYTE-IDENTICAL in:
|
|
4
|
+
// vibe-math-v2/math-computation.js, vibe-math-v3/…, vibe-math-v4/…, vibe-math-v5/…
|
|
5
|
+
// Interface freeze: _oneoff/mc-P1-ready/INTERFACE-FREEZE.md
|
|
6
|
+
// Contract: _oneoff/math-computation-spec-v3.md (rev-3) + _oneoff/mc-P1-ready/{tool-schema.json,receipt.md,prompts.md,guards.md}
|
|
7
|
+
//
|
|
8
|
+
// Imports only node:crypto and its sibling ./math-engines.js (no repo imports), so the four
|
|
9
|
+
// byte-identical copies work from any preset directory and from an installed .agent-presets dir.
|
|
10
|
+
import { createHash } from 'node:crypto'
|
|
11
|
+
import { MATH_ENGINES, MATH_ENGINE_ORDER, mathEngineCandidates, mathEngineArgErrorHints } from './math-engines.js'
|
|
12
|
+
|
|
13
|
+
export const MATH_TOOL_NAME = 'math_computation'
|
|
14
|
+
|
|
15
|
+
export const MATH_PARAM_NAMES = Object.freeze([
|
|
16
|
+
'mathComputation', 'mathMode', 'mathEngines', 'mathTimeoutMs', 'mathPackages', 'mathInstallScope',
|
|
17
|
+
])
|
|
18
|
+
|
|
19
|
+
export const MATH_PARAM_DEFAULTS = Object.freeze({
|
|
20
|
+
mathComputation: 'auto',
|
|
21
|
+
mathMode: 'typed+shell',
|
|
22
|
+
mathEngines: MATH_ENGINE_ORDER.slice(),
|
|
23
|
+
mathTimeoutMs: 60000,
|
|
24
|
+
mathPackages: [],
|
|
25
|
+
mathInstallScope: 'user',
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
export const MATH_FAILURE_CODES = Object.freeze([
|
|
29
|
+
'MATH_NOT_AVAILABLE',
|
|
30
|
+
'MATH_ENGINE_NOT_FOUND',
|
|
31
|
+
'MATH_ENGINE_LICENSE_REQUIRED',
|
|
32
|
+
'MATH_ENGINE_UNUSABLE',
|
|
33
|
+
'MATH_MISSING_PACKAGES',
|
|
34
|
+
'MATH_TIMEOUT',
|
|
35
|
+
'MATH_NONZERO_EXIT',
|
|
36
|
+
'MATH_ENGINE_BAD_ARGV',
|
|
37
|
+
'MATH_REFUSED',
|
|
38
|
+
'MATH_INVALID_ARGUMENT',
|
|
39
|
+
'MATH_NO_SUBPROCESS',
|
|
40
|
+
])
|
|
41
|
+
|
|
42
|
+
export const MATH_CAPS = Object.freeze({ stdout: 64 * 1024, stderr: 64 * 1024, file: 4 * 1024 * 1024 })
|
|
43
|
+
|
|
44
|
+
// Archive retention (P2a item 5): a documented CAP that only ever WARNS - the tool never deletes.
|
|
45
|
+
// Per-run: the attempt number that may be created for one archive id (attempt 1 lives in
|
|
46
|
+
// Computation/<id>/, later attempts in Computation/<id>/attempts/<n>/). Per-project: only checked
|
|
47
|
+
// when the host exposes the optional listDir(); absent that, the per-run cap still applies.
|
|
48
|
+
export const MATH_ARCHIVE_MAX_ATTEMPTS_PER_RUN = 20
|
|
49
|
+
export const MATH_ARCHIVE_MAX_RUNS = 200
|
|
50
|
+
export const MATH_ARCHIVE_WARNING = 'ARCHIVE_RETENTION_EXCEEDED'
|
|
51
|
+
export const MATH_SCRIPT_CHANGED_WARNING = 'SCRIPT_CHANGED_SINCE_LAST_RECEIPT'
|
|
52
|
+
export const MATH_SCRIPT_CHANGED_DURING_RUN_WARNING = 'SCRIPT_CHANGED_DURING_RUN'
|
|
53
|
+
|
|
54
|
+
export const MATH_SHELL_FALLBACK_MARK = '未经工具归档'
|
|
55
|
+
export const MATH_SHELL_FALLBACK_MARK_EN = 'not tool-archived'
|
|
56
|
+
export const MATH_BAD_ARGV_HINT = 'mathEngineOverride'
|
|
57
|
+
|
|
58
|
+
// ── prompt text (verbatim source: _oneoff/mc-P1-ready/prompts.md) ───────────────────────────────
|
|
59
|
+
export const MATH_TOOL_DESCRIPTION = '数学计算:先用 op:\'probe\' 预检本机可用引擎与许可,再用 op:\'run\' 计算(mode=code|file|expr;'
|
|
60
|
+
+ 'P1 引擎=python|r|octave|julia + matlab|maple|wolfram(仅探测/许可,永不安装)+ cli(默认开启:用你指定的命令执行,'
|
|
61
|
+
+ '仍受本工具的超时/输出上限/cwd 约束并留回执;它与宿主 shell 的区别就是有回执与上限——要用宿主 shell 兜底由 mathMode 控制,'
|
|
62
|
+
+ '且结论必须标注未经工具归档))。每次执行都会归档成可复核回执(Computation/<id>/),回执里含 scriptPath/scriptHash 与**实际 argv**;'
|
|
63
|
+
+ '脚本原件就在那个归档目录里,你可以用普通文件工具打开/编辑它,编辑后用 mode:\'file\' 重跑会得到一份**新回执**——'
|
|
64
|
+
+ '**不得**拿旧回执当作修改后代码的证据。缺引擎/缺包只报告并给"用户自装指引"或"代理代装计划"(先计划、再确认);'
|
|
65
|
+
+ '商业引擎只给厂商指引;引擎选项报错时会回显 argv 并提示用 mathEngineOverride。'
|
|
66
|
+
+ '替代方案若改变精确性或结论强度(精确解→数值近似、闭式解→采样/求积、改精度/容差/假设、换算法类)**必须在结论里声明**,不得读起来像得到了原本的结果。'
|
|
67
|
+
+ '计算结果是经验证据,不是证明。'
|
|
68
|
+
|
|
69
|
+
export const MATH_PERSONA_TOOL_LINE = '- math_computation {op: probe|run|receipt|install, engine, mode: code|file|expr, …} — '
|
|
70
|
+
+ '先 probe 预检引擎/包/许可,再调引擎计算并把脚本与输出归档成可复核回执;缺引擎/缺包只报告与给安装指引/计划;shell 兜底不算归档。'
|
|
71
|
+
|
|
72
|
+
// P2a: the archive -> edit -> re-run workflow. Injected into every preset's rule block (and appended
|
|
73
|
+
// to both persona blocks) so an agent cannot miss that (a) the script is archived, (b) it may edit
|
|
74
|
+
// it, and (c) re-running is what produces evidence for the edited code.
|
|
75
|
+
export const MATH_ARCHIVE_WORKFLOW_LINE = '- 归档→编辑→重跑:mode:\'code\' 的脚本原件在回执的 scriptPath(Computation/<id>/script.<ext>),你可以用普通文件工具打开并编辑它;'
|
|
76
|
+
+ '编辑后用 mode:\'file\' 指向它重跑,会写出一份**新回执/新 attempt**(含新的 scriptHash)。**旧回执对修改后的代码无效**——'
|
|
77
|
+
+ '报告里必须引用与当前代码哈希一致的那份回执;工具会在 scriptChanged / scriptChangedDuringRun 为 true 时显式告警。'
|
|
78
|
+
|
|
79
|
+
export const MATH_ARCHIVE_WORKFLOW_LINE_EN = '- Archive -> edit -> re-run: for mode:\'code\' the script original is at the receipt\'s scriptPath (Computation/<id>/script.<ext>) and you may open/edit it with your normal file tools; after editing, re-run it with mode:\'file\' to write a NEW receipt/attempt with a NEW scriptHash. **An old receipt is NOT evidence for edited code** - cite the receipt whose scriptHash matches the current code; the tool warns explicitly via scriptChanged / scriptChangedDuringRun.'
|
|
80
|
+
|
|
81
|
+
// Round-6 (A): honesty about SUBSTITUTIONS. An alternative that weakens exactness or conclusion
|
|
82
|
+
// strength must be declared, and the conclusion must never read as if the requested (exact) result
|
|
83
|
+
// had been obtained. Injected through mathAvailabilityLine (rule block) AND appended to both persona
|
|
84
|
+
// blocks by the presets - same mechanism as the archive-workflow line.
|
|
85
|
+
export const MATH_SUBSTITUTION_RULE_LINE = '- 替代必须声明(诚实性):当替代方案改变了**精确性或结论强度**时(精确符号解 → 数值近似、闭式解 → 采样/求积、改了精度/容差/假设、换了算法类),结论里**必须写明**,不得读起来像得到了原本(精确/所要求的)结果;拿不到精确结果就直说。'
|
|
86
|
+
|
|
87
|
+
export const MATH_SUBSTITUTION_RULE_LINE_EN = '- Declare substitutions (honesty): when an alternative changes EXACTNESS or conclusion strength (exact symbolic solution -> numerical approximation, closed form -> sampling/quadrature, changed precision/tolerances/assumptions, a different algorithm class), the conclusion MUST say so explicitly and must not read as if the original (exact/requested) result had been obtained; if the exact result is unavailable, say so plainly.'
|
|
88
|
+
|
|
89
|
+
export const MATH_RULE_LINES = Object.freeze([
|
|
90
|
+
'- 需要精确数值、符号化简、反例搜索、统计或线性代数时调用 math_computation:先 probe,再 run。',
|
|
91
|
+
'- 复核他人的数值结论时用 op:\'receipt\'(或 op:\'run\', mode:\'file\' 指向同一脚本)重跑,并把回执路径写进报告。',
|
|
92
|
+
'- 归档即引用:报告里带 Computation/<id>/receipt.json;这是"支撑材料"的用法。',
|
|
93
|
+
'- 判断标准:① 有价值或可能复用;② 较为关键或必要;③ 你对该陈述有把握(置信度高)——没把握的先别入库。',
|
|
94
|
+
'- 不得把计算结果当成"已证明":对象是否已验证仍只由本预设既有的验证/共识路径给出。',
|
|
95
|
+
MATH_SUBSTITUTION_RULE_LINE,
|
|
96
|
+
])
|
|
97
|
+
|
|
98
|
+
// The shell-fallback sentence is deliberately NOT part of MATH_RULE_LINES: it is only advertised
|
|
99
|
+
// when mathMode === 'typed+shell'. Owners either use mathAvailabilityLine(probe, lang, mathMode)
|
|
100
|
+
// (which appends it conditionally) or append MATH_SHELL_RULE_LINE themselves under the same
|
|
101
|
+
// condition. A 'typed' preset must contain no shell-fallback sentence at all.
|
|
102
|
+
export const MATH_SHELL_RULE_LINE = '- shell 兜底标注:工具不可用而改用宿主 shell 时,结论必须写"未经工具归档(shell 路径)",且不得与工具回执混同(shell 无回执、无超时/输出上限保证)。'
|
|
103
|
+
|
|
104
|
+
export const MATH_RULE_LINES_EN = Object.freeze([
|
|
105
|
+
'- Call math_computation for exact numerics, symbolic simplification, counter-example search, statistics or linear algebra: probe first, then run.',
|
|
106
|
+
'- To re-check someone else\'s numeric conclusion, re-run the same script (op:\'receipt\' or op:\'run\', mode:\'file\') and cite the receipt path.',
|
|
107
|
+
'- Archive means cite: put Computation/<id>/receipt.json in your report - that is what "supporting material" means.',
|
|
108
|
+
'- Criteria: (1) valuable or likely reusable; (2) important or necessary; (3) you are confident in it - do not archive what you are unsure about.',
|
|
109
|
+
'- Never treat a computation result as "proved": whether an object is verified is still decided only by this preset\'s existing verification/consensus path.',
|
|
110
|
+
MATH_SUBSTITUTION_RULE_LINE_EN,
|
|
111
|
+
])
|
|
112
|
+
|
|
113
|
+
export const MATH_SHELL_RULE_LINE_EN = '- Shell fallback: if the tool cannot run you may use the host shell, but mark the conclusion "not tool-archived (shell path)" - shell runs have no receipt and no timeout/output guarantees; only tool-routed computations count as reproducible supporting material.'
|
|
114
|
+
|
|
115
|
+
export const MATH_TOOL_SCHEMA = Object.freeze({
|
|
116
|
+
type: 'object',
|
|
117
|
+
properties: {
|
|
118
|
+
op: { type: 'string', enum: ['probe', 'run', 'receipt', 'install'] },
|
|
119
|
+
engine: { type: 'string' },
|
|
120
|
+
mode: { type: 'string', enum: ['code', 'file', 'expr'] },
|
|
121
|
+
code: { type: 'string' },
|
|
122
|
+
file: { type: 'string' },
|
|
123
|
+
expr: { type: 'string' },
|
|
124
|
+
packages: { type: 'array', items: { type: 'string' } },
|
|
125
|
+
timeoutMs: { type: 'integer' },
|
|
126
|
+
captureFiles: { type: 'array', items: { type: 'string' } },
|
|
127
|
+
record: { type: 'boolean' },
|
|
128
|
+
cli: {
|
|
129
|
+
type: 'object',
|
|
130
|
+
properties: { command: { type: 'string' }, argv: { type: 'array', items: { type: 'string' } }, stdin: { type: 'string' } },
|
|
131
|
+
required: ['command'],
|
|
132
|
+
additionalProperties: false,
|
|
133
|
+
},
|
|
134
|
+
scope: { type: 'string', enum: ['user', 'system'] },
|
|
135
|
+
dryRun: { type: 'boolean' },
|
|
136
|
+
confirm: { type: 'string' },
|
|
137
|
+
// AUDIT-B FIX (HIGH): the escape hatch the BAD_ARGV message advertises must be a real, accepted
|
|
138
|
+
// argument. Shape: { <engine>: { versionArgv?, scriptArgv?, evalArgv?, packageProbe? } }.
|
|
139
|
+
mathEngineOverride: { type: 'object' },
|
|
140
|
+
},
|
|
141
|
+
required: ['op'],
|
|
142
|
+
additionalProperties: false,
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
// ── small helpers ───────────────────────────────────────────────────────────────────────────────
|
|
146
|
+
function sha256(s) { return createHash('sha256').update(String(s)).digest('hex') }
|
|
147
|
+
function isObj(v) { return !!v && typeof v === 'object' && !Array.isArray(v) }
|
|
148
|
+
function canonical(s) { return String(s == null ? '' : s).replace(/\r\n/g, '\n').replace(/[ \t]+$/gm, '').replace(/\n+$/, '') + '\n' }
|
|
149
|
+
function slug(s) { return String(s == null ? '' : s).replace(/[^A-Za-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'default' }
|
|
150
|
+
function absJoin(root, rel) { return String(root).replace(/[\\/]+$/, '') + '/' + String(rel).replace(/^[\\/]+/, '') }
|
|
151
|
+
function projectRel(root, rel) {
|
|
152
|
+
const raw = String(rel == null ? '' : rel).trim()
|
|
153
|
+
if (!raw) return null
|
|
154
|
+
const norm = raw.replace(/\\/g, '/')
|
|
155
|
+
if (norm.startsWith('/') || /^[A-Za-z]:/.test(norm)) return null
|
|
156
|
+
const parts = []
|
|
157
|
+
for (const seg of norm.split('/')) {
|
|
158
|
+
if (!seg || seg === '.') continue
|
|
159
|
+
if (seg === '..') { if (!parts.length) return null; parts.pop(); continue }
|
|
160
|
+
parts.push(seg)
|
|
161
|
+
}
|
|
162
|
+
return parts.join('/')
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── params ──────────────────────────────────────────────────────────────────────────────────────
|
|
166
|
+
export function normalizeMathParams(raw) {
|
|
167
|
+
const r = isObj(raw) ? raw : {}
|
|
168
|
+
const out = {}
|
|
169
|
+
if ('mathComputation' in r) out.mathComputation = (r.mathComputation === 'off' || r.mathComputation === 'auto' || r.mathComputation === 'on') ? r.mathComputation : MATH_PARAM_DEFAULTS.mathComputation
|
|
170
|
+
if ('mathMode' in r) out.mathMode = (r.mathMode === 'typed' || r.mathMode === 'typed+shell') ? r.mathMode : MATH_PARAM_DEFAULTS.mathMode
|
|
171
|
+
if ('mathEngines' in r) {
|
|
172
|
+
const list = Array.isArray(r.mathEngines) ? r.mathEngines.filter((x) => typeof x === 'string' && !!MATH_ENGINES[x]) : []
|
|
173
|
+
const seen = []
|
|
174
|
+
for (const n of list) if (seen.indexOf(n) === -1) seen.push(n)
|
|
175
|
+
out.mathEngines = seen.length ? seen : MATH_PARAM_DEFAULTS.mathEngines.slice()
|
|
176
|
+
}
|
|
177
|
+
if ('mathTimeoutMs' in r) {
|
|
178
|
+
const n = Number(r.mathTimeoutMs)
|
|
179
|
+
out.mathTimeoutMs = Number.isFinite(n) && n > 0 ? Math.max(1000, Math.floor(n)) : MATH_PARAM_DEFAULTS.mathTimeoutMs
|
|
180
|
+
}
|
|
181
|
+
if ('mathPackages' in r) {
|
|
182
|
+
const list = Array.isArray(r.mathPackages) ? r.mathPackages.filter((x) => typeof x === 'string' && x.trim()).map((x) => x.trim()) : []
|
|
183
|
+
const seen = []
|
|
184
|
+
for (const n of list) if (seen.indexOf(n) === -1) seen.push(n)
|
|
185
|
+
out.mathPackages = seen
|
|
186
|
+
}
|
|
187
|
+
if ('mathInstallScope' in r) out.mathInstallScope = (r.mathInstallScope === 'user' || r.mathInstallScope === 'system') ? r.mathInstallScope : MATH_PARAM_DEFAULTS.mathInstallScope
|
|
188
|
+
return out
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function effectiveMathParams(raw) {
|
|
192
|
+
return Object.assign({}, MATH_PARAM_DEFAULTS, normalizeMathParams(raw))
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// ── closed-schema validation ────────────────────────────────────────────────────────────────────
|
|
196
|
+
const ALLOWED_ARGS = ['op', 'engine', 'mode', 'code', 'file', 'expr', 'packages', 'timeoutMs', 'captureFiles', 'record', 'cli', 'scope', 'dryRun', 'confirm', 'mathEngineOverride']
|
|
197
|
+
// AUDIT-B FIX (HIGH): validate the override shape in the same closed-argument spirit as everything
|
|
198
|
+
// else, so the escape hatch is usable but cannot smuggle arbitrary junk into argv assembly.
|
|
199
|
+
const OVERRIDE_ARGV_KEYS = ['versionArgv', 'scriptArgv', 'evalArgv']
|
|
200
|
+
function validateEngineOverride(ov) {
|
|
201
|
+
if (!isObj(ov)) return bad('mathEngineOverride must be an object keyed by engine name')
|
|
202
|
+
for (const name of Object.keys(ov)) {
|
|
203
|
+
if (!MATH_ENGINES[name]) return bad('mathEngineOverride: unknown engine ' + name + '(可用:' + MATH_ENGINE_ORDER.join(', ') + ')')
|
|
204
|
+
const o = ov[name]
|
|
205
|
+
if (!isObj(o)) return bad('mathEngineOverride.' + name + ' must be an object')
|
|
206
|
+
for (const k of Object.keys(o)) {
|
|
207
|
+
if (OVERRIDE_ARGV_KEYS.indexOf(k) === -1 && k !== 'packageProbe') return bad('mathEngineOverride.' + name + ': unknown key ' + k)
|
|
208
|
+
if (OVERRIDE_ARGV_KEYS.indexOf(k) !== -1 && (!Array.isArray(o[k]) || o[k].some((a) => typeof a !== 'string'))) return bad('mathEngineOverride.' + name + '.' + k + ' must be a string[]')
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return null
|
|
212
|
+
}
|
|
213
|
+
function bad(reason, code, nextHint) { const out = { ok: false, code: code || 'MATH_INVALID_ARGUMENT', reason: reason }; if (nextHint) out.next = nextHint; return out }
|
|
214
|
+
|
|
215
|
+
export function validateMathArgs(args, params) {
|
|
216
|
+
if (!isObj(args)) return bad('args must be an object')
|
|
217
|
+
for (const k of Object.keys(args)) if (ALLOWED_ARGS.indexOf(k) === -1) return bad('unknown argument: ' + k)
|
|
218
|
+
const op = args.op
|
|
219
|
+
if (op !== 'probe' && op !== 'run' && op !== 'receipt' && op !== 'install') return bad('op must be probe|run|receipt|install')
|
|
220
|
+
const p = effectiveMathParams(params)
|
|
221
|
+
if (args.engine !== undefined) {
|
|
222
|
+
if (typeof args.engine !== 'string') return bad('engine must be a string')
|
|
223
|
+
if (args.engine !== 'auto' && !MATH_ENGINES[args.engine]) return bad('unknown engine: ' + args.engine)
|
|
224
|
+
}
|
|
225
|
+
if (args.packages !== undefined) {
|
|
226
|
+
if (!Array.isArray(args.packages) || args.packages.some((x) => typeof x !== 'string')) return bad('packages must be a string[]')
|
|
227
|
+
// round-6 (D): accept the manager-understood pass-through forms; refuse anything else explicitly.
|
|
228
|
+
for (const spec of args.packages) {
|
|
229
|
+
const parsed = parsePackageSpec(spec)
|
|
230
|
+
if (!parsed.ok) return bad(parsed.problem, 'MATH_INVALID_ARGUMENT', next('reason', { reason: 'unsupported-version-syntax' }))
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (args.captureFiles !== undefined && (!Array.isArray(args.captureFiles) || args.captureFiles.some((x) => typeof x !== 'string'))) return bad('captureFiles must be a string[]')
|
|
234
|
+
if (args.timeoutMs !== undefined) {
|
|
235
|
+
const n = Number(args.timeoutMs)
|
|
236
|
+
if (!Number.isFinite(n) || n < 1000) return bad('timeoutMs must be an integer >= 1000')
|
|
237
|
+
}
|
|
238
|
+
if (args.record !== undefined && typeof args.record !== 'boolean') return bad('record must be a boolean')
|
|
239
|
+
if (args.dryRun !== undefined && typeof args.dryRun !== 'boolean') return bad('dryRun must be a boolean')
|
|
240
|
+
if (args.confirm !== undefined && typeof args.confirm !== 'string') return bad('confirm must be a string')
|
|
241
|
+
if (args.mathEngineOverride !== undefined) {
|
|
242
|
+
const badOv = validateEngineOverride(args.mathEngineOverride)
|
|
243
|
+
if (badOv) return badOv
|
|
244
|
+
}
|
|
245
|
+
if (args.scope !== undefined && args.scope !== 'user' && args.scope !== 'system') return bad('scope must be user|system')
|
|
246
|
+
if (args.cli !== undefined) {
|
|
247
|
+
if (!isObj(args.cli)) return bad('cli must be an object')
|
|
248
|
+
for (const k of Object.keys(args.cli)) if (['command', 'argv', 'stdin'].indexOf(k) === -1) return bad('unknown cli key: ' + k)
|
|
249
|
+
if (typeof args.cli.command !== 'string' || !args.cli.command.trim()) return bad('cli.command is required')
|
|
250
|
+
if (args.cli.argv !== undefined && (!Array.isArray(args.cli.argv) || args.cli.argv.some((x) => typeof x !== 'string'))) return bad('cli.argv must be a string[]')
|
|
251
|
+
}
|
|
252
|
+
if (op === 'run') {
|
|
253
|
+
const mode = args.mode
|
|
254
|
+
if (mode !== 'code' && mode !== 'file' && mode !== 'expr') return bad('mode must be code|file|expr for op=run')
|
|
255
|
+
const engine = args.engine || 'auto'
|
|
256
|
+
if (engine === 'cli') {
|
|
257
|
+
if (p.mathMode !== 'typed+shell') return bad('engine=cli is disabled while mathMode=' + p.mathMode, 'MATH_REFUSED', next('reason', { reason: 'policy' }))
|
|
258
|
+
if (p.mathEngines.indexOf('cli') === -1) return bad('engine=cli is not in mathEngines', 'MATH_REFUSED', next('reason', { reason: 'engine-not-allowed' }))
|
|
259
|
+
if (!isObj(args.cli)) return bad('engine=cli requires cli:{command,argv}', 'MATH_REFUSED', next('reason', { reason: 'missing-cli-command' }))
|
|
260
|
+
if (mode === 'expr') return bad('cli does not support mode=expr', 'MATH_REFUSED', next('reason', { reason: 'expr-not-supported' }))
|
|
261
|
+
}
|
|
262
|
+
if (mode === 'code' && typeof args.code !== 'string') return bad('mode=code requires code')
|
|
263
|
+
if (mode === 'file') {
|
|
264
|
+
if (typeof args.file !== 'string') return bad('mode=file requires file')
|
|
265
|
+
if (projectRel('', args.file) === null) return bad('file must be a project-relative path inside the project', 'MATH_REFUSED', next('reason', { reason: 'path-outside-project' }))
|
|
266
|
+
}
|
|
267
|
+
if (mode === 'expr' && typeof args.expr !== 'string') return bad('mode=expr requires expr')
|
|
268
|
+
if (engine !== 'cli' && !MATH_ENGINES[engine] && engine !== 'auto') return bad('unknown engine: ' + engine)
|
|
269
|
+
}
|
|
270
|
+
if (op === 'receipt' && typeof args.file !== 'string') return bad('op=receipt requires file (a receipt id or script path)')
|
|
271
|
+
if (op === 'install') {
|
|
272
|
+
if (!Array.isArray(args.packages) || !args.packages.length) return bad('op=install requires packages[]')
|
|
273
|
+
}
|
|
274
|
+
return { ok: true, args: args, params: p }
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ── failure / return shells ─────────────────────────────────────────────────────────────────────
|
|
278
|
+
function next(kind, extra) { return Object.assign({ kind: kind }, extra || {}) }
|
|
279
|
+
function fail(code, engine, message, extra) {
|
|
280
|
+
const out = { ok: false, op: null, engine: engine || null, code: code, message: message }
|
|
281
|
+
if (extra && extra.next) out.next = extra.next
|
|
282
|
+
if (extra && extra.argv) out.argv = extra.argv
|
|
283
|
+
if (extra && extra.receipt) out.receipt = extra.receipt
|
|
284
|
+
if (extra && extra.missing) out.missing = extra.missing
|
|
285
|
+
if (extra && extra.engines) out.engines = extra.engines
|
|
286
|
+
if (extra && extra.available !== undefined) out.available = extra.available
|
|
287
|
+
if (extra && extra.packages) out.packages = extra.packages
|
|
288
|
+
// round-7 (live-session fix): the probe diagnostic must survive into the response, otherwise
|
|
289
|
+
// "存在但不可用" is opaque (a real session showed exactly that).
|
|
290
|
+
if (extra && extra.probe) out.probe = jsonSafeDiag(extra.probe)
|
|
291
|
+
return out
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function userInstallNext(engine) {
|
|
295
|
+
const d = MATH_ENGINES[engine] || {}
|
|
296
|
+
const per = d.userInstall || {}
|
|
297
|
+
const platform = process && process.platform === 'win32' ? 'windows' : (process && process.platform === 'darwin' ? 'macos' : 'linux')
|
|
298
|
+
return next('user-install', { engine: engine, perOs: per, command: per[platform] || '', platform: platform })
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ── host adaptation ─────────────────────────────────────────────────────────────────────────────
|
|
302
|
+
function adaptHost(host) {
|
|
303
|
+
const h = host || {}
|
|
304
|
+
if (h.__mathAdapted) return h.__mathAdapted
|
|
305
|
+
const need = ['register', 'projectRoot', 'writeText', 'readText', 'exists', 'resolveExecutable', 'spawn']
|
|
306
|
+
for (const k of need) if (typeof h[k] !== 'function') throw new Error('math_computation: host.' + k + ' is required')
|
|
307
|
+
const adapted = {
|
|
308
|
+
register: h.register,
|
|
309
|
+
params: typeof h.params === 'function' ? h.params : function () { return h.params || {} },
|
|
310
|
+
projectRoot: h.projectRoot,
|
|
311
|
+
designator: typeof h.designator === 'string' && h.designator ? h.designator : 'vibe-math',
|
|
312
|
+
writeText: h.writeText,
|
|
313
|
+
readText: h.readText,
|
|
314
|
+
exists: h.exists,
|
|
315
|
+
resolveExecutable: h.resolveExecutable,
|
|
316
|
+
spawn: h.spawn,
|
|
317
|
+
hasSubprocess: typeof h.hasSubprocess === 'function' ? h.hasSubprocess : null,
|
|
318
|
+
listDir: typeof h.listDir === 'function' ? h.listDir : null,
|
|
319
|
+
// round-7 (fix 2): optional bundled-runtime discovery (absolute roots + absolute listing).
|
|
320
|
+
runtimeRoots: typeof h.runtimeRoots === 'function' ? h.runtimeRoots : null,
|
|
321
|
+
listDirAbs: typeof h.listDirAbs === 'function' ? h.listDirAbs : null,
|
|
322
|
+
log: typeof h.log === 'function' ? h.log : function () {},
|
|
323
|
+
}
|
|
324
|
+
adapted.__mathHost = true
|
|
325
|
+
try { h.__mathAdapted = adapted } catch (e) { /* frozen host object: the caller can still pass the adapted one */ }
|
|
326
|
+
return adapted
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const PROBE_CACHE = new WeakMap()
|
|
330
|
+
|
|
331
|
+
export async function probeMathEngines(host, opts) {
|
|
332
|
+
const H = adaptHost(host)
|
|
333
|
+
const params = effectiveMathParams(typeof H.params === 'function' ? H.params() : H.params)
|
|
334
|
+
const refresh = !!(opts && opts.refresh)
|
|
335
|
+
const cached = PROBE_CACHE.get(H)
|
|
336
|
+
if (cached && !refresh) return cached
|
|
337
|
+
const engines = []
|
|
338
|
+
for (const name of params.mathEngines) {
|
|
339
|
+
const d = MATH_ENGINES[name]
|
|
340
|
+
if (!d) continue
|
|
341
|
+
if (name === 'cli') continue // cli needs a caller-supplied command, so it is not auto-detected
|
|
342
|
+
const hit = await resolveCandidate(H, d)
|
|
343
|
+
if (!hit) continue
|
|
344
|
+
const v = await probeVersion(H, d, hit)
|
|
345
|
+
if (!v.ok) {
|
|
346
|
+
if (d.license === 'commercial') continue // installed but not usable: reported by op=run as LICENSE_REQUIRED
|
|
347
|
+
continue
|
|
348
|
+
}
|
|
349
|
+
engines.push({ name: name, version: v.version, path: hit, license: d.license })
|
|
350
|
+
}
|
|
351
|
+
const result = { ok: true, engines: engines, available: engines.length > 0 }
|
|
352
|
+
PROBE_CACHE.set(H, result)
|
|
353
|
+
return result
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async function resolveCandidate(H, d) {
|
|
357
|
+
const cands = mathEngineCandidates(d.name)
|
|
358
|
+
if (!cands) return null
|
|
359
|
+
for (const c of cands) {
|
|
360
|
+
try {
|
|
361
|
+
const p = await H.resolveExecutable(c)
|
|
362
|
+
if (typeof p === 'string' && p) return p
|
|
363
|
+
} catch (e) { /* not this one */ }
|
|
364
|
+
}
|
|
365
|
+
// round-7 (fix 2): DSH ships its own runtimes; PATH always wins, so this is the LAST resort. The
|
|
366
|
+
// tree name is globbed (never hard-coded) and executable names come from the descriptor candidates.
|
|
367
|
+
for (const p of await dshRuntimeCandidates(H, d.name)) {
|
|
368
|
+
if (typeof p === 'string' && p) return p
|
|
369
|
+
}
|
|
370
|
+
return null
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Generic discovery of a DSH-bundled runtime for one engine:
|
|
374
|
+
// <root>/dsh-runtimes/<tree>/dependencies/<engine>/<candidate><ext>
|
|
375
|
+
// `runtimeRoots` and `listDirAbs` are OPTIONAL host fields (each preset knows its own home and fs);
|
|
376
|
+
// when the host does not provide them this returns [] and behaviour is exactly as before.
|
|
377
|
+
async function dshRuntimeCandidates(H, engineName) {
|
|
378
|
+
if (typeof H.listDirAbs !== 'function' || typeof H.runtimeRoots !== 'function') return []
|
|
379
|
+
const cands = mathEngineCandidates(engineName)
|
|
380
|
+
if (!cands) return []
|
|
381
|
+
let roots = []
|
|
382
|
+
try { roots = (await H.runtimeRoots()) || [] } catch (e) { return [] }
|
|
383
|
+
if (!Array.isArray(roots) || !roots.length) return []
|
|
384
|
+
const win = !!(typeof process !== 'undefined' && process.platform === 'win32')
|
|
385
|
+
const exts = win ? ['.exe', '.cmd', ''] : ['']
|
|
386
|
+
const dirAliases = engineName === 'r' ? ['r', 'R'] : [engineName]
|
|
387
|
+
const out = []
|
|
388
|
+
for (const root of roots) {
|
|
389
|
+
if (!root) continue
|
|
390
|
+
const base = String(root).replace(/[\\/]+$/, '') + '/dsh-runtimes'
|
|
391
|
+
let trees = []
|
|
392
|
+
try { trees = (await H.listDirAbs(base)) || [] } catch (e) { continue }
|
|
393
|
+
for (const tree of trees) {
|
|
394
|
+
if (!tree || tree.type !== 'directory' || !tree.name) continue
|
|
395
|
+
for (const dirName of dirAliases) {
|
|
396
|
+
const depDir = base + '/' + tree.name + '/dependencies/' + dirName
|
|
397
|
+
let entries = []
|
|
398
|
+
try { entries = (await H.listDirAbs(depDir)) || [] } catch (e) { continue }
|
|
399
|
+
// Only names the descriptor itself would look for (python3/python/py, …), so a bundled
|
|
400
|
+
// runtime cannot smuggle in an unrelated binary.
|
|
401
|
+
for (const c of cands) {
|
|
402
|
+
for (const ext of exts) {
|
|
403
|
+
const hit = entries.find((e) => e && e.type === 'file' && String(e.name) === c + ext)
|
|
404
|
+
if (hit) out.push(depDir + '/' + hit.name)
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
return Array.from(new Set(out))
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// round-7 (live root cause): probe-ish spawns used `projectRoot()` as cwd, but on a BRAND-NEW session
|
|
414
|
+
// that directory does not exist yet, and the host answers such a spawn with `spawned:true, exit:null`
|
|
415
|
+
// (retries cannot help - same cwd, same result). Runs are unaffected because they write the receipt
|
|
416
|
+
// (creating the tree) before spawning. So probes use a cwd that is guaranteed to exist: an explicit
|
|
417
|
+
// host `probeCwd`, else the OS temp dir, else the project root.
|
|
418
|
+
async function probeCwd(H) {
|
|
419
|
+
if (typeof H.probeCwd === 'function') {
|
|
420
|
+
try { const p = await H.probeCwd(); if (typeof p === 'string' && p) return p } catch (e) { /* fall through */ }
|
|
421
|
+
}
|
|
422
|
+
const env = (typeof process !== 'undefined' && process.env) || {}
|
|
423
|
+
const tmp = env.TEMP || env.TMPDIR || env.TMP
|
|
424
|
+
if (typeof tmp === 'string' && tmp) return tmp
|
|
425
|
+
return await H.projectRoot()
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async function probeVersion(H, d, exe, opts) {
|
|
429
|
+
const o = opts || {}
|
|
430
|
+
const argv = [exe].concat(d.versionArgv || [])
|
|
431
|
+
const timeoutMs = Number(o.timeoutMs) || 20000 // a COLD host runner can take seconds; 5s was too tight
|
|
432
|
+
const r = await H.spawn({ argv: argv, cwd: await probeCwd(H), timeoutMs: timeoutMs, stdoutCap: 8192, stderrCap: 8192 })
|
|
433
|
+
const diag = {
|
|
434
|
+
argv: argv.slice(0, 3),
|
|
435
|
+
exit: r ? (r.exit === undefined ? null : r.exit) : null,
|
|
436
|
+
timedOut: !!(r && r.timedOut),
|
|
437
|
+
ms: r ? r.ms : null,
|
|
438
|
+
spawned: !!r,
|
|
439
|
+
stderrTail: String((r && r.stderr) || '').slice(-300),
|
|
440
|
+
}
|
|
441
|
+
if (!r) return { ok: false, version: 'unknown', diag: diag }
|
|
442
|
+
if (r.timedOut) return { ok: false, version: 'unknown', diag: diag }
|
|
443
|
+
const m = new RegExp(d.versionRe).exec(String(r.stdout || '') + '\n' + String(r.stderr || ''))
|
|
444
|
+
return { ok: r.exit === 0 && !!m, version: m ? m[1] : 'unknown', diag: diag }
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// round-7 (live-session fix): an engine that RESOLVED but whose version probe failed is retried once
|
|
448
|
+
// with a longer budget WHEN the failure looks like a cold-start/timeout (timedOut, or a spawn that
|
|
449
|
+
// produced no exit code). A deterministic failure (non-zero exit, unparsable output) is NOT retried -
|
|
450
|
+
// retrying every failure would double every probe spawn. The diagnostic travels with the result
|
|
451
|
+
// either way, so "存在但不可用" is never opaque.
|
|
452
|
+
// round-7 (live regression): diagnostics must be JSON-SAFE. An earlier version stored a retry diag
|
|
453
|
+
// that was the SAME object as its parent, so `JSON.stringify(response)` threw ("circular structure")
|
|
454
|
+
// and the caller saw no probe result at all. Project plain fields only, never keep back-references.
|
|
455
|
+
function jsonSafeDiag(d) {
|
|
456
|
+
if (!d || typeof d !== 'object') return null
|
|
457
|
+
const out = { argv: Array.isArray(d.argv) ? d.argv.map(String).slice(0, 4) : [] }
|
|
458
|
+
for (const k of ['exit', 'timedOut', 'ms', 'spawned', 'stderrTail', 'attempts', 'retried']) {
|
|
459
|
+
const v = d[k]
|
|
460
|
+
if (v === undefined) continue
|
|
461
|
+
out[k] = (v === null || typeof v === 'number' || typeof v === 'boolean' || typeof v === 'string') ? v : (Array.isArray(v) ? v.map((x) => (x && typeof x === 'object') ? jsonSafeDiag(x) : x) : null)
|
|
462
|
+
}
|
|
463
|
+
if (d.retryDiag) out.retryDiag = jsonSafeDiag(d.retryDiag)
|
|
464
|
+
return out
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
async function probeVersionWithRetry(H, d, exe) {
|
|
468
|
+
const attempts = []
|
|
469
|
+
let last = null
|
|
470
|
+
for (let i = 0; i < 3; i++) {
|
|
471
|
+
last = await probeVersion(H, d, exe, { timeoutMs: i === 0 ? 20000 : 45000 })
|
|
472
|
+
attempts.push(jsonSafeDiag(last.diag))
|
|
473
|
+
if (last.ok) break
|
|
474
|
+
// A NULL spawn (host returned nothing usable) and a timeout both look like the session-early
|
|
475
|
+
// flakiness the live test hit; a deterministic failure (non-zero exit, unparsable output) is not
|
|
476
|
+
// retried, so a genuinely broken engine is reported immediately instead of three times slower.
|
|
477
|
+
const looksCold = !!(last.diag && (last.diag.timedOut || last.diag.exit === null || !last.diag.spawned))
|
|
478
|
+
if (!looksCold) break
|
|
479
|
+
await new Promise((resolve) => setTimeout(resolve, 250 + i * 400))
|
|
480
|
+
}
|
|
481
|
+
if (last && last.diag) {
|
|
482
|
+
last.diag = jsonSafeDiag(last.diag)
|
|
483
|
+
last.diag.attempts = attempts.length
|
|
484
|
+
if (attempts.length > 1) {
|
|
485
|
+
last.diag.retried = true
|
|
486
|
+
// COPIES, never a reference to this same object (that produced the circular payload).
|
|
487
|
+
last.diag.retryDiag = attempts[attempts.length - 1]
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
return last
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
async function checkLicence(H, d, exe) {
|
|
494
|
+
if (d.license !== 'commercial') return { ok: true }
|
|
495
|
+
if (!d.licenseProbe) return { ok: false }
|
|
496
|
+
const r = await H.spawn({ argv: [exe].concat(d.licenseProbe.argv), cwd: await probeCwd(H), timeoutMs: 10000, stdoutCap: 8192, stderrCap: 8192 })
|
|
497
|
+
if (!r || r.timedOut || r.exit !== 0) return { ok: false }
|
|
498
|
+
if (d.licenseProbe.okWhen === 'trim-1') return { ok: String(r.stdout || '').trim() === '1' }
|
|
499
|
+
if (d.licenseProbe.okWhen === 'not-unlicensed') return { ok: !/Unlicensed/i.test(String(r.stdout || '')) }
|
|
500
|
+
return { ok: true }
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
async function resolveEngine(H, requested, params, args) {
|
|
504
|
+
const names = requested === 'auto' ? params.mathEngines.slice() : [requested]
|
|
505
|
+
for (const name of names) {
|
|
506
|
+
const d0 = MATH_ENGINES[name]
|
|
507
|
+
if (!d0) continue
|
|
508
|
+
// AUDIT-B FIX (HIGH): the override must also reach the VERSION probe and the licence probe, not
|
|
509
|
+
// just the script/eval argv - the BAD_ARGV hint advertises versionArgv explicitly.
|
|
510
|
+
const d = applyOverride({ name: name, desc: d0 }, params, args)
|
|
511
|
+
if (params.mathEngines.indexOf(name) === -1) return fail('MATH_REFUSED', name, '引擎 ' + name + ' 不在 mathEngines 允许列表内', { next: next('reason', { reason: 'engine-not-allowed' }) })
|
|
512
|
+
if (name === 'cli') {
|
|
513
|
+
if (params.mathMode !== 'typed+shell') return fail('MATH_REFUSED', name, 'cli 被策略禁用(mathMode=' + params.mathMode + ')', { next: next('reason', { reason: 'policy' }) })
|
|
514
|
+
let exe = null
|
|
515
|
+
try { exe = await H.resolveExecutable(args.cli.command) } catch (e) { exe = null }
|
|
516
|
+
if (!exe) return fail('MATH_ENGINE_NOT_FOUND', name, 'cli 命令无法解析:' + args.cli.command, { next: userInstallNext('cli') })
|
|
517
|
+
// round-7 (finding 5): report the REAL version when the command's family is recognisable (the
|
|
518
|
+
// descriptor for that family carries the version probe); unknown commands stay 'unknown'.
|
|
519
|
+
let cliVersion = 'unknown'
|
|
520
|
+
let cliProbe = null
|
|
521
|
+
const fam = engineFamilyForExe(exe)
|
|
522
|
+
if (fam && MATH_ENGINES[fam]) {
|
|
523
|
+
const fv = await probeVersionWithRetry(H, MATH_ENGINES[fam], exe)
|
|
524
|
+
cliProbe = fv.diag || null
|
|
525
|
+
if (fv.ok) cliVersion = fv.version
|
|
526
|
+
}
|
|
527
|
+
return { ok: true, name: name, desc: d, exe: exe, version: cliVersion, family: fam || null, probe: cliProbe }
|
|
528
|
+
}
|
|
529
|
+
const hit = await resolveCandidate(H, d)
|
|
530
|
+
if (!hit) continue
|
|
531
|
+
const v = await probeVersionWithRetry(H, d, hit)
|
|
532
|
+
if (!v.ok) {
|
|
533
|
+
if (d.license === 'commercial' || d.versionOptional) {
|
|
534
|
+
const lic = await checkLicence(H, d, hit)
|
|
535
|
+
if (!lic.ok) return fail('MATH_ENGINE_LICENSE_REQUIRED', name, name + ' 已安装但许可不可用(仅厂商可激活)', { next: next('vendor', { engine: name, url: d.vendor || '' }) })
|
|
536
|
+
return { ok: true, name: name, desc: d, exe: hit, version: 'unknown' }
|
|
537
|
+
}
|
|
538
|
+
// round-7 (live-session fix): the engine WAS found - so an install guide would be misleading
|
|
539
|
+
// (the user does not need to install anything; the host path is what failed). Report the probe
|
|
540
|
+
// diagnostic instead and keep the escape hatch advisory.
|
|
541
|
+
return fail('MATH_ENGINE_UNUSABLE', name, name + ' 存在但不可用(版本探针失败:exit=' + String(v.diag.exit) + (v.diag.timedOut ? ',超时' : '') + ',argv=' + JSON.stringify(v.diag.argv) + ')', {
|
|
542
|
+
probe: v.diag,
|
|
543
|
+
next: next('note', { guidance: '引擎已找到但探测失败:请检查宿主 subprocess 通路(冷启动/超时/权限),或用 mathEngineOverride 调整 versionArgv 模板;已装的引擎不需要重装。' }),
|
|
544
|
+
})
|
|
545
|
+
}
|
|
546
|
+
const lic = await checkLicence(H, d, hit)
|
|
547
|
+
if (!lic.ok) return fail('MATH_ENGINE_LICENSE_REQUIRED', name, name + ' 已安装但许可不可用(仅厂商可激活)', { next: next('vendor', { engine: name, url: d.vendor || '' }) })
|
|
548
|
+
return { ok: true, name: name, desc: d, exe: hit, version: v.version }
|
|
549
|
+
}
|
|
550
|
+
const first = requested === 'auto' ? (params.mathEngines[0] || 'python') : requested
|
|
551
|
+
return fail('MATH_ENGINE_NOT_FOUND', first, '本机没有可用的计算引擎(试过:' + names.join(', ') + ')', { next: userInstallNext(first) })
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// ── package probe ───────────────────────────────────────────────────────────────────────────────
|
|
555
|
+
function buildProbeArgv(d, pkgs) {
|
|
556
|
+
const quoted = pkgs.map((p) => '"' + String(p).replace(/"/g, '') + '"').join(',')
|
|
557
|
+
const plain = pkgs.join(',')
|
|
558
|
+
const code = String(d.probeCode || '').replace(/__QPKGS__/g, quoted).replace(/__PKGS__/g, plain)
|
|
559
|
+
const out = []
|
|
560
|
+
for (const a of (d.packageProbe.argv || [])) {
|
|
561
|
+
// `<pkgs...>` expands to ONE argv item per package (probes that read `sys.argv`); `<pkgs>` stays
|
|
562
|
+
// the comma-joined string for templates that interpolate it into code (R/Julia/Octave).
|
|
563
|
+
if (a === '<probeCode>') out.push(code)
|
|
564
|
+
else if (a === '<pkgs>') out.push(plain)
|
|
565
|
+
else if (a === '<pkgs...>') for (const p of pkgs) out.push(String(p))
|
|
566
|
+
else out.push(a)
|
|
567
|
+
}
|
|
568
|
+
return out
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
async function probePackages(H, det, pkgs) {
|
|
572
|
+
// round-6 (D): presence is probed by BASE NAME (a version constraint is the manager's business);
|
|
573
|
+
// the full spec is what the install plan passes through.
|
|
574
|
+
const names = []
|
|
575
|
+
for (const s of pkgs) { const n = parsePackageSpec(s).name || String(s); if (names.indexOf(n) === -1) names.push(n) }
|
|
576
|
+
const found = {}
|
|
577
|
+
for (const p of names) found[p] = null
|
|
578
|
+
if (!names.length || !det.desc.packageProbe) return { requested: names, found: found }
|
|
579
|
+
const argv = [det.exe].concat(buildProbeArgv(det.desc, names))
|
|
580
|
+
// round-7 (live-session fix): a NULL spawn (the host's subprocess seam returned nothing - seen in
|
|
581
|
+
// real sessions right after session start) or a timeout must NOT be reported as "package missing".
|
|
582
|
+
// Retry a couple of times, and if the precheck still cannot run, say so explicitly (fail-open).
|
|
583
|
+
let r = null
|
|
584
|
+
const attempts = []
|
|
585
|
+
for (let i = 0; i < 3; i++) {
|
|
586
|
+
r = await H.spawn({ argv: argv, cwd: await probeCwd(H), timeoutMs: 15000 + i * 15000, stdoutCap: 16384, stderrCap: 16384 })
|
|
587
|
+
attempts.push(r ? { exit: r.exit === undefined ? null : r.exit, timedOut: !!r.timedOut } : null)
|
|
588
|
+
const usable = !!(r && !r.timedOut && r.exit !== null && r.exit !== undefined)
|
|
589
|
+
if (usable) break
|
|
590
|
+
await new Promise((resolve) => setTimeout(resolve, 250 + i * 400))
|
|
591
|
+
}
|
|
592
|
+
const ran = !!(r && !r.timedOut && r.exit !== null && r.exit !== undefined)
|
|
593
|
+
if (!ran) return { requested: names, found: found, probeFailed: true, attempts: attempts, argv: argv.slice(0, 3) }
|
|
594
|
+
if (det.desc.packageProbe.parse === 'lines') {
|
|
595
|
+
const text = String(r.stdout || '')
|
|
596
|
+
for (const p of pkgs) if (new RegExp('(^|\\s)' + p.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '(\\s|$)', 'm').test(text)) found[p] = 'present'
|
|
597
|
+
} else {
|
|
598
|
+
// Accept BOTH shapes: `name:ok|name:missing` (historical) and one-per-line `name ok` (round-7:
|
|
599
|
+
// the probe codes avoid shell metacharacters such as `|`, which a real host's spawn rejected).
|
|
600
|
+
for (const rawTok of String(r.stdout || '').split(/[|\n]/)) {
|
|
601
|
+
const tok = rawTok.trim()
|
|
602
|
+
if (!tok) continue
|
|
603
|
+
let name = ''
|
|
604
|
+
let state = ''
|
|
605
|
+
const idx = tok.indexOf(':')
|
|
606
|
+
if (idx > 0) {
|
|
607
|
+
name = tok.slice(0, idx).trim()
|
|
608
|
+
state = tok.slice(idx + 1).trim()
|
|
609
|
+
} else {
|
|
610
|
+
const m = /^(\S+)\s+(\S+)$/.exec(tok)
|
|
611
|
+
if (!m) continue
|
|
612
|
+
name = m[1]
|
|
613
|
+
state = m[2]
|
|
614
|
+
}
|
|
615
|
+
if (Object.prototype.hasOwnProperty.call(found, name)) found[name] = (state === 'ok' || state === '1' || state === 'TRUE' || state === 'True') ? 'present' : null
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
return { requested: pkgs.slice(), found: found }
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// ── argv assembly ───────────────────────────────────────────────────────────────────────────────
|
|
622
|
+
function assembleArgv(det, mode, payload) {
|
|
623
|
+
const d = det.desc
|
|
624
|
+
if (det.name === 'cli') {
|
|
625
|
+
const cliArgv = (payload.cli && Array.isArray(payload.cli.argv)) ? payload.cli.argv : []
|
|
626
|
+
const argv = [det.exe].concat(cliArgv)
|
|
627
|
+
// round-7 (finding 4): with mode:'code' the script is archived as `script.txt`, but the caller
|
|
628
|
+
// cannot know the runId in advance - so the archived script path is appended automatically and
|
|
629
|
+
// the documented "run this code" flow actually RUNS it (instead of dropping into a REPL, exit 0,
|
|
630
|
+
// empty stdout).
|
|
631
|
+
// round-7 (live nuance): ONLY when the caller's argv does not already provide a program slot.
|
|
632
|
+
// `python -c 'print(1)'` runs the caller's code, and appending the archived file would silently
|
|
633
|
+
// feed it as `sys.argv[1]` - the caller's argv must win there.
|
|
634
|
+
const runsOwnCode = cliArgv.some((a) => a === '-c' || a === '-m' || a === '-e' || a === '--eval' || a === '--command')
|
|
635
|
+
const appendScript = !!payload.scriptAbs && (mode === 'code' || mode === 'file') && !runsOwnCode
|
|
636
|
+
if (appendScript) argv.push(payload.scriptAbs)
|
|
637
|
+
return {
|
|
638
|
+
argv: argv,
|
|
639
|
+
cli: {
|
|
640
|
+
command: payload.cli.command, argv: cliArgv, scriptAppended: appendScript,
|
|
641
|
+
scriptSkipped: (!!payload.scriptAbs && (mode === 'code' || mode === 'file') && runsOwnCode) ? 'caller argv already provides a program slot (-c/-m/-e/--eval)' : null,
|
|
642
|
+
},
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
if (mode === 'code' || mode === 'file') {
|
|
646
|
+
return { argv: [det.exe].concat((d.scriptArgv || []).map((a) => a === '<script>' ? payload.scriptAbs : a)) }
|
|
647
|
+
}
|
|
648
|
+
if (mode === 'expr') {
|
|
649
|
+
if (!d.evalArgv) return { refused: 'engine does not support mode=expr' }
|
|
650
|
+
return { argv: [det.exe].concat(d.evalArgv.map((a) => a === '<expr>' ? payload.expr : a)) }
|
|
651
|
+
}
|
|
652
|
+
return { refused: 'unknown mode' }
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// round-7 (findings 3/5): for `engine:'cli'` the descriptor is generic, so the FAMILY of the resolved
|
|
656
|
+
// command has to be inferred from its basename to (a) run the right package precheck and (b) report a
|
|
657
|
+
// real version. Unknown commands are reported as unknown and their package precheck is SKIPPED (a
|
|
658
|
+
// false "missing" must never block the escape hatch).
|
|
659
|
+
function engineFamilyForExe(exe) {
|
|
660
|
+
const base = String(exe || '').replace(/\\/g, '/').split('/').pop().toLowerCase().replace(/\.(exe|cmd|bat|sh)$/, '')
|
|
661
|
+
if (/^python[0-9.]*$/.test(base) || base === 'py') return 'python'
|
|
662
|
+
if (base === 'rscript' || base === 'r') return 'r'
|
|
663
|
+
if (base === 'octave' || base === 'octave-cli') return 'octave'
|
|
664
|
+
if (base === 'julia') return 'julia'
|
|
665
|
+
return null
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// AUDIT-B FIX (HIGH): `applyOverride` used to read only `params.mathEngineOverride`, which no preset
|
|
669
|
+
// can ever set (the six frozen params are copied verbatim by effectiveMathParams), while the tool
|
|
670
|
+
// description, MATH_BAD_ARGV_HINT and the BAD_ARGV message all advertised it. The per-call ARGUMENT
|
|
671
|
+
// now wins and is the documented route; the param route stays as a fallback for hosts that inject it.
|
|
672
|
+
function applyOverride(det, params, args) {
|
|
673
|
+
const d = det.desc
|
|
674
|
+
const fromArgs = args && isObj(args.mathEngineOverride) ? args.mathEngineOverride : null
|
|
675
|
+
const ov = fromArgs || (params && params.mathEngineOverride)
|
|
676
|
+
const o = ov && isObj(ov) ? ov[det.name] : null
|
|
677
|
+
if (!o) return d
|
|
678
|
+
return Object.assign({}, d, {
|
|
679
|
+
versionArgv: Array.isArray(o.versionArgv) ? o.versionArgv.slice() : d.versionArgv,
|
|
680
|
+
scriptArgv: Array.isArray(o.scriptArgv) ? o.scriptArgv.slice() : d.scriptArgv,
|
|
681
|
+
evalArgv: Array.isArray(o.evalArgv) ? o.evalArgv.slice() : d.evalArgv,
|
|
682
|
+
packageProbe: isObj(o.packageProbe) ? o.packageProbe : d.packageProbe,
|
|
683
|
+
})
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// ── receipt ────────────────────────────────────────────────────────────────────────────────────
|
|
687
|
+
function runIdOf(designator, projectSlug, engine, mode, scriptText, packages, fileRel) {
|
|
688
|
+
// P2a: mode:'file' is keyed by the SOURCE PATH, not by its content, so that editing the file and
|
|
689
|
+
// re-running lands in the same archive id - which is what makes the hash reconciliation below
|
|
690
|
+
// meaningful. mode:'code'/'expr' have no path, so their content stays the key.
|
|
691
|
+
const key = (mode === 'file' && fileRel) ? ('file:' + fileRel) : canonical(scriptText)
|
|
692
|
+
const input = sha256([engine, mode, key, packages.slice().sort().join(',')].join('\n'))
|
|
693
|
+
return { runId: designator + '-' + projectSlug + '-' + input.slice(0, 12), input: input }
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// P2a item 3: append-only archives. Attempt 1 lives in Computation/<id>/ and is never rewritten;
|
|
697
|
+
// later runs of the same id go to Computation/<id>/attempts/<n>/ (first free n >= 2).
|
|
698
|
+
//
|
|
699
|
+
// AUDIT-C FIX (HIGH): `exists`-then-write is a read-modify-write race. Two concurrent runs of the
|
|
700
|
+
// SAME archive id both observed "no receipt.json yet", both took attempt 1, and the second silently
|
|
701
|
+
// overwrote the first receipt/script - so the append-only guarantee did not hold exactly when two
|
|
702
|
+
// members ran the same computation at once. Allocation (and the receipt write that claims the slot)
|
|
703
|
+
// now runs under a per-archive-id in-process mutex, so two calls through one plugin instance can
|
|
704
|
+
// never share an attempt dir. Cross-process concurrency still needs an exclusive-create primitive
|
|
705
|
+
// from the host (documented in docs/math-computation.md §6).
|
|
706
|
+
const mathArchiveChains = new Map()
|
|
707
|
+
function withArchiveLock(key, fn) {
|
|
708
|
+
const prev = mathArchiveChains.get(key) || Promise.resolve()
|
|
709
|
+
const run = prev.then(fn, fn)
|
|
710
|
+
const tail = run.then(() => {}, () => {})
|
|
711
|
+
mathArchiveChains.set(key, tail)
|
|
712
|
+
tail.then(() => { if (mathArchiveChains.get(key) === tail) mathArchiveChains.delete(key) })
|
|
713
|
+
return run
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
async function nextAttempt(H, baseDir) {
|
|
717
|
+
const hasFirst = await H.exists(baseDir + '/receipt.json')
|
|
718
|
+
if (!hasFirst) return { attempt: 1, dir: baseDir }
|
|
719
|
+
let n = 2
|
|
720
|
+
while (n < 10000) {
|
|
721
|
+
const d = baseDir + '/attempts/' + n
|
|
722
|
+
if (!(await H.exists(d + '/receipt.json'))) return { attempt: n, dir: d }
|
|
723
|
+
n++
|
|
724
|
+
}
|
|
725
|
+
return { attempt: n, dir: baseDir + '/attempts/' + n }
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
async function previousReceiptOf(H, baseDir, attempt) {
|
|
729
|
+
if (attempt > 2) {
|
|
730
|
+
const prev = await H.readText(baseDir + '/attempts/' + (attempt - 1) + '/receipt.json')
|
|
731
|
+
if (prev !== undefined) { try { return JSON.parse(prev) } catch (e) { /* fall through */ } }
|
|
732
|
+
}
|
|
733
|
+
const first = await H.readText(baseDir + '/receipt.json')
|
|
734
|
+
if (first !== undefined) { try { return JSON.parse(first) } catch (e) { /* ignore */ } }
|
|
735
|
+
return null
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function warning(code, message) { return { code: code, message: message } }
|
|
739
|
+
|
|
740
|
+
async function retentionWarnings(H, baseDir, attempt) {
|
|
741
|
+
const out = []
|
|
742
|
+
if (attempt > MATH_ARCHIVE_MAX_ATTEMPTS_PER_RUN) {
|
|
743
|
+
out.push(warning(MATH_ARCHIVE_WARNING, 'Computation/ 归档的 attempt 数已超过上限 ' + MATH_ARCHIVE_MAX_ATTEMPTS_PER_RUN + '(本次为第 ' + attempt + ' 次);工具只告警、绝不自动删除,请人工整理旧 attempt。'))
|
|
744
|
+
}
|
|
745
|
+
if (typeof H.listDir === 'function' && H.listDir) {
|
|
746
|
+
try {
|
|
747
|
+
const entries = await H.listDir('Computation')
|
|
748
|
+
const runs = (entries || []).filter((e) => e && e.type === 'directory').length
|
|
749
|
+
if (runs > MATH_ARCHIVE_MAX_RUNS) out.push(warning(MATH_ARCHIVE_WARNING, 'Computation/ 下的 run 目录数(' + runs + ')已超过上限 ' + MATH_ARCHIVE_MAX_RUNS + ';工具只告警、绝不自动删除。'))
|
|
750
|
+
} catch (e) { /* best effort */ }
|
|
751
|
+
}
|
|
752
|
+
return out
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function renderReceiptMd(r, fullOut, fullErr) {
|
|
756
|
+
const lines = []
|
|
757
|
+
lines.push('# 计算回执|' + r.preset + ' · ' + r.project + '|' + r.runId + (r.attempt && r.attempt > 1 ? '(attempt ' + r.attempt + ')' : ''))
|
|
758
|
+
lines.push('')
|
|
759
|
+
lines.push('- 引擎:' + r.engine.name + ' ' + r.engine.version + '(source=' + r.engine.source + ',path=' + r.engine.path + ')')
|
|
760
|
+
lines.push('- 模式:' + r.mode + '|cwd:' + r.cwd + '|attempt:' + (r.attempt || 1) + '(归档目录 ' + (r.attemptDir || '') + ',同 id 的首次归档 ' + (r.baseRunDir || '') + ')')
|
|
761
|
+
lines.push('- 命令(照抄可复跑):' + r.argv.join(' '))
|
|
762
|
+
lines.push('- 结果:exit=' + r.exit + '|耗时 ' + r.ms + 'ms|超时=' + r.timedOut + '|输出截断 ' + r.truncated.stdout + '/' + r.truncated.stderr)
|
|
763
|
+
lines.push('- 脚本:' + (r.scriptPath || r.script.path) + '|scriptHash=sha256:' + String(r.scriptHash || r.script.sha256) + '(' + r.script.bytes + ' bytes)')
|
|
764
|
+
if (r.sourceFile) {
|
|
765
|
+
lines.push('- 源文件:' + r.sourceFile + '(运行前 sha256:' + String(r.sourceHashBefore || '').slice(0, 16) + '…,运行后 sha256:' + String(r.sourceHashAfter || '').slice(0, 16) + '…)')
|
|
766
|
+
lines.push('- 哈希核对:scriptChanged=' + !!r.scriptChanged + '|scriptChangedDuringRun=' + !!r.scriptChangedDuringRun
|
|
767
|
+
+ (r.previousReceipt ? '(上一份回执 attempt ' + (r.previousReceipt.attempt || 1) + ' 记的 scriptHash 为 ' + String(r.previousReceipt.scriptHash || '').slice(0, 16) + '…)' : '(没有更早的回执)'))
|
|
768
|
+
if (r.scriptChanged) lines.push(' ▸ **旧回执不代表当前代码**:引用证据时必须用 scriptHash 与当前文件一致的那份回执。')
|
|
769
|
+
if (r.scriptChangedDuringRun) lines.push(' ▸ **本次运行期间文件被改动**:请重跑并引用重跑后的回执。')
|
|
770
|
+
}
|
|
771
|
+
const missing = Object.keys(r.packages.found).filter((k) => !r.packages.found[k])
|
|
772
|
+
lines.push('- 包:' + (Object.keys(r.packages.found).length ? Object.keys(r.packages.found).map((k) => k + '=' + (r.packages.found[k] || '未找到')).join('、') : '(未请求)') + (missing.length ? '|缺失:' + missing.join(',') : ''))
|
|
773
|
+
lines.push('- 网络:**未由插件强制**(subprocess.spawn 无 policy 槽)')
|
|
774
|
+
lines.push('- 复现:math_computation { op:\'run\', engine:\'' + r.engine.name + '\', mode:\'file\', file:\'' + (r.scriptPath || r.script.path) + '\' }')
|
|
775
|
+
for (const w of (r.warnings || [])) lines.push('- ⚠ [' + w.code + '] ' + w.message)
|
|
776
|
+
lines.push('')
|
|
777
|
+
lines.push('## 输出摘要(stdout,共 ' + String(fullOut || '').split('\n').length + ' 行)')
|
|
778
|
+
lines.push(String(fullOut || '').split('\n').slice(0, 20).join('\n'))
|
|
779
|
+
if (fullErr) { lines.push(''); lines.push('## stderr(前 10 行)'); lines.push(String(fullErr).split('\n').slice(0, 10).join('\n')) }
|
|
780
|
+
lines.push('')
|
|
781
|
+
lines.push('> 本回执是**经验证据**,不是数学证明;对象是否"已验证"仍由该预设既有的验证/共识路径给出。')
|
|
782
|
+
return lines.join('\n') + '\n'
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// ── op: probe ──────────────────────────────────────────────────────────────────────────────────
|
|
786
|
+
async function opProbe(H, args, params) {
|
|
787
|
+
const probe = await probeMathEngines(H, { refresh: !!(args.packages && args.packages.indexOf('refresh') !== -1) })
|
|
788
|
+
const pkgs = (args.packages || []).filter((p) => p !== 'refresh')
|
|
789
|
+
const want = pkgs.length ? pkgs : params.mathPackages
|
|
790
|
+
// round-7 (fix 1): a probe that NAMES an engine must describe THAT engine. Previously the response
|
|
791
|
+
// carried `engine: null` (or the first ALLOWED engine) and the failure path always blamed
|
|
792
|
+
// `params.mathEngines[0]` (python) - so `probe {engine:'r'}` echoed python and told the user to
|
|
793
|
+
// install python. Resolve the requested engine up front instead.
|
|
794
|
+
const requested = (args.engine && args.engine !== 'auto') ? String(args.engine) : null
|
|
795
|
+
const chosen = requested
|
|
796
|
+
? await resolveEngine(H, requested, params, args)
|
|
797
|
+
: (probe.engines.length ? await resolveEngine(H, probe.engines[0].name, params, args) : null)
|
|
798
|
+
let packages = { requested: want.slice(), found: {} }
|
|
799
|
+
const probeDet = familyProbeDet(chosen)
|
|
800
|
+
if (want.length && probeDet) packages = await probePackages(H, probeDet, want)
|
|
801
|
+
else if (want.length && chosen && chosen.ok && chosen.name === 'cli') packages = { requested: want.map((s) => parsePackageSpec(s).name || s), found: {}, precheckSkipped: 'engine=cli:无法从命令名判断引擎族(不阻塞)' }
|
|
802
|
+
if (requested && chosen && !chosen.ok) {
|
|
803
|
+
// Policy/absence refusal for the REQUESTED engine, with its own guidance.
|
|
804
|
+
const out = Object.assign({}, chosen)
|
|
805
|
+
out.op = 'probe'
|
|
806
|
+
out.engines = probe.engines
|
|
807
|
+
out.available = !!probe.available
|
|
808
|
+
out.packages = packages
|
|
809
|
+
return out
|
|
810
|
+
}
|
|
811
|
+
if (!probe.available && !(chosen && chosen.ok)) {
|
|
812
|
+
const first = requested || params.mathEngines[0] || 'python'
|
|
813
|
+
const out = fail('MATH_ENGINE_NOT_FOUND', first, '本机没有可用的计算引擎(请求:' + first + ')', { next: userInstallNext(first), engines: [], available: false, packages: packages })
|
|
814
|
+
out.op = 'probe'
|
|
815
|
+
return out
|
|
816
|
+
}
|
|
817
|
+
// A REQUESTED engine that resolved (e.g. `cli`, or a runtime-discovered interpreter) counts as
|
|
818
|
+
// available even when auto-detection found nothing - otherwise probe denied what run would do.
|
|
819
|
+
const engines = probe.engines.slice()
|
|
820
|
+
if (chosen && chosen.ok && !engines.some((e) => e.name === chosen.name)) {
|
|
821
|
+
engines.push({ name: chosen.name, path: chosen.exe, version: chosen.version, license: (MATH_ENGINES[chosen.name] || {}).license })
|
|
822
|
+
}
|
|
823
|
+
const engineInfo = (chosen && chosen.ok) ? { name: chosen.name, path: chosen.exe, version: chosen.version } : null
|
|
824
|
+
return {
|
|
825
|
+
ok: true, op: 'probe', engine: engineInfo ? engineInfo.name : null, engineInfo: engineInfo,
|
|
826
|
+
engines: engines, available: true, packages: packages,
|
|
827
|
+
message: '可用引擎:' + engines.map((e) => e.name + ' ' + e.version).join('、') + (engineInfo ? '(请求 ' + engineInfo.name + ':' + engineInfo.version + ')' : ''),
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// round-7 (finding 3): a cli command has no package probe of its own, so the FAMILY descriptor of
|
|
832
|
+
// the resolved executable must provide one. Returns null when the family is unknown - the caller then
|
|
833
|
+
// SKIPS the precheck (never a false "missing", which blocked the escape hatch).
|
|
834
|
+
function familyProbeDet(det) {
|
|
835
|
+
if (!det || det.name !== 'cli') return det
|
|
836
|
+
const fam = det.family || engineFamilyForExe(det.exe)
|
|
837
|
+
const fd = fam ? MATH_ENGINES[fam] : null
|
|
838
|
+
return (fd && fd.packageProbe) ? { ok: true, name: fam, desc: fd, exe: det.exe, version: det.version } : null
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// ── op: run ─────────────────────────────────────────────────────────────────────────────────────
|
|
842
|
+
async function opRun(H, args, params) {
|
|
843
|
+
const mode = args.mode
|
|
844
|
+
const det = await resolveEngine(H, args.engine || 'auto', params, args)
|
|
845
|
+
if (!det.ok) return det
|
|
846
|
+
const desc = applyOverride(det, params, args)
|
|
847
|
+
const det2 = { ok: true, name: det.name, desc: desc, exe: det.exe, version: det.version }
|
|
848
|
+
// The generic escape hatch is recorded as `cli:<command>` (spec §4.3 / guards §18): the model and
|
|
849
|
+
// the receipt must be able to tell WHICH command was run, not just that it was "cli".
|
|
850
|
+
const cliCommand = args.cli && typeof args.cli.command === 'string' ? args.cli.command : ''
|
|
851
|
+
const engineLabel = det.name === 'cli' ? ('cli:' + cliCommand) : det.name
|
|
852
|
+
|
|
853
|
+
const want = (args.packages && args.packages.length) ? args.packages : params.mathPackages
|
|
854
|
+
// round-7 (finding 3): for cli the precheck runs against the FAMILY of `cli.command` (same
|
|
855
|
+
// discovery-shaped logic); an unrecognisable command SKIPS the precheck instead of reporting a
|
|
856
|
+
// false "missing" that would block the escape hatch.
|
|
857
|
+
const probeDet = familyProbeDet(det2)
|
|
858
|
+
const pk = probeDet ? await probePackages(H, probeDet, want) : { requested: want.map((s) => parsePackageSpec(s).name || s), found: {} }
|
|
859
|
+
// Presence is keyed by BASE NAME (round-6 D); the plan keeps the original specs so the manager
|
|
860
|
+
// receives the version constraint verbatim. round-7: a precheck that could not RUN (null spawn /
|
|
861
|
+
// timeout) must not be read as "missing" - that misled a real session.
|
|
862
|
+
const missing = (probeDet && !pk.probeFailed) ? want.map((s) => parsePackageSpec(s).name || s).filter((p) => Object.prototype.hasOwnProperty.call(pk.found, p) && !pk.found[p]) : []
|
|
863
|
+
if (missing.length) {
|
|
864
|
+
return fail('MATH_MISSING_PACKAGES', det.name, '需要的包未安装:' + missing.join(', ') + '(只检查是否存在;版本求解交给包管理器)', {
|
|
865
|
+
missing: missing,
|
|
866
|
+
next: next('agent-install', { engine: det.name, packages: missing, specs: want.slice(), dryRun: true }),
|
|
867
|
+
packages: pk,
|
|
868
|
+
})
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
const root = await H.projectRoot()
|
|
872
|
+
let scriptText = ''
|
|
873
|
+
let fileRel = null
|
|
874
|
+
let sourceHashBefore = null
|
|
875
|
+
if (mode === 'code') scriptText = args.code
|
|
876
|
+
else if (mode === 'file') {
|
|
877
|
+
fileRel = projectRel('', args.file)
|
|
878
|
+
if (fileRel === null) return fail('MATH_REFUSED', det.name, 'file 必须位于项目内:' + args.file, { next: next('reason', { reason: 'path-outside-project' }) })
|
|
879
|
+
const txt = await H.readText(fileRel)
|
|
880
|
+
if (txt === undefined) return fail('MATH_REFUSED', det.name, '找不到文件:' + args.file, { next: next('reason', { reason: 'file-not-found' }) })
|
|
881
|
+
scriptText = txt
|
|
882
|
+
sourceHashBefore = sha256(txt)
|
|
883
|
+
} else scriptText = (args.expr || '') + '\n'
|
|
884
|
+
|
|
885
|
+
const { runId, input } = runIdOf(H.designator, slug(root), det.name, mode, scriptText, want, fileRel)
|
|
886
|
+
const baseDir = 'Computation/' + runId
|
|
887
|
+
// P2a item 3 + AUDIT-C FIX (HIGH): the whole claim-and-archive section for one archive id is
|
|
888
|
+
// serialised. Allocation, the script/stdout/stderr writes and receipt.json (which claims the slot)
|
|
889
|
+
// must not interleave with another run of the same id, or both take attempt 1 and one is lost.
|
|
890
|
+
return await withArchiveLock(baseDir, async () => {
|
|
891
|
+
const slot = await nextAttempt(H, baseDir)
|
|
892
|
+
const dir = slot.dir
|
|
893
|
+
const attempt = slot.attempt
|
|
894
|
+
const prevReceipt = await previousReceiptOf(H, baseDir, attempt)
|
|
895
|
+
const scriptRel = dir + '/script' + (det.name === 'cli' ? '.txt' : desc.ext)
|
|
896
|
+
const scriptAbs = absJoin(root, scriptRel)
|
|
897
|
+
const scriptHash = sha256(scriptText)
|
|
898
|
+
// P2a item 2: reconciliation against the previous receipt for the same archive id (mode:'file' is
|
|
899
|
+
// path-keyed, so this is exactly "the file changed since that receipt").
|
|
900
|
+
const scriptChanged = !!(mode === 'file' && prevReceipt && prevReceipt.scriptHash && prevReceipt.scriptHash !== scriptHash)
|
|
901
|
+
const wrote = await H.writeText(scriptRel, scriptText)
|
|
902
|
+
if (wrote === false) return fail('MATH_INVALID_ARGUMENT', det.name, '无法写入 ' + scriptRel)
|
|
903
|
+
|
|
904
|
+
const assembled = assembleArgv(det2, mode, {
|
|
905
|
+
scriptAbs: scriptAbs,
|
|
906
|
+
expr: args.expr,
|
|
907
|
+
cli: args.cli,
|
|
908
|
+
})
|
|
909
|
+
if (assembled.refused) return fail('MATH_REFUSED', det.name, assembled.refused, { next: next('reason', { reason: 'mode-not-supported' }) })
|
|
910
|
+
|
|
911
|
+
const timeoutMs = args.timeoutMs || params.mathTimeoutMs
|
|
912
|
+
const r = await H.spawn({ argv: assembled.argv, cwd: root, timeoutMs: timeoutMs, stdoutCap: MATH_CAPS.file, stderrCap: MATH_CAPS.file })
|
|
913
|
+
// P2a item 2/4: hash the SOURCE again after the run. A member editing the file while this run was
|
|
914
|
+
// in flight shows up here (and never silently).
|
|
915
|
+
let sourceHashAfter = null
|
|
916
|
+
let scriptChangedDuringRun = false
|
|
917
|
+
if (mode === 'file' && fileRel) {
|
|
918
|
+
const after = await H.readText(fileRel)
|
|
919
|
+
sourceHashAfter = after === undefined ? null : sha256(after)
|
|
920
|
+
// AUDIT-A FIX: a null post-run hash means the source DISAPPEARED during the run - that is a
|
|
921
|
+
// change, not "no change". Comparing null-safe (null !== beforeHash) keeps it fail-closed.
|
|
922
|
+
scriptChangedDuringRun = sourceHashAfter !== sourceHashBefore
|
|
923
|
+
}
|
|
924
|
+
if (!r) return fail('MATH_NO_SUBPROCESS', det.name, '宿主不提供 subprocess 服务,无法执行计算', { next: next('note', { reason: 'no-subprocess' }) })
|
|
925
|
+
|
|
926
|
+
const fullOut = String(r.stdout || '')
|
|
927
|
+
const fullErr = String(r.stderr || '')
|
|
928
|
+
await H.writeText(dir + '/stdout.txt', fullOut)
|
|
929
|
+
await H.writeText(dir + '/stderr.txt', fullErr)
|
|
930
|
+
|
|
931
|
+
const captured = []
|
|
932
|
+
for (const cf of (args.captureFiles || [])) {
|
|
933
|
+
const rel = projectRel('', cf)
|
|
934
|
+
if (rel === null) continue
|
|
935
|
+
const txt = await H.readText(rel)
|
|
936
|
+
if (txt === undefined) continue
|
|
937
|
+
const dest = dir + '/captured/' + rel
|
|
938
|
+
if (await H.writeText(dest, txt) !== false) captured.push(rel)
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
const warnings = []
|
|
942
|
+
if (!probeDet && want.length) warnings.push(warning('PACKAGE_PRECHECK_SKIPPED', 'engine=cli:无法从命令名判断引擎族,已跳过包预检(不阻塞执行,也不会误报缺包);如需预检请直接用 python/r/octave/julia 或改用可识别的解释器路径。'))
|
|
943
|
+
if (pk && pk.probeFailed && want.length) warnings.push(warning('PACKAGE_PRECHECK_UNKNOWN', '包预检未能执行(宿主 subprocess 在这几次尝试里返回空/超时,会话早期常见):本次不判定缺包、不阻塞执行;如需确定性结论请重试或直接用对应引擎。probe=' + JSON.stringify(pk.argv) + ' attempts=' + JSON.stringify(pk.attempts)))
|
|
944
|
+
if (fullOut.length > MATH_CAPS.stdout) warnings.push(warning('OUTPUT_TRUNCATED', 'stdout 超过 64KB,完整版见 ' + dir + '/stdout.txt'))
|
|
945
|
+
if (scriptChanged) warnings.push(warning(MATH_SCRIPT_CHANGED_WARNING, 'scriptChanged:文件 ' + fileRel + ' 自上一次回执以来已改变(旧回执不再代表当前代码),本次已写出新 attempt ' + attempt + '。'))
|
|
946
|
+
if (scriptChangedDuringRun) warnings.push(warning(MATH_SCRIPT_CHANGED_DURING_RUN_WARNING, 'scriptChangedDuringRun:文件 ' + fileRel + ' 在本次运行期间被改动(可能有另一个成员在编辑);本次回执的 scriptHash 是运行前版本 ' + String(sourceHashBefore).slice(0, 12) + '…,请重跑后再引用。'))
|
|
947
|
+
for (const w of await retentionWarnings(H, baseDir, attempt)) warnings.push(w)
|
|
948
|
+
|
|
949
|
+
const receipt = {
|
|
950
|
+
schema: 'vibe-math/math-computation-receipt@1',
|
|
951
|
+
runId: runId, preset: H.designator, project: slug(root), op: 'run', mode: mode,
|
|
952
|
+
attempt: attempt, attemptDir: dir, baseRunDir: baseDir,
|
|
953
|
+
engine: { name: engineLabel, path: det.exe, version: det.version, source: det.name === 'cli' ? 'cli' : 'path' },
|
|
954
|
+
script: { path: scriptRel, sha256: scriptHash, bytes: Buffer.byteLength(scriptText, 'utf8') },
|
|
955
|
+
scriptPath: scriptRel, scriptHash: scriptHash,
|
|
956
|
+
sourceFile: fileRel || null,
|
|
957
|
+
sourceHashBefore: sourceHashBefore, sourceHashAfter: sourceHashAfter,
|
|
958
|
+
scriptChanged: scriptChanged, scriptChangedDuringRun: scriptChangedDuringRun,
|
|
959
|
+
previousReceipt: prevReceipt ? { runId: prevReceipt.runId, attempt: prevReceipt.attempt || 1, scriptHash: prevReceipt.scriptHash || (prevReceipt.script && prevReceipt.script.sha256) || null } : null,
|
|
960
|
+
argv: assembled.argv.slice(),
|
|
961
|
+
cwd: root,
|
|
962
|
+
packages: { requested: want.slice(), found: pk.found },
|
|
963
|
+
exit: r.timedOut ? null : (r.exit === undefined ? null : r.exit),
|
|
964
|
+
timedOut: !!r.timedOut,
|
|
965
|
+
ms: Number(r.ms || 0),
|
|
966
|
+
truncated: { stdout: fullOut.length > MATH_CAPS.stdout, stderr: fullErr.length > MATH_CAPS.stderr },
|
|
967
|
+
artifacts: [{ path: 'stdout.txt', sha256: sha256(fullOut), bytes: Buffer.byteLength(fullOut, 'utf8') }],
|
|
968
|
+
captured: captured,
|
|
969
|
+
network: 'not-enforced-by-plugin',
|
|
970
|
+
warnings: warnings,
|
|
971
|
+
determinism: { inputSha256: input, packagesSorted: true, noWallClockInId: true },
|
|
972
|
+
}
|
|
973
|
+
if (assembled.cli) receipt.cli = { command: assembled.cli.command, argv: assembled.cli.argv, stdinUsed: false, scriptAppended: !!assembled.cli.scriptAppended, scriptSkipped: assembled.cli.scriptSkipped || null }
|
|
974
|
+
const receiptJson = JSON.stringify(receipt, null, 2)
|
|
975
|
+
await H.writeText(dir + '/receipt.json', receiptJson)
|
|
976
|
+
await H.writeText(dir + '/receipt.md', renderReceiptMd(receipt, fullOut, fullErr))
|
|
977
|
+
const receiptRef = { dir: dir, json: dir + '/receipt.json', md: dir + '/receipt.md', sha256: sha256(receiptJson) }
|
|
978
|
+
H.log('math_computation', det.name + ' ' + (det.name === 'cli' ? 'cli' : mode) + ' attempt=' + attempt + ' exit=' + receipt.exit + ' argv=' + assembled.argv.join(' '))
|
|
979
|
+
|
|
980
|
+
const shell = {
|
|
981
|
+
ok: true, op: 'run', engine: engineLabel, mode: mode,
|
|
982
|
+
engineInfo: receipt.engine,
|
|
983
|
+
argv: assembled.argv.slice(),
|
|
984
|
+
scriptPath: scriptRel, scriptHash: scriptHash,
|
|
985
|
+
attempt: attempt, attemptDir: dir, baseRunDir: baseDir,
|
|
986
|
+
scriptChanged: scriptChanged, scriptChangedDuringRun: scriptChangedDuringRun,
|
|
987
|
+
sourceFile: fileRel || null,
|
|
988
|
+
previousReceipt: receipt.previousReceipt,
|
|
989
|
+
cliScriptAppended: !!(receipt.cli && receipt.cli.scriptAppended),
|
|
990
|
+
packages: pk.found,
|
|
991
|
+
exit: receipt.exit, timedOut: receipt.timedOut, ms: receipt.ms,
|
|
992
|
+
stdout: fullOut.slice(0, MATH_CAPS.stdout), stderr: fullErr.slice(0, MATH_CAPS.stderr),
|
|
993
|
+
truncated: receipt.truncated, artifacts: receipt.artifacts, receipt: receiptRef,
|
|
994
|
+
warnings: warnings,
|
|
995
|
+
}
|
|
996
|
+
if (receipt.timedOut) {
|
|
997
|
+
return Object.assign(fail('MATH_TIMEOUT', engineLabel, '执行超时(' + timeoutMs + 'ms)已被终止', { argv: assembled.argv.slice(), receipt: receiptRef }),
|
|
998
|
+
archiveFields(), { ms: receipt.ms, timedOut: true })
|
|
999
|
+
}
|
|
1000
|
+
if (r.exit !== 0) {
|
|
1001
|
+
const blob = fullErr + '\n' + fullOut
|
|
1002
|
+
const hints = mathEngineArgErrorHints(det.name)
|
|
1003
|
+
const looksLikeArgError = hints.some((h) => blob.toLowerCase().indexOf(String(h).toLowerCase()) !== -1)
|
|
1004
|
+
if (looksLikeArgError) {
|
|
1005
|
+
const out = fail('MATH_ENGINE_BAD_ARGV', engineLabel, engineLabel + ' 以选项/用法错误退出(argv=' + assembled.argv.join(' ') + ')。模板可能与本机版本不符:请用 mathEngineOverride 覆盖 versionArgv/scriptArgv/evalArgv。', {
|
|
1006
|
+
argv: assembled.argv.slice(),
|
|
1007
|
+
receipt: receiptRef,
|
|
1008
|
+
next: next('engine-override', { engine: det.name, argv: assembled.argv.slice(), hint: MATH_BAD_ARGV_HINT }),
|
|
1009
|
+
})
|
|
1010
|
+
out.exit = r.exit
|
|
1011
|
+
out.stderr = fullErr.slice(0, MATH_CAPS.stderr)
|
|
1012
|
+
return Object.assign(out, archiveFields())
|
|
1013
|
+
}
|
|
1014
|
+
const out = fail('MATH_NONZERO_EXIT', engineLabel, '引擎以非零退出码结束:' + r.exit, { argv: assembled.argv.slice(), receipt: receiptRef })
|
|
1015
|
+
out.exit = r.exit
|
|
1016
|
+
out.stderr = fullErr.slice(0, MATH_CAPS.stderr)
|
|
1017
|
+
return Object.assign(out, archiveFields())
|
|
1018
|
+
}
|
|
1019
|
+
return shell
|
|
1020
|
+
|
|
1021
|
+
function archiveFields() {
|
|
1022
|
+
return {
|
|
1023
|
+
scriptPath: scriptRel, scriptHash: scriptHash,
|
|
1024
|
+
attempt: attempt, attemptDir: dir, baseRunDir: baseDir,
|
|
1025
|
+
scriptChanged: scriptChanged, scriptChangedDuringRun: scriptChangedDuringRun,
|
|
1026
|
+
sourceFile: fileRel || null, previousReceipt: receipt.previousReceipt, warnings: warnings,
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
}) // end withArchiveLock(baseDir) — see the AUDIT-C HIGH fix above
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// ── op: receipt ────────────────────────────────────────────────────────────────────────────────
|
|
1033
|
+
// P2a item 2: reconciliation. Read the receipt, re-hash the script it points at, and report
|
|
1034
|
+
// scriptChanged. P2a item 3: receipt files are NEVER rewritten - only a MISSING receipt.md is
|
|
1035
|
+
// filled in (that is the one documented exception, and it is not a rewrite of existing data).
|
|
1036
|
+
async function opReceipt(H, args) {
|
|
1037
|
+
const raw = String(args.file || '').replace(/\\/g, '/').replace(/^\.\//, '')
|
|
1038
|
+
const segs = raw.split('/').filter(Boolean)
|
|
1039
|
+
let runId = ''
|
|
1040
|
+
let attemptDir = null
|
|
1041
|
+
if (segs[0] === 'Computation') {
|
|
1042
|
+
runId = segs[1] || ''
|
|
1043
|
+
if (segs[2] === 'attempts' && segs[3]) attemptDir = 'Computation/' + runId + '/attempts/' + segs[3]
|
|
1044
|
+
} else {
|
|
1045
|
+
runId = segs[0] || ''
|
|
1046
|
+
}
|
|
1047
|
+
if (!runId) return fail('MATH_INVALID_ARGUMENT', null, 'op=receipt requires a receipt id or path')
|
|
1048
|
+
const tried = attemptDir ? [attemptDir, 'Computation/' + runId] : ['Computation/' + runId]
|
|
1049
|
+
let dir = null
|
|
1050
|
+
let json
|
|
1051
|
+
for (const d of tried) {
|
|
1052
|
+
json = await H.readText(d + '/receipt.json')
|
|
1053
|
+
if (json !== undefined) { dir = d; break }
|
|
1054
|
+
}
|
|
1055
|
+
// Round-2 lens-1 fix: a MISSING archive directory is a refusal with an explicit reason (a
|
|
1056
|
+
// fail-closed state, not a malformed call). A receipt whose JSON cannot be parsed stays
|
|
1057
|
+
// MATH_INVALID_ARGUMENT (that IS a malformed artifact).
|
|
1058
|
+
if (json === undefined) return fail('MATH_REFUSED', null, '没有找到回执归档:Computation/' + runId + '(目录或 receipt.json 不存在)', { next: next('reason', { reason: 'archive-missing' }) })
|
|
1059
|
+
let parsed
|
|
1060
|
+
try { parsed = JSON.parse(json) } catch (e) { return fail('MATH_INVALID_ARGUMENT', null, '回执无法解析:' + dir, { next: next('reason', { reason: 'receipt-unparsable' }) }) }
|
|
1061
|
+
const recordedHash = parsed.scriptHash || (parsed.script && parsed.script.sha256) || null
|
|
1062
|
+
const scriptRel = parsed.scriptPath || (parsed.script && parsed.script.path) || (dir + '/script')
|
|
1063
|
+
const currentText = await H.readText(scriptRel)
|
|
1064
|
+
const currentHash = currentText === undefined ? null : sha256(currentText)
|
|
1065
|
+
// AUDIT-A FIX: a missing archive script is not "unchanged". Reconciliation is fail-closed: if the
|
|
1066
|
+
// script cannot be read, the receipt cannot be trusted for any code, so report scriptChanged.
|
|
1067
|
+
const scriptMissing = currentText === undefined
|
|
1068
|
+
const scriptChanged = scriptMissing || (currentHash !== null && recordedHash !== null && currentHash !== recordedHash)
|
|
1069
|
+
const warnings = []
|
|
1070
|
+
if (scriptMissing) {
|
|
1071
|
+
warnings.push(warning(MATH_SCRIPT_CHANGED_WARNING, 'scriptChanged:归档脚本 ' + scriptRel + ' 已不存在(被删除或移动)——**该回执不能作为任何代码的证据**,请重跑。'))
|
|
1072
|
+
} else if (scriptChanged) {
|
|
1073
|
+
warnings.push(warning(MATH_SCRIPT_CHANGED_WARNING, 'scriptChanged:归档脚本 ' + scriptRel + ' 的当前哈希与回执记录不一致——**这份回执不再代表当前代码**,请用 mode:\'file\' 重跑并引用新回执。'))
|
|
1074
|
+
}
|
|
1075
|
+
const mdRel = dir + '/receipt.md'
|
|
1076
|
+
let rendered = false
|
|
1077
|
+
if (!(await H.exists(mdRel))) {
|
|
1078
|
+
const out = await H.readText(dir + '/stdout.txt')
|
|
1079
|
+
const err = await H.readText(dir + '/stderr.txt')
|
|
1080
|
+
await H.writeText(mdRel, renderReceiptMd(parsed, out || '', err || ''))
|
|
1081
|
+
rendered = true
|
|
1082
|
+
}
|
|
1083
|
+
return {
|
|
1084
|
+
ok: true, op: 'receipt', engine: parsed.engine ? parsed.engine.name : null,
|
|
1085
|
+
scriptPath: scriptRel, scriptHash: recordedHash, currentScriptHash: currentHash,
|
|
1086
|
+
scriptChanged: scriptChanged, attempt: parsed.attempt || 1, attemptDir: dir,
|
|
1087
|
+
receipt: { dir: dir, json: dir + '/receipt.json', md: mdRel },
|
|
1088
|
+
warnings: warnings,
|
|
1089
|
+
message: (scriptChanged ? '⚠ 归档脚本已改变:旧回执不再代表当前代码;' : '') + (rendered ? '缺失的 receipt.md 已补写(未改写既有数据)' : '回执只读返回(既有文件未改写)'),
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
// ── op: install ────────────────────────────────────────────────────────────────────────────────
|
|
1094
|
+
function substInstall(tmpl, engineName, exe, pkg) {
|
|
1095
|
+
return tmpl.map((a) => String(a).replace(/<exe>/g, exe).replace(/<pkg>/g, pkg).replace(/__PKG__/g, pkg))
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
async function opInstall(H, args, params) {
|
|
1099
|
+
const engineName = (args.engine && args.engine !== 'auto') ? args.engine : (params.mathEngines[0] || 'python')
|
|
1100
|
+
const d = MATH_ENGINES[engineName]
|
|
1101
|
+
if (!d) return fail('MATH_INVALID_ARGUMENT', engineName, 'unknown engine: ' + engineName)
|
|
1102
|
+
if (d.license === 'commercial' || !d.install) {
|
|
1103
|
+
return fail('MATH_REFUSED', engineName, engineName + ' 永不代装(商业引擎只给厂商指引)', {
|
|
1104
|
+
next: next('vendor', { engine: engineName, url: d.vendor || '' }),
|
|
1105
|
+
})
|
|
1106
|
+
}
|
|
1107
|
+
const scope = args.scope || params.mathInstallScope || 'user' // per-call only; never persisted anywhere
|
|
1108
|
+
// round-6 (B): resolve the interpreter the SAME way the run path does (the descriptor's candidate
|
|
1109
|
+
// list, in order) so the plan targets the interpreter that will actually run - and so the manager
|
|
1110
|
+
// detection sees that interpreter's REAL path (a conda `python3` is not a bare `python`).
|
|
1111
|
+
let exe = engineName
|
|
1112
|
+
{
|
|
1113
|
+
const cands = mathEngineCandidates(engineName) || [engineName === 'r' ? 'Rscript' : engineName]
|
|
1114
|
+
for (const c of cands) {
|
|
1115
|
+
try { const hit = await H.resolveExecutable(c); if (hit) { exe = hit; break } } catch (e) { /* try the next candidate */ }
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
// round-6 (B): python dispatches on the DETECTED environment; every other engine keeps its own
|
|
1119
|
+
// single template. The chosen manager (and whether it is only an assumption) is part of the plan.
|
|
1120
|
+
let managerInfo = null
|
|
1121
|
+
let mgrTmpl = null
|
|
1122
|
+
if (d.install.managers) {
|
|
1123
|
+
managerInfo = await detectPythonManager(H, exe)
|
|
1124
|
+
mgrTmpl = d.install.managers[managerInfo.manager] || null
|
|
1125
|
+
}
|
|
1126
|
+
const activeManager = managerInfo ? managerInfo.manager : d.install.manager
|
|
1127
|
+
const userTmpl = (mgrTmpl && mgrTmpl.userArgv) || d.install.userArgv
|
|
1128
|
+
const systemTmpl = (mgrTmpl && mgrTmpl.systemArgv) || (mgrTmpl ? null : d.install.systemArgv)
|
|
1129
|
+
const tmpl = scope === 'system' ? systemTmpl : userTmpl
|
|
1130
|
+
if (!tmpl) {
|
|
1131
|
+
// round-6 (C): the refusal says WHY per engine (or per detected manager) instead of a generic line.
|
|
1132
|
+
const why = (mgrTmpl && mgrTmpl.systemUnsupportedReason) || d.install.systemUnsupportedReason || '该引擎只提供用户级安装'
|
|
1133
|
+
return fail('MATH_REFUSED', engineName, engineName + ' 不支持 system 作用域:' + why, { next: next('reason', { reason: 'system-scope-unsupported' }) })
|
|
1134
|
+
}
|
|
1135
|
+
const uninstallTmpl = (mgrTmpl && mgrTmpl.uninstallArgv) || d.install.uninstallArgv
|
|
1136
|
+
const commands = args.packages.map((p) => ({ argv: substInstall(tmpl, engineName, exe, p), why: '缺少 ' + p }))
|
|
1137
|
+
const plan = {
|
|
1138
|
+
engine: engineName, scope: scope, manager: activeManager, commands: commands,
|
|
1139
|
+
managerAssumed: !!(managerInfo && managerInfo.assumed), managerWhy: managerInfo ? managerInfo.why : '',
|
|
1140
|
+
versionPolicy: '版本求解交给包管理器(本工具只检查是否已安装,并把 pkg<op>version 原样透传)',
|
|
1141
|
+
note: scope === 'user' ? '将安装到用户级目录' : '仅本次系统作用域(不会被记住)',
|
|
1142
|
+
}
|
|
1143
|
+
const planToken = sha256(JSON.stringify({ engine: engineName, packages: args.packages, scope: scope, commands: commands }) + '|planVersion=1').slice(0, 32)
|
|
1144
|
+
if (!args.confirm) {
|
|
1145
|
+
return { ok: true, op: 'install', engine: engineName, plan: plan, planToken: planToken, next: next('agent-install', { dryRun: true, manager: activeManager, managerAssumed: !!plan.managerAssumed }), message: '安装计划(尚未执行,管理器=' + activeManager + (plan.managerAssumed ? '(假设:' + plan.managerWhy + ')' : '') + '):' + commands.map((c) => c.argv.join(' ')).join(' ; ') }
|
|
1146
|
+
}
|
|
1147
|
+
if (args.confirm !== planToken) {
|
|
1148
|
+
return fail('MATH_REFUSED', engineName, 'planToken 不匹配(计划可能已变),请重新出计划', { next: next('reason', { reason: 'plan-token-mismatch' }) })
|
|
1149
|
+
}
|
|
1150
|
+
const timeoutMs = args.timeoutMs || params.mathTimeoutMs
|
|
1151
|
+
const results = []
|
|
1152
|
+
for (const c of commands) {
|
|
1153
|
+
const r = await H.spawn({ argv: c.argv, cwd: await H.projectRoot(), timeoutMs: timeoutMs, stdoutCap: MATH_CAPS.file, stderrCap: MATH_CAPS.file })
|
|
1154
|
+
results.push({ argv: c.argv, exit: r ? r.exit : null, timedOut: !!(r && r.timedOut), ms: r ? r.ms : 0, stdoutTail: String((r && r.stdout) || '').slice(-2000), stderrTail: String((r && r.stderr) || '').slice(-2000) })
|
|
1155
|
+
if (!r || r.timedOut || r.exit !== 0) {
|
|
1156
|
+
const audit0 = { schema: 'vibe-math/math-computation-install@1', planToken: planToken, scope: scope, manager: activeManager, commands: commands, results: results, exit: r ? r.exit : null, timedOut: !!(r && r.timedOut), installed: args.packages, rollback: rollbackFor(d, exe, args.packages, uninstallTmpl), network: 'not-enforced-by-plugin' }
|
|
1157
|
+
await H.writeText('Computation/installs/' + planToken + '.json', JSON.stringify(audit0, null, 2))
|
|
1158
|
+
const code = (r && r.timedOut) ? 'MATH_TIMEOUT' : 'MATH_NONZERO_EXIT'
|
|
1159
|
+
const out = fail(code, engineName, '安装失败(' + c.argv.join(' ') + '):' + (r ? ('exit=' + r.exit) : 'no subprocess'), { next: next('note', { audit: 'Computation/installs/' + planToken + '.json' }) })
|
|
1160
|
+
out.audit = 'Computation/installs/' + planToken + '.json'
|
|
1161
|
+
return out
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
const audit = { schema: 'vibe-math/math-computation-install@1', planToken: planToken, scope: scope, manager: activeManager, commands: commands, results: results, exit: 0, timedOut: false, installed: args.packages, before: {}, after: {}, rollback: rollbackFor(d, exe, args.packages, uninstallTmpl), network: 'not-enforced-by-plugin' }
|
|
1165
|
+
await H.writeText('Computation/installs/' + planToken + '.json', JSON.stringify(audit, null, 2))
|
|
1166
|
+
return { ok: true, op: 'install', engine: engineName, executed: true, scope: scope, audit: 'Computation/installs/' + planToken + '.json', message: '已安装:' + args.packages.join(', ') + '(审计:Computation/installs/' + planToken + '.json)' }
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
// ── round-6 (B/D): package specs + Python package-manager dispatch ──────────────────────────────
|
|
1170
|
+
// Version SOLVING is delegated to the package manager: the tool only checks presence (by base name)
|
|
1171
|
+
// and passes the spec through. The accepted grammar is small and manager-understood; anything else
|
|
1172
|
+
// (spaces, `;`, `|`, `&`, `$`, backticks, `@`, parentheses, empty) is refused with an explicit
|
|
1173
|
+
// reason instead of being handed to a shell.
|
|
1174
|
+
const PKG_SPEC_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*(\[[A-Za-z0-9._,-]+\])?(?:(?:==|>=|<=|~=|!=|>|<|=)[A-Za-z0-9._*+!-]+)?$/
|
|
1175
|
+
export function parsePackageSpec(spec) {
|
|
1176
|
+
const raw = String(spec == null ? '' : spec).trim()
|
|
1177
|
+
if (!raw) return { ok: false, problem: 'empty package spec' }
|
|
1178
|
+
if (!PKG_SPEC_RE.test(raw)) {
|
|
1179
|
+
return { ok: false, problem: 'unsupported version syntax: "' + raw + '"(版本求解交给包管理器:请用它能理解的形式,例如 pip 的 pkg==1.2 或 conda 的 pkg=1.2;本工具只检查是否已安装)' }
|
|
1180
|
+
}
|
|
1181
|
+
const name = raw.replace(/(?:==|>=|<=|~=|!=|>|<|=).*$/, '').replace(/\[.*$/, '')
|
|
1182
|
+
return { ok: true, name: name, spec: raw, pinned: raw !== name }
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
// Detect WHICH python package manager the interpreter's own environment implies - detect, don't
|
|
1186
|
+
// guess. conda/mamba markers live in the interpreter path (…/envs/<name>/… or …/miniconda3/…) and
|
|
1187
|
+
// must be confirmed by resolving the manager; uv counts only when it sits NEXT TO the interpreter
|
|
1188
|
+
// (a uv-managed venv). Anything ambiguous falls back to pip, and `assumed: true` travels into the
|
|
1189
|
+
// plan/message so the operator is told which manager the plan assumes.
|
|
1190
|
+
async function detectPythonManager(H, exe) {
|
|
1191
|
+
const p = String(exe || '').replace(/\\/g, '/').toLowerCase()
|
|
1192
|
+
if (/\/envs\/|\/conda|\/miniconda|\/anaconda|\/mambaforge|\/miniforge/.test(p)) {
|
|
1193
|
+
for (const mgr of ['conda', 'mamba']) {
|
|
1194
|
+
try {
|
|
1195
|
+
const found = await H.resolveExecutable(mgr)
|
|
1196
|
+
if (found) return { manager: mgr, assumed: false, why: '解释器路径看起来像 ' + mgr + ' 环境(' + p.split('/').slice(-3).join('/') + ')' }
|
|
1197
|
+
} catch (e) { /* try the next candidate */ }
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
try {
|
|
1201
|
+
const uv = await H.resolveExecutable('uv')
|
|
1202
|
+
if (uv) {
|
|
1203
|
+
const dirOf = (x) => String(x).replace(/\\/g, '/').replace(/\/[^/]*$/, '')
|
|
1204
|
+
if (dirOf(uv) === dirOf(exe)) return { manager: 'uv', assumed: false, why: 'uv 与解释器同目录(uv 管理的虚拟环境)' }
|
|
1205
|
+
}
|
|
1206
|
+
} catch (e) { /* fall through to pip */ }
|
|
1207
|
+
return { manager: 'pip', assumed: true, why: '未检测到 conda/mamba 标记,也没有与解释器同目录的 uv ⇒ 按文档回退到 pip(计划中的管理器是**假设**)' }
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
function rollbackFor(d, exe, packages, uninstallTmpl) {
|
|
1211
|
+
const tmpl = uninstallTmpl || (d.install && d.install.uninstallArgv)
|
|
1212
|
+
if (!tmpl) return { supported: 'partial', commands: [], note: '该管理器未提供卸载模板;本工具不做自动回滚' }
|
|
1213
|
+
return { supported: 'partial', commands: packages.map((p) => substInstall(tmpl, d.name, exe, p)), note: '包管理器各自支持卸载;本工具不做自动回滚' }
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
// ── dispatch + registration ────────────────────────────────────────────────────────────────────
|
|
1217
|
+
async function dispatch(H, rawArgs) {
|
|
1218
|
+
const params = effectiveMathParams(typeof H.params === 'function' ? H.params() : H.params)
|
|
1219
|
+
if (params.mathComputation === 'off') {
|
|
1220
|
+
return fail('MATH_NOT_AVAILABLE', null, '该预设已关闭数学计算(mathComputation=off)', { next: next('enable', { param: 'mathComputation', value: 'auto' }) })
|
|
1221
|
+
}
|
|
1222
|
+
// Optional host capability flag: a host that KNOWS it has no subprocess service can say so, and
|
|
1223
|
+
// then "no engine resolvable" is reported as MATH_NO_SUBPROCESS instead of a misleading
|
|
1224
|
+
// ENGINE_NOT_FOUND + "install python" guide. Hosts that do not provide the flag keep the
|
|
1225
|
+
// resolve-throw => ENGINE_NOT_FOUND / spawn-null => MATH_NO_SUBPROCESS mapping.
|
|
1226
|
+
if (typeof H.hasSubprocess === 'function' && H.hasSubprocess() === false) {
|
|
1227
|
+
const out = fail('MATH_NO_SUBPROCESS', null, '宿主不提供 subprocess 服务,无法执行计算', { next: next('note', { reason: 'no-subprocess' }) })
|
|
1228
|
+
out.op = isObj(rawArgs) ? (rawArgs.op || null) : null
|
|
1229
|
+
return out
|
|
1230
|
+
}
|
|
1231
|
+
const v = validateMathArgs(rawArgs, params)
|
|
1232
|
+
if (!v.ok) {
|
|
1233
|
+
const out = fail(v.code, null, v.reason)
|
|
1234
|
+
if (v.next) out.next = v.next
|
|
1235
|
+
out.op = isObj(rawArgs) ? (rawArgs.op || null) : null
|
|
1236
|
+
return out
|
|
1237
|
+
}
|
|
1238
|
+
const args = v.args
|
|
1239
|
+
if (args.op === 'probe') { const out = await opProbe(H, args, params); out.op = 'probe'; return out }
|
|
1240
|
+
if (args.op === 'run') { const out = await opRun(H, args, params); if (out && out.op === null) out.op = 'run'; return out }
|
|
1241
|
+
if (args.op === 'receipt') { const out = await opReceipt(H, args); out.op = 'receipt'; return out }
|
|
1242
|
+
const out = await opInstall(H, args, params)
|
|
1243
|
+
if (out && out.op === null) out.op = 'install'
|
|
1244
|
+
return out
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
export function registerMathComputation(host) {
|
|
1248
|
+
const H = adaptHost(host)
|
|
1249
|
+
H.__mathHost = true
|
|
1250
|
+
const handler = async function (args) {
|
|
1251
|
+
try {
|
|
1252
|
+
return await dispatch(H, args)
|
|
1253
|
+
} catch (e) {
|
|
1254
|
+
return fail('MATH_INVALID_ARGUMENT', null, 'math_computation 内部错误:' + String((e && e.message) || e))
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
H.register(MATH_TOOL_NAME, MATH_TOOL_DESCRIPTION, MATH_TOOL_SCHEMA, handler)
|
|
1258
|
+
const probe = async function (opts) {
|
|
1259
|
+
const h = await probeMathEngines(H, opts)
|
|
1260
|
+
return h
|
|
1261
|
+
}
|
|
1262
|
+
return { toolName: MATH_TOOL_NAME, description: MATH_TOOL_DESCRIPTION, parameters: MATH_TOOL_SCHEMA, handler: handler, probe: probe, host: H }
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
export function mathAvailabilityLine(probe, lang, mathMode) {
|
|
1266
|
+
const mode = mathMode || MATH_PARAM_DEFAULTS.mathMode
|
|
1267
|
+
const list = (probe && probe.engines ? probe.engines : []).map((e) => e.name + ' ' + (e.version || '?')).join('、')
|
|
1268
|
+
if (lang === 'en') {
|
|
1269
|
+
const head = '- math_computation: available ' + (list || '(none)') + '. Probe first, then run'
|
|
1270
|
+
+ ' (the tool path leaves a re-runnable receipt - cite it). If a package is missing, say so and offer a fallback or an install plan;'
|
|
1271
|
+
+ ' installing requires a plan plus confirmation.'
|
|
1272
|
+
const shell = mode === 'typed+shell' ? '\n' + MATH_SHELL_RULE_LINE_EN : ''
|
|
1273
|
+
return head + shell + '\n' + MATH_ARCHIVE_WORKFLOW_LINE_EN + '\n' + MATH_RULE_LINES_EN.join('\n')
|
|
1274
|
+
}
|
|
1275
|
+
const head = '- math_computation:本机可用 ' + (list || '(无)') + '。需要数值/符号/统计计算时先 probe 再 run'
|
|
1276
|
+
+ '(工具路径会留下可复核回执,结论请引用回执路径);缺包时如实说明并给替代方案或安装计划(不要假装),安装需先出计划并征得确认。'
|
|
1277
|
+
const shell = mode === 'typed+shell' ? '\n' + MATH_SHELL_RULE_LINE : ''
|
|
1278
|
+
return head + shell + '\n' + MATH_ARCHIVE_WORKFLOW_LINE + '\n' + MATH_RULE_LINES.join('\n')
|
|
1279
|
+
}
|