dsh-math-modeling-agent 0.1.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.
- package/LICENSE +21 -0
- package/README.md +127 -0
- package/cordis.patch.yml +8 -0
- package/package.json +34 -0
- package/skills/math-modeling-agent/SKILL.md +50 -0
- package/skills/math-modeling-agent/examples/minimal-run/README.md +14 -0
- package/skills/math-modeling-agent/examples/resumed-run/README.md +13 -0
- package/skills/math-modeling-agent/references/claims-evidence.md +29 -0
- package/skills/math-modeling-agent/references/data-subproblems.md +17 -0
- package/skills/math-modeling-agent/references/math-grill.md +33 -0
- package/skills/math-modeling-agent/references/modeling-methodology.md +38 -0
- package/skills/math-modeling-agent/references/problem-types.md +20 -0
- package/skills/math-modeling-agent/references/report-contract.md +29 -0
- package/skills/math-modeling-agent/references/research-breakthrough.md +27 -0
- package/skills/math-modeling-agent/references/state-recovery.md +18 -0
- package/skills/math-modeling-agent/references/tool-policy.md +21 -0
- package/skills/math-modeling-agent/references/workflow.md +39 -0
- package/skills/math-modeling-agent/schemas/attempt.schema.json +70 -0
- package/skills/math-modeling-agent/schemas/ledger.schema.json +47 -0
- package/skills/math-modeling-agent/schemas/run.schema.json +102 -0
- package/skills/math-modeling-agent/scripts/capability-probe.mjs +105 -0
- package/skills/math-modeling-agent/scripts/python-environment.mjs +408 -0
- package/skills/math-modeling-agent/scripts/run-state.mjs +547 -0
- package/skills/math-modeling-audit/SKILL.md +41 -0
- package/skills/math-modeling-audit/examples/audit-report.md +13 -0
- package/skills/math-modeling-audit/examples/mcm-final-review.md +86 -0
- package/skills/math-modeling-audit/references/data-citation-audit.md +15 -0
- package/skills/math-modeling-audit/references/evidence-levels.md +10 -0
- package/skills/math-modeling-audit/references/mcm-icm-final-judge.md +331 -0
- package/skills/math-modeling-audit/references/verification-protocol.md +27 -0
- package/skills/math-modeling-audit/scripts/mcm-score.mjs +218 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/yohanchen1/MathModelingAgent/schemas/ledger.schema.json",
|
|
4
|
+
"title": "Math Modeling Ledger",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": [
|
|
8
|
+
"schemaVersion",
|
|
9
|
+
"taskId",
|
|
10
|
+
"scope",
|
|
11
|
+
"assumptions",
|
|
12
|
+
"claims",
|
|
13
|
+
"obligations",
|
|
14
|
+
"subproblems",
|
|
15
|
+
"candidates",
|
|
16
|
+
"issues"
|
|
17
|
+
],
|
|
18
|
+
"properties": {
|
|
19
|
+
"schemaVersion": {
|
|
20
|
+
"const": 1
|
|
21
|
+
},
|
|
22
|
+
"taskId": {
|
|
23
|
+
"type": "string"
|
|
24
|
+
},
|
|
25
|
+
"scope": {
|
|
26
|
+
"type": "object"
|
|
27
|
+
},
|
|
28
|
+
"assumptions": {
|
|
29
|
+
"type": "array"
|
|
30
|
+
},
|
|
31
|
+
"claims": {
|
|
32
|
+
"type": "array"
|
|
33
|
+
},
|
|
34
|
+
"obligations": {
|
|
35
|
+
"type": "array"
|
|
36
|
+
},
|
|
37
|
+
"subproblems": {
|
|
38
|
+
"type": "array"
|
|
39
|
+
},
|
|
40
|
+
"candidates": {
|
|
41
|
+
"type": "array"
|
|
42
|
+
},
|
|
43
|
+
"issues": {
|
|
44
|
+
"type": "array"
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/yohanchen1/MathModelingAgent/schemas/run.schema.json",
|
|
4
|
+
"title": "Math Modeling Run",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"additionalProperties": false,
|
|
7
|
+
"required": [
|
|
8
|
+
"schemaVersion",
|
|
9
|
+
"taskId",
|
|
10
|
+
"mode",
|
|
11
|
+
"status",
|
|
12
|
+
"eventSequence",
|
|
13
|
+
"currentAttempt",
|
|
14
|
+
"bestCandidateId",
|
|
15
|
+
"budget",
|
|
16
|
+
"createdAt",
|
|
17
|
+
"updatedAt"
|
|
18
|
+
],
|
|
19
|
+
"properties": {
|
|
20
|
+
"schemaVersion": {
|
|
21
|
+
"const": 1
|
|
22
|
+
},
|
|
23
|
+
"taskId": {
|
|
24
|
+
"type": "string",
|
|
25
|
+
"pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$"
|
|
26
|
+
},
|
|
27
|
+
"mode": {
|
|
28
|
+
"enum": [
|
|
29
|
+
"fast",
|
|
30
|
+
"standard",
|
|
31
|
+
"high-assurance"
|
|
32
|
+
]
|
|
33
|
+
},
|
|
34
|
+
"status": {
|
|
35
|
+
"enum": [
|
|
36
|
+
"TRIAGE",
|
|
37
|
+
"SCOPE_FROZEN",
|
|
38
|
+
"INPUT_PROFILED",
|
|
39
|
+
"CLAIMS_REGISTERED",
|
|
40
|
+
"CANDIDATES_READY",
|
|
41
|
+
"ATTEMPT",
|
|
42
|
+
"EXECUTE",
|
|
43
|
+
"VERIFY",
|
|
44
|
+
"REVISE",
|
|
45
|
+
"RESEARCH",
|
|
46
|
+
"FORK",
|
|
47
|
+
"SOLVED",
|
|
48
|
+
"PARTIAL",
|
|
49
|
+
"CONDITIONAL",
|
|
50
|
+
"INCONCLUSIVE",
|
|
51
|
+
"REFUTED",
|
|
52
|
+
"INFEASIBLE",
|
|
53
|
+
"UNIDENTIFIABLE",
|
|
54
|
+
"BLOCKED",
|
|
55
|
+
"CANCELLED"
|
|
56
|
+
]
|
|
57
|
+
},
|
|
58
|
+
"eventSequence": {
|
|
59
|
+
"type": "integer",
|
|
60
|
+
"minimum": 0
|
|
61
|
+
},
|
|
62
|
+
"currentAttempt": {
|
|
63
|
+
"type": "integer",
|
|
64
|
+
"minimum": 0
|
|
65
|
+
},
|
|
66
|
+
"bestCandidateId": {
|
|
67
|
+
"type": [
|
|
68
|
+
"string",
|
|
69
|
+
"null"
|
|
70
|
+
]
|
|
71
|
+
},
|
|
72
|
+
"budget": {
|
|
73
|
+
"type": "object",
|
|
74
|
+
"additionalProperties": false,
|
|
75
|
+
"required": [
|
|
76
|
+
"attempts",
|
|
77
|
+
"researchQueries",
|
|
78
|
+
"computeSeconds"
|
|
79
|
+
],
|
|
80
|
+
"properties": {
|
|
81
|
+
"attempts": {
|
|
82
|
+
"type": "integer",
|
|
83
|
+
"minimum": 1
|
|
84
|
+
},
|
|
85
|
+
"researchQueries": {
|
|
86
|
+
"type": "integer",
|
|
87
|
+
"minimum": 0
|
|
88
|
+
},
|
|
89
|
+
"computeSeconds": {
|
|
90
|
+
"type": "integer",
|
|
91
|
+
"minimum": 0
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
"createdAt": {
|
|
96
|
+
"type": "string"
|
|
97
|
+
},
|
|
98
|
+
"updatedAt": {
|
|
99
|
+
"type": "string"
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { accessSync, constants, existsSync, statSync } from 'node:fs'
|
|
2
|
+
import { delimiter, extname, join } from 'node:path'
|
|
3
|
+
import { pathToFileURL } from 'node:url'
|
|
4
|
+
|
|
5
|
+
const REASON = 'Found on PATH; execute through the DSH shell to verify version and usability.'
|
|
6
|
+
|
|
7
|
+
const TOOL_CANDIDATES = {
|
|
8
|
+
uv: ['uv'],
|
|
9
|
+
python: ['python3', 'python', 'py'],
|
|
10
|
+
lean: ['lean'],
|
|
11
|
+
lake: ['lake'],
|
|
12
|
+
wolfram: ['wolframscript', 'WolframScript'],
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function executableExtensions(env, platform) {
|
|
16
|
+
if (platform !== 'win32') return ['']
|
|
17
|
+
const raw = env.PATHEXT || '.COM;.EXE;.BAT;.CMD'
|
|
18
|
+
return raw
|
|
19
|
+
.split(';')
|
|
20
|
+
.map((value) => value.trim())
|
|
21
|
+
.filter(Boolean)
|
|
22
|
+
.map((value) => value.toLowerCase())
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function candidateNames(name, extensions, platform) {
|
|
26
|
+
if (platform !== 'win32' || extname(name)) return [name]
|
|
27
|
+
return extensions.map((extension) => `${name}${extension}`)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isExecutable(path, platform) {
|
|
31
|
+
if (!existsSync(path)) return false
|
|
32
|
+
try {
|
|
33
|
+
if (!statSync(path).isFile()) return false
|
|
34
|
+
accessSync(path, platform === 'win32' ? constants.F_OK : constants.X_OK)
|
|
35
|
+
return true
|
|
36
|
+
} catch {
|
|
37
|
+
return false
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function findExecutable(names, options = {}) {
|
|
42
|
+
const env = options.env ?? process.env
|
|
43
|
+
const platform = options.platform ?? process.platform
|
|
44
|
+
const directories = (env.PATH ?? '').split(delimiter).filter(Boolean)
|
|
45
|
+
const extensions = executableExtensions(env, platform)
|
|
46
|
+
|
|
47
|
+
for (const directory of directories) {
|
|
48
|
+
for (const name of names) {
|
|
49
|
+
for (const candidate of candidateNames(name, extensions, platform)) {
|
|
50
|
+
const path = join(directory, candidate)
|
|
51
|
+
if (isExecutable(path, platform)) return path
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return undefined
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function toolRecord(key, names, options) {
|
|
59
|
+
const executable = findExecutable(names, options)
|
|
60
|
+
if (!executable) {
|
|
61
|
+
return {
|
|
62
|
+
status: 'unavailable',
|
|
63
|
+
executable: null,
|
|
64
|
+
kind: key,
|
|
65
|
+
reason: 'No candidate executable was found on PATH.',
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
status: 'candidate',
|
|
70
|
+
executable,
|
|
71
|
+
kind: key,
|
|
72
|
+
reason: REASON,
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function probeCapabilities(options = {}) {
|
|
77
|
+
const env = options.env ?? process.env
|
|
78
|
+
const platform = options.platform ?? process.platform
|
|
79
|
+
return {
|
|
80
|
+
schemaVersion: 1,
|
|
81
|
+
platform,
|
|
82
|
+
node: {
|
|
83
|
+
status: 'available',
|
|
84
|
+
executable: process.execPath,
|
|
85
|
+
version: process.version,
|
|
86
|
+
},
|
|
87
|
+
tools: Object.fromEntries(
|
|
88
|
+
Object.entries(TOOL_CANDIDATES).map(([key, names]) => [
|
|
89
|
+
key,
|
|
90
|
+
toolRecord(key, names, { env, platform }),
|
|
91
|
+
]),
|
|
92
|
+
),
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function main() {
|
|
97
|
+
const pretty = process.argv.includes('--pretty')
|
|
98
|
+
process.stdout.write(
|
|
99
|
+
`${JSON.stringify(probeCapabilities(), null, pretty ? 2 : 0)}
|
|
100
|
+
`,
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const invokedPath = process.argv[1]
|
|
105
|
+
if (invokedPath && import.meta.url === pathToFileURL(invokedPath).href) main()
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
import { closeSync, openSync } from 'node:fs'
|
|
2
|
+
import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
|
|
3
|
+
import { randomUUID } from 'node:crypto'
|
|
4
|
+
import { dirname, isAbsolute, posix, resolve, win32 } from 'node:path'
|
|
5
|
+
import { spawnSync } from 'node:child_process'
|
|
6
|
+
import { pathToFileURL } from 'node:url'
|
|
7
|
+
|
|
8
|
+
const PACKAGE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*(?:==[A-Za-z0-9][A-Za-z0-9._+-]*)?$/
|
|
9
|
+
const REPAIR_GUIDANCE = 'Remove the inconsistent Python environment artifacts and retry.'
|
|
10
|
+
|
|
11
|
+
export function validatePackageSpec(value) {
|
|
12
|
+
if (!PACKAGE_PATTERN.test(value)) throw new TypeError(`unsafe package spec: ${value}`)
|
|
13
|
+
return value
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function environmentPython(environmentDir, platform, resolvePath) {
|
|
17
|
+
return platform === 'win32'
|
|
18
|
+
? resolvePath(environmentDir, 'Scripts', 'python.exe')
|
|
19
|
+
: resolvePath(environmentDir, 'bin', 'python')
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function temporaryPath(path) {
|
|
23
|
+
return `${path}.${process.pid}.${randomUUID()}.tmp`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function atomicWriteJson(path, value, { renamePath = rename, removePath = rm } = {}) {
|
|
27
|
+
await mkdir(dirname(path), { recursive: true })
|
|
28
|
+
const temporary = temporaryPath(path)
|
|
29
|
+
try {
|
|
30
|
+
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`)
|
|
31
|
+
await renamePath(temporary, path)
|
|
32
|
+
} catch (error) {
|
|
33
|
+
await removePath(temporary, { force: true }).catch(() => {})
|
|
34
|
+
throw error
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function defaultSpawnOptions({ cwd, stdoutDescriptor } = {}) {
|
|
39
|
+
return {
|
|
40
|
+
cwd,
|
|
41
|
+
shell: false,
|
|
42
|
+
stdio: ['ignore', stdoutDescriptor ?? 2, 2],
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function defaultRun(command, args, options = {}) {
|
|
47
|
+
let descriptor
|
|
48
|
+
try {
|
|
49
|
+
descriptor = options.stdoutFile ? openSync(options.stdoutFile, 'w') : undefined
|
|
50
|
+
const result = spawnSync(command, args, defaultSpawnOptions({
|
|
51
|
+
cwd: options.cwd,
|
|
52
|
+
stdoutDescriptor: descriptor,
|
|
53
|
+
}))
|
|
54
|
+
return result.status ?? 1
|
|
55
|
+
} finally {
|
|
56
|
+
if (descriptor !== undefined) closeSync(descriptor)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function assertSuccess(status, label) {
|
|
61
|
+
if (status !== 0) throw new Error(`${label} failed with exit code ${status}`)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function pathApi(platform) {
|
|
65
|
+
return platform === 'win32' ? win32 : posix
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Candidates come from the trusted PATH probe; PATH is intentionally user-controlled, and this CLI runs with that user's privileges.
|
|
69
|
+
async function validateExecutable(record, kind, platform, fileStat) {
|
|
70
|
+
if (!record || record.status !== 'candidate' || !record.executable) return null
|
|
71
|
+
const api = pathApi(platform)
|
|
72
|
+
const executable = record.executable
|
|
73
|
+
const absolute = platform === process.platform ? isAbsolute(executable) : api.isAbsolute(executable)
|
|
74
|
+
if (!absolute) throw new Error(`${kind} candidate executable must be an absolute path`)
|
|
75
|
+
const basename = api.basename(executable).toLowerCase()
|
|
76
|
+
const allowed = kind === 'uv'
|
|
77
|
+
? /^(uv)(?:\.exe)?$/
|
|
78
|
+
: /^(python|python3|py)(?:\.exe)?$/
|
|
79
|
+
if (!allowed.test(basename)) throw new Error(`${kind} candidate executable has an untrusted basename`)
|
|
80
|
+
let info
|
|
81
|
+
try {
|
|
82
|
+
info = await fileStat(executable)
|
|
83
|
+
} catch {
|
|
84
|
+
throw new Error(`${kind} candidate executable is not a regular file`)
|
|
85
|
+
}
|
|
86
|
+
if (!info.isFile()) throw new Error(`${kind} candidate executable is not a regular file`)
|
|
87
|
+
return { ...record, executable }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function pythonVenvArgs(record, environmentDir, platform) {
|
|
91
|
+
const name = pathApi(platform).basename(record.executable).toLowerCase()
|
|
92
|
+
if (name === 'py' || name === 'py.exe') return ['-3', '-m', 'venv', environmentDir]
|
|
93
|
+
return ['-m', 'venv', environmentDir]
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function exists(path, fileStat) {
|
|
97
|
+
try {
|
|
98
|
+
await fileStat(path)
|
|
99
|
+
return true
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (error?.code === 'ENOENT') return false
|
|
102
|
+
throw error
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function samePackages(left, right) {
|
|
107
|
+
return Array.isArray(left)
|
|
108
|
+
&& left.length === right.length
|
|
109
|
+
&& left.every((value, index) => value === right[index])
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function readManifest(manifestPath) {
|
|
113
|
+
try {
|
|
114
|
+
return JSON.parse(await readFile(manifestPath, 'utf8'))
|
|
115
|
+
} catch {
|
|
116
|
+
throw new Error(`Python environment manifest is invalid. ${REPAIR_GUIDANCE}`)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function isBlockedManifest(manifest, environmentDir) {
|
|
121
|
+
return manifest?.status === 'blocked'
|
|
122
|
+
&& manifest.adapter === null
|
|
123
|
+
&& manifest.environmentDir === environmentDir
|
|
124
|
+
&& manifest.python === null
|
|
125
|
+
&& manifest.lockPath === null
|
|
126
|
+
&& Array.isArray(manifest.requestedPackages)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function isReadyManifest(manifest, environmentDir, lockPath) {
|
|
130
|
+
return manifest?.status === 'ready'
|
|
131
|
+
&& manifest.environmentDir === environmentDir
|
|
132
|
+
&& manifest.lockPath === lockPath
|
|
133
|
+
&& Array.isArray(manifest.requestedPackages)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function inspectExisting({ environmentDir, manifestPath, lockPath, packages, fileStat }) {
|
|
137
|
+
const [hasEnvironment, hasManifest, hasLock] = await Promise.all([
|
|
138
|
+
exists(environmentDir, fileStat), exists(manifestPath, fileStat), exists(lockPath, fileStat),
|
|
139
|
+
])
|
|
140
|
+
if (!hasEnvironment && !hasManifest && !hasLock) return null
|
|
141
|
+
if (hasManifest) {
|
|
142
|
+
const manifest = await readManifest(manifestPath)
|
|
143
|
+
if (isBlockedManifest(manifest, environmentDir)) {
|
|
144
|
+
if (hasEnvironment || hasLock) {
|
|
145
|
+
throw new Error(`Python environment is orphaned or inconsistent. ${REPAIR_GUIDANCE}`)
|
|
146
|
+
}
|
|
147
|
+
if (!samePackages(manifest.requestedPackages, packages)) {
|
|
148
|
+
throw new Error('Existing blocked Python environment uses a different package configuration; refusing to mutate it.')
|
|
149
|
+
}
|
|
150
|
+
return manifest
|
|
151
|
+
}
|
|
152
|
+
if (isReadyManifest(manifest, environmentDir, lockPath) && hasEnvironment && hasLock) {
|
|
153
|
+
if (!samePackages(manifest.requestedPackages, packages)) {
|
|
154
|
+
throw new Error('Existing ready Python environment uses a different package configuration; refusing to mutate it.')
|
|
155
|
+
}
|
|
156
|
+
return manifest
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
throw new Error(`Python environment is orphaned or inconsistent. ${REPAIR_GUIDANCE}`)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function defaultProcessAlive(pid) {
|
|
163
|
+
try {
|
|
164
|
+
process.kill(pid, 0)
|
|
165
|
+
return true
|
|
166
|
+
} catch (error) {
|
|
167
|
+
return error?.code === 'EPERM'
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function acquireLock({ lockDir, manifestPath, environmentDir, lockPath, packages, fileStat, now, processAlive, retryDelay, retryDelayMs, attempts, staleMs, renamePath, removePath }) {
|
|
172
|
+
const owner = { pid: process.pid, time: now(), id: randomUUID() }
|
|
173
|
+
let reclaimedDeadOwner = false
|
|
174
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
175
|
+
let created = false
|
|
176
|
+
try {
|
|
177
|
+
await mkdir(lockDir)
|
|
178
|
+
created = true
|
|
179
|
+
await atomicWriteJson(resolve(lockDir, 'owner.json'), owner, { renamePath, removePath })
|
|
180
|
+
return { owner, reclaimedDeadOwner }
|
|
181
|
+
} catch (error) {
|
|
182
|
+
if (!created && error?.code !== 'EEXIST') throw error
|
|
183
|
+
if (created && error?.code !== 'ENOENT' && error?.code !== 'EEXIST') throw error
|
|
184
|
+
}
|
|
185
|
+
let incumbent
|
|
186
|
+
try {
|
|
187
|
+
incumbent = JSON.parse(await readFile(resolve(lockDir, 'owner.json'), 'utf8'))
|
|
188
|
+
} catch {
|
|
189
|
+
incumbent = null
|
|
190
|
+
}
|
|
191
|
+
let lockAge = 0
|
|
192
|
+
try {
|
|
193
|
+
lockAge = now() - (await fileStat(lockDir)).mtimeMs
|
|
194
|
+
} catch (error) {
|
|
195
|
+
if (error?.code === 'ENOENT') continue
|
|
196
|
+
throw error
|
|
197
|
+
}
|
|
198
|
+
const validOwner = incumbent && Number.isInteger(incumbent.pid) && Number.isFinite(incumbent.time)
|
|
199
|
+
const staleDeadOwner = validOwner && now() - incumbent.time >= staleMs && !processAlive(incumbent.pid)
|
|
200
|
+
const stalePartialOwner = !validOwner && lockAge >= staleMs
|
|
201
|
+
if (staleDeadOwner || stalePartialOwner) {
|
|
202
|
+
let mayReclaim = !await exists(manifestPath, fileStat)
|
|
203
|
+
if (!mayReclaim) {
|
|
204
|
+
const hasLock = await exists(lockDir, fileStat)
|
|
205
|
+
if (!hasLock) {
|
|
206
|
+
const existing = await inspectExisting({ environmentDir, manifestPath, lockPath, packages, fileStat })
|
|
207
|
+
mayReclaim = existing?.status === 'blocked'
|
|
208
|
+
} else {
|
|
209
|
+
const manifest = await readManifest(manifestPath)
|
|
210
|
+
mayReclaim = isBlockedManifest(manifest, environmentDir)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (mayReclaim) {
|
|
214
|
+
const reclaimed = `${lockDir}.reclaimed.${owner.id}`
|
|
215
|
+
try {
|
|
216
|
+
await renamePath(lockDir, reclaimed)
|
|
217
|
+
await removePath(reclaimed, { recursive: true, force: true })
|
|
218
|
+
reclaimedDeadOwner = true
|
|
219
|
+
continue
|
|
220
|
+
} catch (error) {
|
|
221
|
+
if (error?.code !== 'ENOENT') throw error
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
await retryDelay(retryDelayMs)
|
|
226
|
+
}
|
|
227
|
+
throw new Error('Timed out waiting for the Python environment lock.')
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function releaseLock(lockDir, owner, removePath) {
|
|
231
|
+
try {
|
|
232
|
+
const incumbent = JSON.parse(await readFile(resolve(lockDir, 'owner.json'), 'utf8'))
|
|
233
|
+
if (incumbent.id === owner.id) await removePath(lockDir, { recursive: true, force: true })
|
|
234
|
+
} catch (error) {
|
|
235
|
+
if (error?.code !== 'ENOENT') throw error
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function ensureContained(runDir, path, platform) {
|
|
240
|
+
const api = win32.isAbsolute(runDir) ? win32 : pathApi(platform)
|
|
241
|
+
const relative = api.relative(runDir, path)
|
|
242
|
+
if (relative === '' || (!relative.startsWith('..') && !api.isAbsolute(relative))) return path
|
|
243
|
+
throw new Error('Python environment path escapes the run directory.')
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function freezeRequirements(runCommand, pythonPath, executionPythonPath, runDir, stagedLockPath, finalLockPath, commands) {
|
|
247
|
+
const freezeArgs = ['-m', 'pip', 'freeze']
|
|
248
|
+
commands.push({ command: pythonPath, args: freezeArgs, stdoutFile: finalLockPath })
|
|
249
|
+
assertSuccess(
|
|
250
|
+
await runCommand(executionPythonPath, freezeArgs, { cwd: runDir, stdoutFile: stagedLockPath }),
|
|
251
|
+
'pip freeze',
|
|
252
|
+
)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export async function createPythonEnvironment(input) {
|
|
256
|
+
const platform = input.platform ?? process.platform
|
|
257
|
+
const resolvePath = input.resolvePath ?? resolve
|
|
258
|
+
const runDir = resolvePath(input.runDir)
|
|
259
|
+
const environmentDir = ensureContained(runDir, resolvePath(runDir, '.python-env'), platform)
|
|
260
|
+
const lockPath = ensureContained(runDir, resolvePath(runDir, 'requirements.lock'), platform)
|
|
261
|
+
const manifestPath = ensureContained(runDir, resolvePath(runDir, 'python-environment.json'), platform)
|
|
262
|
+
const transactionLockDir = ensureContained(runDir, resolvePath(runDir, '.python-environment.lock'), platform)
|
|
263
|
+
const packages = input.packages.map(validatePackageSpec)
|
|
264
|
+
const runCommand = input.runCommand ?? defaultRun
|
|
265
|
+
const fileStat = input.fileStat ?? stat
|
|
266
|
+
const renamePath = input.renamePath ?? rename
|
|
267
|
+
const removePath = input.removePath ?? rm
|
|
268
|
+
const retryDelay = input.retryDelay ?? ((milliseconds) => new Promise((resolveDelay) => setTimeout(resolveDelay, milliseconds)))
|
|
269
|
+
const ownerOptions = {
|
|
270
|
+
lockDir: transactionLockDir,
|
|
271
|
+
manifestPath,
|
|
272
|
+
environmentDir,
|
|
273
|
+
lockPath,
|
|
274
|
+
packages,
|
|
275
|
+
fileStat,
|
|
276
|
+
now: input.now ?? Date.now,
|
|
277
|
+
processAlive: input.processAlive ?? defaultProcessAlive,
|
|
278
|
+
retryDelay,
|
|
279
|
+
retryDelayMs: input.lockRetryDelayMs ?? 50,
|
|
280
|
+
attempts: input.lockAttempts ?? 100,
|
|
281
|
+
staleMs: input.lockStaleMs ?? 30_000,
|
|
282
|
+
renamePath,
|
|
283
|
+
removePath,
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
await mkdir(runDir, { recursive: true })
|
|
287
|
+
const hasRuntime = Boolean(input.capabilities.tools?.uv?.status === 'candidate' || input.capabilities.tools?.python?.status === 'candidate')
|
|
288
|
+
if (await exists(manifestPath, fileStat)) {
|
|
289
|
+
const existing = await inspectExisting({ environmentDir, manifestPath, lockPath, packages, fileStat })
|
|
290
|
+
if (existing?.status === 'ready' || !hasRuntime) return existing
|
|
291
|
+
}
|
|
292
|
+
const { owner, reclaimedDeadOwner } = await acquireLock(ownerOptions)
|
|
293
|
+
let committed = false
|
|
294
|
+
try {
|
|
295
|
+
if (reclaimedDeadOwner && await exists(environmentDir, fileStat) && !await exists(manifestPath, fileStat)) {
|
|
296
|
+
await removePath(environmentDir, { recursive: true, force: true })
|
|
297
|
+
await removePath(lockPath, { force: true }).catch(() => {})
|
|
298
|
+
}
|
|
299
|
+
const existing = await inspectExisting({ environmentDir, manifestPath, lockPath, packages, fileStat })
|
|
300
|
+
if (existing?.status === 'ready') return existing
|
|
301
|
+
|
|
302
|
+
const uv = await validateExecutable(input.capabilities.tools?.uv, 'uv', platform, fileStat)
|
|
303
|
+
const python = await validateExecutable(input.capabilities.tools?.python, 'python', platform, fileStat)
|
|
304
|
+
if (existing?.status === 'blocked') {
|
|
305
|
+
if (!uv && !python) return existing
|
|
306
|
+
await removePath(manifestPath, { force: true })
|
|
307
|
+
}
|
|
308
|
+
const commands = []
|
|
309
|
+
if (!uv && !python) {
|
|
310
|
+
const manifest = {
|
|
311
|
+
schemaVersion: 1, status: 'blocked', adapter: null, environmentDir, python: null,
|
|
312
|
+
requestedPackages: packages, lockPath: null, commands,
|
|
313
|
+
reason: 'No uv or Python runtime candidate is available.',
|
|
314
|
+
}
|
|
315
|
+
await atomicWriteJson(manifestPath, manifest)
|
|
316
|
+
return manifest
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const attemptId = randomUUID()
|
|
320
|
+
const stagedLockPath = ensureContained(runDir, resolvePath(runDir, `requirements.lock.${attemptId}.tmp`), platform)
|
|
321
|
+
const stagedManifestPath = ensureContained(runDir, resolvePath(runDir, `python-environment.json.${attemptId}.tmp`), platform)
|
|
322
|
+
const finalPythonPath = environmentPython(environmentDir, platform, resolvePath)
|
|
323
|
+
try {
|
|
324
|
+
let adapter
|
|
325
|
+
if (uv) {
|
|
326
|
+
adapter = 'uv'
|
|
327
|
+
const createArgs = ['venv', '--seed', environmentDir]
|
|
328
|
+
commands.push({ command: uv.executable, args: createArgs })
|
|
329
|
+
assertSuccess(await runCommand(uv.executable, createArgs, { cwd: runDir }), 'uv venv')
|
|
330
|
+
if (packages.length) {
|
|
331
|
+
const installArgs = ['pip', 'install', '--python', finalPythonPath, ...packages]
|
|
332
|
+
commands.push({ command: uv.executable, args: installArgs })
|
|
333
|
+
assertSuccess(await runCommand(uv.executable, installArgs, { cwd: runDir }), 'uv pip install')
|
|
334
|
+
}
|
|
335
|
+
} else {
|
|
336
|
+
adapter = 'venv'
|
|
337
|
+
const createArgs = pythonVenvArgs(python, environmentDir, platform)
|
|
338
|
+
commands.push({ command: python.executable, args: createArgs })
|
|
339
|
+
assertSuccess(await runCommand(python.executable, createArgs, { cwd: runDir }), 'python venv')
|
|
340
|
+
if (packages.length) {
|
|
341
|
+
const installArgs = ['-m', 'pip', 'install', ...packages]
|
|
342
|
+
commands.push({ command: finalPythonPath, args: installArgs })
|
|
343
|
+
assertSuccess(await runCommand(finalPythonPath, installArgs, { cwd: runDir }), 'pip install')
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
await mkdir(environmentDir, { recursive: true })
|
|
347
|
+
await freezeRequirements(runCommand, finalPythonPath, finalPythonPath, runDir, stagedLockPath, lockPath, commands)
|
|
348
|
+
const manifest = {
|
|
349
|
+
schemaVersion: 1, status: 'ready', adapter, environmentDir, python: finalPythonPath,
|
|
350
|
+
requestedPackages: packages, lockPath, commands,
|
|
351
|
+
}
|
|
352
|
+
await writeFile(stagedManifestPath, `${JSON.stringify(manifest, null, 2)}\n`)
|
|
353
|
+
await renamePath(stagedLockPath, lockPath)
|
|
354
|
+
await renamePath(stagedManifestPath, manifestPath)
|
|
355
|
+
committed = true
|
|
356
|
+
return manifest
|
|
357
|
+
} catch (error) {
|
|
358
|
+
committed = await exists(manifestPath, fileStat)
|
|
359
|
+
if (!committed) {
|
|
360
|
+
await removePath(stagedLockPath, { force: true }).catch(() => {})
|
|
361
|
+
await removePath(stagedManifestPath, { force: true }).catch(() => {})
|
|
362
|
+
await removePath(lockPath, { force: true }).catch(() => {})
|
|
363
|
+
await removePath(environmentDir, { recursive: true, force: true }).catch(() => {})
|
|
364
|
+
}
|
|
365
|
+
throw error
|
|
366
|
+
}
|
|
367
|
+
} finally {
|
|
368
|
+
const release = releaseLock(transactionLockDir, owner, removePath)
|
|
369
|
+
if (committed) await release.catch(() => {})
|
|
370
|
+
else await release
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export function parseArguments(argv) {
|
|
375
|
+
const result = { packages: [] }
|
|
376
|
+
const nextValue = (index, option) => {
|
|
377
|
+
const value = argv[index + 1]
|
|
378
|
+
if (value === undefined || value.startsWith('--')) throw new Error(`missing value for ${option}`)
|
|
379
|
+
return value
|
|
380
|
+
}
|
|
381
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
382
|
+
const value = argv[index]
|
|
383
|
+
if (value === '--run-dir') result.runDir = nextValue(index++, value)
|
|
384
|
+
else if (value === '--capabilities') result.capabilitiesPath = nextValue(index++, value)
|
|
385
|
+
else if (value === '--package') result.packages.push(nextValue(index++, value))
|
|
386
|
+
else throw new Error(`unknown argument: ${value}`)
|
|
387
|
+
}
|
|
388
|
+
if (!result.runDir || !result.capabilitiesPath) {
|
|
389
|
+
throw new Error('usage: python-environment.mjs --run-dir <path> --capabilities <json> [--package <spec>]')
|
|
390
|
+
}
|
|
391
|
+
return result
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async function main() {
|
|
395
|
+
const args = parseArguments(process.argv.slice(2))
|
|
396
|
+
const capabilities = JSON.parse(await readFile(args.capabilitiesPath, 'utf8'))
|
|
397
|
+
const manifest = await createPythonEnvironment({ runDir: args.runDir, packages: args.packages, capabilities })
|
|
398
|
+
process.stdout.write(`${JSON.stringify(manifest, null, 2)}\n`)
|
|
399
|
+
if (manifest.status === 'blocked') process.exitCode = 2
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const invokedPath = process.argv[1]
|
|
403
|
+
if (invokedPath && import.meta.url === pathToFileURL(invokedPath).href) {
|
|
404
|
+
main().catch((error) => {
|
|
405
|
+
process.stderr.write(`${error.message}\n`)
|
|
406
|
+
process.exitCode = 1
|
|
407
|
+
})
|
|
408
|
+
}
|